commit 98d84a65f0040c8368bcf6c7cd2c1ae55597b743 Author: Flechazo <2558755403@qq.com> Date: Mon Sep 21 09:53:46 2026 +0800 init diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f03a561 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +* text=auto + +*.go text eol=lf +*.hs text eol=lf +*.cabal text eol=lf +*.proto text eol=lf +*.py text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.json text eol=lf +*.md text eol=lf +*.cs text eol=lf +*.csproj text eol=lf +*.ps1 text eol=crlf + +*.dll binary +*.exe binary +*.bin binary +*.pb binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04b5e9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Local editor and workspace state +.vscode/ +.vs/ +.idea/ +*.suo +*.user +.env +.env.* +!.env.example +go/config.json + +# Project notes and local/runtime data are intentionally outside source control +docs/ +data/ + +# Release and language build products +.build/ +dist/ +**/dist-newstyle/ +go/.cache/ +go/bd2server.exe +go/bd2server +go/*.test +go/coverage.out +*.prof +*.pprof +go/internal/statebridge/tool/bd2-state.exe +plugins/**/bin/ +plugins/**/obj/ +TestResults/ +.ghc.environment.* + +# Python caches and temporary files +__pycache__/ +**/__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.coverage + +# General temporary/log output +*.log +*.tmp +*.temp +*.bak +*.orig +*~ +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7289d4c --- /dev/null +++ b/LICENSE @@ -0,0 +1,76 @@ +BD2 PROJECT TERMS OF USE (ALL RIGHTS RESERVED) + +Copyright © 2026 Flechazo. All rights reserved. “Project rights holder” in these terms means Flechazo. + +These terms apply only to the original code, documentation, and configuration of this project that the project rights holder lawfully owns or has the right to license (the “Project Materials”). Obtaining a copy, viewing a public repository, or being able to download or compile the Project Materials does not grant an open-source license or any rights not expressly granted by these terms. +These terms do not retroactively revoke any prior lawful license granted by the actual rights holder that remains in effect. + +1. Limited license to use + + Subject to these terms and applicable law, a recipient who has lawfully obtained the Project Materials through a channel designated by the project rights holder may view, compile, run, and modify them for the recipient's own personal learning, research, and local non-commercial testing. This license is non-exclusive, non-transferable, and non-sublicensable; modified materials remain subject to these terms. Use of the Project Materials must not infringe the rights of third parties. + +2. Restrictions on distribution and commercial use + + The Project Materials, including all or part of any copy, modified or derivative version, build output, or installation package, may be published or distributed only by the project rights holder through channels it designates. Without the project rights holder's prior express written permission, no other person may: + + - copy for, forward to, upload for, mirror for, host in a public repository for, redistribute to, or otherwise make available to any third party the Project Materials or any modified version or build output; + - sell, rent, provide for a fee, bundle for sale, monetize through advertising, or otherwise commercialize the Project Materials; + - use the Project Materials to provide a server, hosting, managed service, or paid access to the public; + - remove or alter copyright notices, license terms, source attributions, or disclaimers in the Project Materials. + + Permission for any of the above must be expressly given by the project rights holder. Silence, public availability, and receipt of a copy do not constitute permission. Rights expressly granted by law that cannot be excluded by contract remain unaffected. + +3. Third-party rights and unofficial status + + Brown Dust II and the associated names, trademarks, game client, images, audio, story, protocols, game data, and other third-party materials belong to their respective rights holders. This is an unofficial local development and research project and is not affiliated with, authorized, sponsored, or endorsed by the game or its rights holders. These terms grant no right to use, copy, modify, or distribute the game client, game data, trademarks, or other third-party materials. Third-party software dependencies, tools, and their respective licenses continue to apply independently; these terms do not alter those licenses. + +4. Disclaimer and limitation of liability + + The Project Materials are provided “AS IS” and “AS AVAILABLE,” without any express or implied warranty of correctness, security, fitness for a particular purpose, continued availability, or non-infringement. Users are responsible for determining whether obtaining, running, and using the Project Materials complies with applicable law, relevant agreements, and third-party rights, and assume the risks arising from their use, modification of the game client, handling of game data, or operation of a local server. To the fullest extent permitted by applicable law, the project rights holder is not liable for losses resulting from the use of, or inability to use, the Project Materials. These terms do not exclude liability that cannot lawfully be excluded. + +5. Breach of terms + + The limited license granted by these terms terminates automatically upon breach. Termination does not affect the project rights holder's right to seek remedies under applicable law, or the provisions concerning third-party rights, disclaimers, and limitations of liability. + +Any authorization beyond these terms requires the project rights holder's prior express written permission. + +—— 中文译文 / Chinese translation —— + +BD2 项目使用条款(保留全部权利) + +版权所有 © 2026 Flechazo。保留全部权利。本条款所称“项目权利人”为 Flechazo。 + +本条款仅适用于项目权利人依法拥有或有权许可的本项目原创代码、文档和配置(下称“项目内容”)。获得副本、看到公开仓库或能够下载、编译项目内容,并不意味着获得开放源代码许可或任何未在本条款中明确授予的权利。 +本条款不追溯撤销任何此前由实际权利人合法授予且仍有效的许可。 + +1. 有限使用许可 + + 在遵守本条款和适用法律、并且已通过项目权利人指定渠道合法取得项目内容的前提下,接收者可为个人学习、研究及本地非商业测试而查看、编译、运行和为自身使用修改项目内容。此项许可是非独占、不可转让、不可再许可的;修改后的内容仍受本条款限制。使用项目内容不应侵犯第三方的权利。 + +2. 传播与商业使用限制 + + 项目内容及其全部或部分副本、修改版、衍生版本、构建产物和安装包,仅可由项目权利人通过其指定渠道发布或传播。未经项目权利人事先明确书面许可,其他人不得: + + - 向第三方复制、转发、上传、镜像、公开仓库托管、再分发或以其他方式提供项目内容及其修改版、构建产物; + - 销售、出租、收费提供、捆绑销售、用于广告变现或以其他方式将项目内容商业化; + - 基于项目内容向公众提供服务器、托管服务、代运营服务或付费访问; + - 去除或篡改项目内容中的版权、许可证、来源和免责声明。 + + 对上述行为的许可必须由项目权利人明确作出;沉默、可公开访问以及收到本项目副本均不构成许可。法律明确允许且不得以合同排除的权利不受本条款限制。 + +3. 第三方权利与非官方性质 + + Brown Dust II 及相关名称、商标、游戏客户端、图像、音频、剧情、协议、游戏数据与其他第三方素材,归各自权利人所有。本项目是非官方本地开发与研究项目,与游戏及其权利人不存在隶属、授权、赞助或认可关系。本条款不授予任何游戏客户端、游戏数据、商标或其他第三方内容的使用、复制、修改或传播权。项目使用的第三方软件依赖、工具及其许可证仍分别适用;本条款不变更这些独立许可。 + +4. 免责声明与责任限制 + + 项目内容按“现状”和“可用状态”提供,不对正确性、安全性、适用性、持续可用性或不侵权作任何明示或默示保证。使用者自行确认其获取、运行和使用行为符合适用法律、相关协议及第三方权利要求,并自行承担使用、修改客户端、处理游戏数据或运行本地服务所产生的风险。于适用法律允许的最大范围内,项目权利人不对使用或无法使用项目内容造成的损失承担责任;本条款不排除依法不得排除的责任。 + +5. 违反条款 + + 违反本条款时,本条款授予的有限许可自动终止。终止不影响项目权利人依据法律寻求救济的权利,也不影响本条款中关于第三方权利、免责与责任限制的规定。 + +任何超出本条款的授权,须事先取得项目权利人的明确书面许可。 + +The English and Chinese texts are two language versions of the same terms. In the event of any discrepancy in interpretation, the English version controls. +英文和中文是同一条款的两种文字版本;如有解释差异,以英文版本为准。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bde3a9f --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# BD2 本地服务器 + +用于本地开发、协议研究和服务端实现练习。服务默认只监听环回地址,不应暴露到公网。 + +## 许可与免责声明 + +本项目原创内容由 Flechazo 保留全部权利,具体中英双语条款见 [LICENSE](LICENSE)。通过项目权利人指定渠道合法取得副本的人,仅可用于个人学习、研究和本地非商业测试;未经项目权利人事先明确书面许可,禁止向第三方转发、镜像、上传、二次分发项目源码或构建产物,禁止售卖、收费提供、商业化运营或对公众开放基于本项目的服务。公开可见不代表允许转载或再分发。 + +本项目是非官方开发与研究项目,与 Brown Dust II 及其权利人无隶属或授权关系。游戏名称、商标、客户端、游戏资源和数据归各自权利人所有;本项目的许可不覆盖它们,也不变更第三方依赖的许可证。项目按现状提供,无正确性、安全性、可用性或不侵权保证。使用者须自行确认适用法律、相关协议和第三方权利要求,并自行承担使用、客户端修改及本地服务运行风险。 + +## 开发构建 + +```powershell +# Go +Push-Location .\go +$env:GOCACHE = (Join-Path $PWD '.cache\go-build') +go test ./... +go build .\cmd\bd2server +Pop-Location + +# Haskell +Push-Location .\haskell +cabal build all --enable-tests +cabal test all --test-show-details=direct +Pop-Location + +$env:PYTHONDONTWRITEBYTECODE = '1' +python -m unittest discover -s .\tools\python\tests -p 'test_*.py' -v +``` + +## 单文件发布 + +```powershell +.\build-release.ps1 +``` + +脚本构建并测试 Haskell CLI,将它嵌入 Go 服务,最终只生成 `.build\bin\bd2server.exe`。运行时 helper 位于临时目录并在进程退出时清理,不需要分发第二个可执行文件。Go 构建缓存只写入 `go\.cache`。运行服务仍需项目的本地配置和资源;“单文件”指两个程序的发布产物合并为一个 exe,不代表把外部资源打进程序。 + +## 客户端插件项目 + +- `plugins/LocalIdentity/`:本地服务端客户端专用插件。 +- `plugins/CaptureEnvironment/`:独立原版对照客户端的抓包插件;不要装进本地服务端客户端。 + +## 首次安装本地客户端 + +先设置客户端目录,修改客户端入口,并构建、安装 `BD2LocalIdentity.dll`: + +```powershell +$clientDir = "<客户端目录>" + +.\.build\bin\bd2server.exe patch-client ` + --game-dir $clientDir + +dotnet build .\plugins\LocalIdentity\LocalIdentity.csproj ` + -c Release ` + -p:GameDir="$clientDir" + +$pluginDir = Join-Path $clientDir 'BepInEx\plugins' +New-Item -ItemType Directory -Force $pluginDir | Out-Null +Copy-Item ` + .\plugins\LocalIdentity\bin\Release\netstandard2.1\BD2LocalIdentity.dll ` + (Join-Path $pluginDir 'BD2LocalIdentity.dll') ` + -Force +``` + +客户端必须已经安装并加载 `BD2LocalIdentity.dll`,否则不能按本地服流程登录。不要把原版抓包插件 `BD2CaptureEnvironment.dll` 安装进这个客户端。 + +## 启动顺序 + +顺序必须是: + +1. 确认 `BD2LocalIdentity.dll` 已安装在本地客户端的 `BepInEx\plugins`。 +2. 启动服务器。 +3. 健康检查返回成功后,再启动游戏客户端。 + +启动服务器: + +```powershell +Push-Location .\go +& ..\.build\bin\bd2server.exe serve ` + --cdn "" ` + --game-data "" ` + --game-data-version "" +Pop-Location +``` + +启动时先核验资源,Go 再调用内嵌的 Haskell CLI 检查和修复存档;通过后加载账号与玩法服务,最后监听指定地址(默认 `127.0.0.1:8080`)。本地身份插件目前固定连接该默认地址;要更换地址,需要同步修改插件。 + +健康检查: + +```powershell +Invoke-WebRequest http://127.0.0.1:8080/healthz +``` + +健康检查成功后才能启动本地客户端。服务器尚未监听时启动客户端,会在登录或维护信息阶段连接失败。 + +## 修改客户端 + +构建服务后运行: + +```powershell +.\.build\bin\bd2server.exe patch-client --game-dir "<客户端目录>" --verify +.\.build\bin\bd2server.exe patch-client --game-dir "<客户端目录>" +``` diff --git a/build-release.ps1 b/build-release.ps1 new file mode 100644 index 0000000..dfba805 --- /dev/null +++ b/build-release.ps1 @@ -0,0 +1,57 @@ +[CmdletBinding()] +param([switch]$SkipTests) + +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot +$goRoot = Join-Path $root 'go' +$haskellRoot = Join-Path $root 'haskell' +$outputDir = Join-Path $root '.build\bin' +$embedDir = Join-Path $goRoot 'internal\statebridge\tool' +$embedTool = Join-Path $embedDir 'bd2-state.exe' + +$env:GOCACHE = Join-Path $goRoot '.cache\go-build' +New-Item -ItemType Directory -Force -Path $env:GOCACHE, $outputDir, $embedDir | Out-Null +if (Test-Path -LiteralPath $embedTool) { Remove-Item -LiteralPath $embedTool -Force } + +Push-Location $root +try { + protoc --proto_path=proto --go_out=go --go_opt=module=bd2server ` + proto/bd2/state/v1/state.proto ` + proto/bd2/state/control/v1/control.proto ` + proto/bd2/state/v2/state.proto ` + proto/bd2/state/control/v2/control.proto + if ($LASTEXITCODE -ne 0) { throw "protoc failed with exit code $LASTEXITCODE" } +} finally { Pop-Location } + +Push-Location $haskellRoot +try { + cabal build all --enable-tests + if ($LASTEXITCODE -ne 0) { throw "cabal build failed with exit code $LASTEXITCODE" } + if (-not $SkipTests) { + cabal test all --test-show-details=direct + if ($LASTEXITCODE -ne 0) { throw "cabal test failed with exit code $LASTEXITCODE" } + } + $haskellTool = (cabal list-bin exe:bd2-state).Trim() + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $haskellTool)) { + throw 'cannot locate the Cabal-built bd2-state executable' + } + Copy-Item -LiteralPath $haskellTool -Destination $embedTool -Force +} finally { Pop-Location } + +try { + Push-Location $goRoot + try { + if (-not $SkipTests) { + go test ./... + if ($LASTEXITCODE -ne 0) { throw "go test failed with exit code $LASTEXITCODE" } + go vet ./... + if ($LASTEXITCODE -ne 0) { throw "go vet failed with exit code $LASTEXITCODE" } + } + $output = Join-Path $outputDir 'bd2server.exe' + go build -tags embedded_state_tool -trimpath -ldflags '-s -w' -o $output .\cmd\bd2server + if ($LASTEXITCODE -ne 0) { throw "go build failed with exit code $LASTEXITCODE" } + Write-Host "Built single-file release: $output" + } finally { Pop-Location } +} finally { + if (Test-Path -LiteralPath $embedTool) { Remove-Item -LiteralPath $embedTool -Force } +} diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go new file mode 100644 index 0000000..2d44d96 --- /dev/null +++ b/go/cmd/bd2server/main.go @@ -0,0 +1,347 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "time" + + "bd2server/internal/account" + "bd2server/internal/battle" + "bd2server/internal/bootstrap" + "bd2server/internal/deck" + "bd2server/internal/feature" + "bd2server/internal/gacha" + "bd2server/internal/gamedata" + "bd2server/internal/introdb" + "bd2server/internal/mail" + "bd2server/internal/missions" + "bd2server/internal/pictorial" + "bd2server/internal/player" + "bd2server/internal/progress" + "bd2server/internal/readonly" + "bd2server/internal/schedule" + "bd2server/internal/session" + "bd2server/internal/transport" + "bd2server/internal/world" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + var err error + switch os.Args[1] { + case "serve": + err = serve(os.Args[2:]) + case "patch-client": + err = patchClient(os.Args[2:]) + case "state": + err = stateCommand(os.Args[2:]) + case "help", "-h", "--help": + usage() + return + default: + err = fmt.Errorf("unknown command %q", os.Args[1]) + } + if err != nil { + slog.Error("command failed", "command", os.Args[1], "error", err) + os.Exit(1) + } +} + +func serve(args []string) error { + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + listen := fs.String("listen", "127.0.0.1:8080", "local listen address") + cdn := fs.String("cdn", "", "ServerData root (required)") + gameData := fs.String("game-data", "", "versioned GameData root (required)") + gameDataVersion := fs.String("game-data-version", "", "validated GameData version (required)") + gameDataOrigin := fs.String("game-data-origin", "https://dl.bd2.pmang.cloud/GameData", "official repair source used only when local validation fails") + accountSeed := fs.String("account-seed", `seed\v2_34_13\login_user.json`, "versioned local account seed") + playerSeed := fs.String("player-seed", `seed\v2_34_13\starter_player.json`, "versioned starter inventory and characters") + readonlySeed := fs.String("readonly-seed", `seed\v2_34_13\readonly.json`, "versioned server schedules and optional feature defaults") + mailSeed := fs.String("mail-seed", `seed\v2_34_13\mail.json`, "versioned starter mailbox") + stateFile := fs.String("state", `..\data\state\progress.json`, "local player progress save") + deckSeed := fs.String("deck-seed", `seed\v2_34_13\decks.json`, "versioned starter deck") + deckState := fs.String("deck-state", `..\data\state\deck.json`, "local deck and waypoint save") + worldSeed := fs.String("world-seed", `seed\v2_34_13\world.json`, "versioned starter world") + stateTool := fs.String("state-tool", "", "optional Haskell state tool override (release builds embed it)") + if err := fs.Parse(args); err != nil { + return err + } + if *cdn == "" || *gameData == "" || *gameDataVersion == "" { + return errors.New("serve requires --cdn, --game-data, and --game-data-version") + } + base := "http://" + *listen + "/game/" + cfg := bootstrap.Config{ + BaseURL: base, + CDNURL: "http://" + *listen + "/assets/ServerData", + Version: bootstrap.ClientVersion, + BundleVer: bootstrap.BundleVersion, + GameDataURL: "http://" + *listen + "/assets/GameData", + GameDataVer: *gameDataVersion, + } + if err := cfg.Validate(); err != nil { + return err + } + if info, err := os.Stat(*cdn); err != nil || !info.IsDir() { + return fmt.Errorf("CDN directory is unavailable: %q", *cdn) + } + verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, filepath.Clean(*gameData), *gameDataVersion, *gameDataOrigin) + if err != nil { + return fmt.Errorf("refuse to advertise unavailable or unverified GameData: %w", err) + } + if downloaded { + slog.Info("repaired GameData from official CDN", "archive", verifiedGameData.ArchivePath, "entries", verifiedGameData.EntryCount) + } + login, err := account.Load(filepath.Clean(*accountSeed)) + if err != nil { + return fmt.Errorf("load local account seed: %w", err) + } + starter, err := player.Load(filepath.Clean(*playerSeed)) + if err != nil { + return fmt.Errorf("load starter player: %w", err) + } + regularGacha, err := gamedata.LoadRegularCostumeGacha(filepath.Clean(*gameData), *gameDataVersion) + if err != nil { + return fmt.Errorf("load regular gacha GameData: %w", err) + } + if err := repairStateBeforeServe(filepath.Dir(filepath.Clean(*stateFile)), *stateTool, *gameDataVersion, regularGacha, starter); err != nil { + return fmt.Errorf("state migration/repair: %w", err) + } + serverConfig, err := readonly.Load(filepath.Clean(*readonlySeed)) + if err != nil { + return fmt.Errorf("load readonly server configuration: %w", err) + } + mailbox, err := mail.Load(filepath.Clean(*mailSeed)) + if err != nil { + return fmt.Errorf("load starter mailbox: %w", err) + } + progressState, err := progress.OpenStore(*stateFile) + if err != nil { + return err + } + // The current local save has completed the first-gacha tutorial gate. + // Deriving this UI flag from authoritative story progress prevents a new + // account from seeing ordinary pools before the tutorial unlocks them. + login.SetFirstGacha(progressState.QuestCleared(37, 21)) + deckConfig, err := deck.LoadSeed(filepath.Clean(*deckSeed)) + if err != nil { + return fmt.Errorf("load starter deck: %w", err) + } + deckStateStore, err := deck.OpenStore(filepath.Clean(*deckState), deckConfig) + if err != nil { + return fmt.Errorf("load deck state: %w", err) + } + ownedItems, err := player.OpenInventory(filepath.Join(filepath.Dir(*stateFile), "items.json"), starter) + if err != nil { + return fmt.Errorf("load owned inventory: %w", err) + } + gold, freeJewelry, jewelry, mileage, err := login.SeedCurrencies() + if err != nil { + return fmt.Errorf("read account seed currency: %w", err) + } + hopePowder, err := login.SeedHopePowder() + if err != nil { + return fmt.Errorf("read account seed hope powder: %w", err) + } + wallet, err := player.OpenWallet(filepath.Join(filepath.Dir(*stateFile), "wallet.json"), player.Currency{ + Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Mileage: mileage, HopePowder: hopePowder, + }) + if err != nil { + return fmt.Errorf("load wallet state: %w", err) + } + if err := login.AttachCurrencies(wallet); err != nil { + return fmt.Errorf("attach wallet to login: %w", err) + } + mailService, err := mail.OpenService(filepath.Join(filepath.Dir(*stateFile), "mail.json"), mailbox, ownedItems, wallet) + if err != nil { + return fmt.Errorf("load mail state: %w", err) + } + missionDesign, err := gamedata.LoadMissionDesign(filepath.Clean(*gameData), *gameDataVersion) + if err != nil { + return fmt.Errorf("load mission GameData: %w", err) + } + missionService, err := missions.Open(filepath.Join(filepath.Dir(*stateFile), "missions.json"), missionDesign, ownedItems) + if err != nil { + return fmt.Errorf("load mission state: %w", err) + } + if err := missionService.AttachWallet(wallet); err != nil { + return fmt.Errorf("attach mission wallet: %w", err) + } + if err := missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 101}); err != nil { + return fmt.Errorf("record daily login mission: %w", err) + } + if err := missionService.SetProgress(gamedata.MissionKey{GroupType: 1, GroupID: 2, ID: 201}, 1); err != nil { + return fmt.Errorf("record weekly login mission: %w", err) + } + ownedEquipment, err := player.OpenEquipmentInventory(filepath.Join(filepath.Dir(*stateFile), "equipment.json")) + if err != nil { + return fmt.Errorf("load owned equipment: %w", err) + } + collection, err := player.OpenCollectionStore(filepath.Join(filepath.Dir(*stateFile), "collection.json"), starter.Costumes) + if err != nil { + return fmt.Errorf("load owned collection: %w", err) + } + infiniteGacha, err := gamedata.LoadInfiniteGacha(filepath.Clean(*gameData), *gameDataVersion) + if err != nil { + return fmt.Errorf("load infinite gacha GameData: %w", err) + } + equipmentGacha, err := gamedata.LoadEquipmentGacha(filepath.Clean(*gameData), *gameDataVersion) + if err != nil { + return fmt.Errorf("load equipment gacha GameData: %w", err) + } + gachaService, err := gacha.NewService(infiniteGacha, regularGacha, collection, wallet) + if err != nil { + return err + } + gachaService.AttachInventory(ownedItems) + gachaService.AttachEquipmentGacha(equipmentGacha, ownedEquipment) + gachaService.AttachPreviewMission(func() error { + return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 111}) + }) + worldService, err := world.Load(filepath.Clean(*worldSeed), filepath.Clean(*gameData), *gameDataVersion, + filepath.Join(filepath.Dir(*stateFile), "characters.json"), progressState, starter, ownedEquipment, ownedItems, wallet) + if err != nil { + return fmt.Errorf("load world state: %w", err) + } + if err := collection.BindBaseCharacters(worldService.CharacterService().RawAll()); err != nil { + return fmt.Errorf("bind base collection characters: %w", err) + } + if err := worldService.AttachCollection(collection); err != nil { + return fmt.Errorf("attach gacha collection state: %w", err) + } + if err := worldService.AttachDecks(deckStateStore); err != nil { + return fmt.Errorf("attach world deck state: %w", err) + } + if err := ownedEquipment.AttachCharacters(worldService.CharacterService()); err != nil { + return fmt.Errorf("attach equipment character state: %w", err) + } + pictorialDesign, err := gamedata.LoadPictorialDesign(filepath.Clean(*gameData), *gameDataVersion) + if err != nil { + return fmt.Errorf("load pictorial GameData: %w", err) + } + pictorialService := &pictorial.Service{Design: pictorialDesign, Owned: worldService} + if err := worldService.CharacterService().AttachMaxHealth(pictorialService.MaxHealth); err != nil { + return fmt.Errorf("attach pictorial character stats: %w", err) + } + battleService := battle.NewService(filepath.Clean(*gameData), *gameDataVersion, ownedItems) + battleService.AttachTutorialWin(func() error { + return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113}) + }) + battleService.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) { + _, buffs, err := pictorialService.Snapshot() + return buffs, err + }) + game, err := session.NewServerWithProgress(login, progressState, + battleService, + worldService, + worldService.CharacterService(), + progressState, + deckStateStore, + ownedItems, + ownedEquipment, + starter, + mailService, + gachaService, + missionService, + pictorialService, + schedule.Version23413(), + readonly.Service{Seed: serverConfig}, + feature.Service{}, + ) + if err != nil { + return err + } + dispatcher := transport.Bootstrap{Config: cfg} + handler := transport.HTTP{Dispatcher: dispatcher, Raw: game, CDNDir: filepath.Clean(*cdn), GameDataDir: filepath.Clean(*gameData)}.Handler() + server := &http.Server{ + Addr: *listen, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 20 * time.Second, + WriteTimeout: 20 * time.Second, + IdleTimeout: 60 * time.Second, + } + slog.Info("BD2 local server listening", "address", *listen, "client", cfg.Version, "bundle", cfg.BundleVer, "cdn", *cdn, "gameData", verifiedGameData.ArchivePath, "gameDataEntries", verifiedGameData.EntryCount, "accountSeed", *accountSeed) + return server.ListenAndServe() +} + +func patchClient(args []string) error { + fs := flag.NewFlagSet("patch-client", flag.ContinueOnError) + gameDir := fs.String("game-dir", "", "BrownDust II game directory (required)") + serverURL := fs.String("url", "http://127.0.0.1:8080/game/", "exactly 27-byte replacement LIVE_URL") + verify := fs.Bool("verify", false, "inspect the embedded LIVE_URL without writing") + if err := fs.Parse(args); err != nil { + return err + } + if *gameDir == "" { + return errors.New("patch-client requires --game-dir") + } + if *verify { + result, err := introdb.VerifyClient(*gameDir) + if err != nil { + return err + } + fmt.Printf("resources.assets: %s\nTextAsset pathID: %d\nLIVE_URL: %s\n", result.AssetsPath, result.ObjectPath, result.URL) + return nil + } + result, err := introdb.PatchClient(*gameDir, *serverURL) + if err != nil { + return err + } + disabled, err := disableLegacyPlugin(*gameDir) + if err != nil { + return err + } + verified, err := introdb.VerifyClient(*gameDir) + if err != nil { + return fmt.Errorf("post-patch verification: %w", err) + } + fmt.Printf("patched: %s\nbackup: %s\nLIVE_URL: %s\n", result.AssetsPath, result.BackupPath, verified.URL) + if disabled != "" { + fmt.Printf("legacy plugin disabled: %s\n", disabled) + } + return nil +} + +func disableLegacyPlugin(gameDir string) (string, error) { + source := filepath.Join(gameDir, "BepInEx", "plugins", "PluginLocalRes.dll") + destination := filepath.Join(gameDir, "BepInEx", "disabled", "PluginLocalRes.dll") + if _, err := os.Stat(source); errors.Is(err, os.ErrNotExist) { + return "", nil + } else if err != nil { + return "", fmt.Errorf("inspect legacy local-resource plugin: %w", err) + } + if _, err := os.Stat(destination); err == nil { + return "", fmt.Errorf("legacy plugin exists at both active and disabled paths") + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return "", err + } + if err := os.Rename(source, destination); err != nil { + return "", fmt.Errorf("disable legacy local-resource plugin: %w", err) + } + return destination, nil +} + +func usage() { + fmt.Fprintln(os.Stderr, `bd2server - BrownDust II 2.34.13 local development server + +Usage: + bd2server serve --cdn DIR --game-data DIR --game-data-version VERSION [options] + bd2server patch-client --game-dir DIR [options] + bd2server state check [options] + bd2server state migrate-v1-v2 [options] + bd2server state repair [options] + +The server binds to loopback by default and is intended for local research.`) +} diff --git a/go/cmd/bd2server/state.go b/go/cmd/bd2server/state.go new file mode 100644 index 0000000..2320d7b --- /dev/null +++ b/go/cmd/bd2server/state.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "encoding/hex" + "errors" + "flag" + "fmt" + "path/filepath" + + controlv1 "bd2server/gen/state/control/v1" + "bd2server/internal/gacha" + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/statebridge" +) + +func stateCommand(args []string) error { + if len(args) == 0 { + return errors.New("usage: bd2server state [options]") + } + switch args[0] { + case "check": + return stateCheckCommand(args[1:]) + case "migrate-v1-v2": + return stateMigrateV1ToV2Command(args[1:]) + case "repair": + return stateRepairCommand(args[1:]) + default: + return errors.New("usage: bd2server state [options]") + } +} + +func stateCheckCommand(args []string) error { + fs := flag.NewFlagSet("state check", flag.ContinueOnError) + stateDir := fs.String("state", filepath.FromSlash("../data/state"), "directory containing the nine JSON state files") + tool := fs.String("tool", "", "optional Haskell state tool override") + if err := fs.Parse(args); err != nil { + return err + } + resolved, cleanup, err := statebridge.ResolveTool(*tool) + if err != nil { + return err + } + defer cleanup() + result, err := statebridge.Check(context.Background(), filepath.Clean(*stateDir), resolved) + if err != nil { + return err + } + fmt.Printf("state SHA-256: %s\n", hex.EncodeToString(result.SourceSHA256[:])) + var errorsFound int + for _, violation := range result.Violations { + severity := violation.Severity.String() + fmt.Printf("%s %s %s: %s", severity, violation.Code, filepath.Join(violation.Path...), violation.Message) + if len(violation.RelatedIds) != 0 { + fmt.Printf(" ids=%v", violation.RelatedIds) + } + fmt.Println() + if violation.Severity == controlv1.Severity_SEVERITY_ERROR { + errorsFound++ + } + } + if errorsFound != 0 { + return fmt.Errorf("state check rejected with %d error(s)", errorsFound) + } + fmt.Printf("state check passed (%d warning(s))\n", len(result.Violations)) + return nil +} + +func stateMigrateV1ToV2Command(args []string) error { + fs := flag.NewFlagSet("state migrate-v1-v2", flag.ContinueOnError) + stateDir := fs.String("state", filepath.FromSlash("../data/state"), "directory containing the nine JSON state files") + tool := fs.String("tool", "", "optional Haskell state tool override") + if err := fs.Parse(args); err != nil { + return err + } + resolved, cleanup, err := statebridge.ResolveTool(*tool) + if err != nil { + return err + } + defer cleanup() + result, err := statebridge.MigrateV1ToV2(context.Background(), filepath.Clean(*stateDir), resolved) + if err != nil { + return err + } + fmt.Printf("source JSON SHA-256: %s\n", hex.EncodeToString(result.SourceSHA256[:])) + for _, violation := range result.Violations { + fmt.Printf("%s %s %s: %s\n", violation.Severity.String(), violation.Code, filepath.Join(violation.Path...), violation.Message) + } + if result.Target == nil { + return errors.New("state migration rejected; no files were changed") + } + fmt.Printf("target protobuf SHA-256: %s\n", hex.EncodeToString(result.TargetSHA256[:])) + fmt.Printf("V1 -> V2 migration passed: %d character(s), %d costume(s); no files were changed\n", + len(result.Target.Roster.Characters), len(result.Target.Roster.Costumes)) + return nil +} + +func stateRepairCommand(args []string) error { + fs := flag.NewFlagSet("state repair", flag.ContinueOnError) + stateDir := fs.String("state", filepath.FromSlash("../data/state"), "directory containing the nine JSON state files") + tool := fs.String("tool", "", "optional Haskell state tool override") + gameData := fs.String("game-data", "", "versioned GameData root (required)") + gameDataVersion := fs.String("game-data-version", "", "validated GameData version (required)") + playerSeed := fs.String("player-seed", `seed\v2_34_13\starter_player.json`, "versioned starter player seed") + apply := fs.Bool("apply", false, "install a changed repair result with rollback protection") + if err := fs.Parse(args); err != nil { + return err + } + if *gameData == "" || *gameDataVersion == "" { + return errors.New("state repair requires --game-data and --game-data-version") + } + resolved, cleanup, err := statebridge.ResolveTool(*tool) + if err != nil { + return err + } + defer cleanup() + catalog, err := gamedata.LoadRegularCostumeGacha(filepath.Clean(*gameData), *gameDataVersion) + if err != nil { + return err + } + starter, err := player.Load(filepath.Clean(*playerSeed)) + if err != nil { + return err + } + groupID, gachaIDs := gacha.StepUpMigrationFact() + validation, err := statebridge.BuildValidationContext(*gameDataVersion, catalog, starter, groupID, gachaIDs) + if err != nil { + return err + } + result, err := statebridge.Repair(context.Background(), filepath.Clean(*stateDir), resolved, validation) + if err != nil { + return err + } + fmt.Printf("source JSON SHA-256: %s\n", hex.EncodeToString(result.SourceSHA256[:])) + if result.Target == nil { + return violationsError("state repair rejected", result.Violations) + } + fmt.Printf("target protobuf SHA-256: %s\nchanged: %t\n", hex.EncodeToString(result.TargetSHA256[:]), result.Changed) + if result.Changed && *apply { + if err := statebridge.InstallRepair(filepath.Clean(*stateDir), result); err != nil { + return err + } + fmt.Println("repair installed; rollback protection cleared after complete commit") + } else if result.Changed { + fmt.Println("dry run only; pass --apply to install") + } else { + fmt.Println("state is already current; no files changed") + } + return nil +} diff --git a/go/cmd/bd2server/state_startup.go b/go/cmd/bd2server/state_startup.go new file mode 100644 index 0000000..2585e53 --- /dev/null +++ b/go/cmd/bd2server/state_startup.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + controlv1 "bd2server/gen/state/control/v1" + "bd2server/internal/gacha" + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/statebridge" +) + +func repairStateBeforeServe(stateDir, toolOverride, gameDataVersion string, catalog *gamedata.RegularGachaCatalog, starter *player.Starter) error { + if recovered, err := statebridge.RecoverInterruptedInstall(stateDir); err != nil { + return fmt.Errorf("recover interrupted state repair: %w", err) + } else if recovered { + slog.Warn("restored state after interrupted repair transaction") + } + complete, err := statebridge.CompleteStateAvailable(stateDir) + if err != nil { + return err + } + if !complete { + slog.Info("state repair deferred until all state domains exist") + return nil + } + tool, cleanup, err := statebridge.ResolveTool(toolOverride) + if err != nil { + return err + } + defer cleanup() + stepGroup, stepIDs := gacha.StepUpMigrationFact() + validation, err := statebridge.BuildValidationContext(gameDataVersion, catalog, starter, stepGroup, stepIDs) + if err != nil { + return err + } + result, err := statebridge.Repair(context.Background(), stateDir, tool, validation) + if err != nil { + return err + } + if result.Target == nil { + return violationsError("state repair rejected", result.Violations) + } + if result.Changed { + if err := statebridge.InstallRepair(stateDir, result); err != nil { + return fmt.Errorf("install repaired state: %w", err) + } + slog.Info("installed Haskell state repair", "targetSHA256", fmt.Sprintf("%x", result.TargetSHA256)) + } + verified, err := statebridge.Repair(context.Background(), stateDir, tool, validation) + if err != nil { + return fmt.Errorf("verify installed repair: %w", err) + } + if verified.Target == nil { + return violationsError("installed state rejected", verified.Violations) + } + if verified.Changed { + return errors.New("state repair is not idempotent; refusing to start") + } + return nil +} + +func violationsError(prefix string, violations []*controlv1.Violation) error { + parts := make([]string, 0, len(violations)) + for _, violation := range violations { + parts = append(parts, violation.Code+": "+violation.Message) + } + return fmt.Errorf("%s: %s", prefix, strings.Join(parts, "; ")) +} diff --git a/go/config.example.json b/go/config.example.json new file mode 100644 index 0000000..7730f4d --- /dev/null +++ b/go/config.example.json @@ -0,0 +1,7 @@ +{ + "listen": "127.0.0.1:8080", + "serverDataDir": "", + "gameDataDir": "", + "gameDataVersion": "", + "clientDir": "" +} diff --git a/go/gen/state/control/v1/control.pb.go b/go/gen/state/control/v1/control.pb.go new file mode 100644 index 0000000..b01eb2d --- /dev/null +++ b/go/gen/state/control/v1/control.pb.go @@ -0,0 +1,354 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v7.36.0 +// source: bd2/state/control/v1/control.proto + +package controlv1 + +import ( + v1 "bd2server/gen/state/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Severity int32 + +const ( + Severity_SEVERITY_UNSPECIFIED Severity = 0 + Severity_SEVERITY_WARNING Severity = 1 + Severity_SEVERITY_ERROR Severity = 2 +) + +// Enum value maps for Severity. +var ( + Severity_name = map[int32]string{ + 0: "SEVERITY_UNSPECIFIED", + 1: "SEVERITY_WARNING", + 2: "SEVERITY_ERROR", + } + Severity_value = map[string]int32{ + "SEVERITY_UNSPECIFIED": 0, + "SEVERITY_WARNING": 1, + "SEVERITY_ERROR": 2, + } +) + +func (x Severity) Enum() *Severity { + p := new(Severity) + *p = x + return p +} + +func (x Severity) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Severity) Descriptor() protoreflect.EnumDescriptor { + return file_bd2_state_control_v1_control_proto_enumTypes[0].Descriptor() +} + +func (Severity) Type() protoreflect.EnumType { + return &file_bd2_state_control_v1_control_proto_enumTypes[0] +} + +func (x Severity) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Severity.Descriptor instead. +func (Severity) EnumDescriptor() ([]byte, []int) { + return file_bd2_state_control_v1_control_proto_rawDescGZIP(), []int{0} +} + +type CheckRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + BridgeApiVersion uint32 `protobuf:"varint,1,opt,name=bridge_api_version,json=bridgeApiVersion,proto3" json:"bridge_api_version,omitempty"` + SourceSha256 []byte `protobuf:"bytes,2,opt,name=source_sha256,json=sourceSha256,proto3" json:"source_sha256,omitempty"` + Snapshot *v1.Snapshot `protobuf:"bytes,10,opt,name=snapshot,proto3" json:"snapshot,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckRequest) Reset() { + *x = CheckRequest{} + mi := &file_bd2_state_control_v1_control_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckRequest) ProtoMessage() {} + +func (x *CheckRequest) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v1_control_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckRequest.ProtoReflect.Descriptor instead. +func (*CheckRequest) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v1_control_proto_rawDescGZIP(), []int{0} +} + +func (x *CheckRequest) GetBridgeApiVersion() uint32 { + if x != nil { + return x.BridgeApiVersion + } + return 0 +} + +func (x *CheckRequest) GetSourceSha256() []byte { + if x != nil { + return x.SourceSha256 + } + return nil +} + +func (x *CheckRequest) GetSnapshot() *v1.Snapshot { + if x != nil { + return x.Snapshot + } + return nil +} + +type Violation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Severity Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=bd2.state.control.v1.Severity" json:"severity,omitempty"` + Path []string `protobuf:"bytes,3,rep,name=path,proto3" json:"path,omitempty"` + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + RelatedIds []uint64 `protobuf:"varint,5,rep,packed,name=related_ids,json=relatedIds,proto3" json:"related_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Violation) Reset() { + *x = Violation{} + mi := &file_bd2_state_control_v1_control_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Violation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Violation) ProtoMessage() {} + +func (x *Violation) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v1_control_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Violation.ProtoReflect.Descriptor instead. +func (*Violation) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v1_control_proto_rawDescGZIP(), []int{1} +} + +func (x *Violation) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *Violation) GetSeverity() Severity { + if x != nil { + return x.Severity + } + return Severity_SEVERITY_UNSPECIFIED +} + +func (x *Violation) GetPath() []string { + if x != nil { + return x.Path + } + return nil +} + +func (x *Violation) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *Violation) GetRelatedIds() []uint64 { + if x != nil { + return x.RelatedIds + } + return nil +} + +type CheckResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + BridgeApiVersion uint32 `protobuf:"varint,1,opt,name=bridge_api_version,json=bridgeApiVersion,proto3" json:"bridge_api_version,omitempty"` + SourceSha256 []byte `protobuf:"bytes,2,opt,name=source_sha256,json=sourceSha256,proto3" json:"source_sha256,omitempty"` + Violations []*Violation `protobuf:"bytes,10,rep,name=violations,proto3" json:"violations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckResponse) Reset() { + *x = CheckResponse{} + mi := &file_bd2_state_control_v1_control_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckResponse) ProtoMessage() {} + +func (x *CheckResponse) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v1_control_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckResponse.ProtoReflect.Descriptor instead. +func (*CheckResponse) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v1_control_proto_rawDescGZIP(), []int{2} +} + +func (x *CheckResponse) GetBridgeApiVersion() uint32 { + if x != nil { + return x.BridgeApiVersion + } + return 0 +} + +func (x *CheckResponse) GetSourceSha256() []byte { + if x != nil { + return x.SourceSha256 + } + return nil +} + +func (x *CheckResponse) GetViolations() []*Violation { + if x != nil { + return x.Violations + } + return nil +} + +var File_bd2_state_control_v1_control_proto protoreflect.FileDescriptor + +const file_bd2_state_control_v1_control_proto_rawDesc = "" + + "\n" + + "\"bd2/state/control/v1/control.proto\x12\x14bd2.state.control.v1\x1a\x18bd2/state/v1/state.proto\"\x95\x01\n" + + "\fCheckRequest\x12,\n" + + "\x12bridge_api_version\x18\x01 \x01(\rR\x10bridgeApiVersion\x12#\n" + + "\rsource_sha256\x18\x02 \x01(\fR\fsourceSha256\x122\n" + + "\bsnapshot\x18\n" + + " \x01(\v2\x16.bd2.state.v1.SnapshotR\bsnapshot\"\xaa\x01\n" + + "\tViolation\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12:\n" + + "\bseverity\x18\x02 \x01(\x0e2\x1e.bd2.state.control.v1.SeverityR\bseverity\x12\x12\n" + + "\x04path\x18\x03 \x03(\tR\x04path\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x12\x1f\n" + + "\vrelated_ids\x18\x05 \x03(\x04R\n" + + "relatedIds\"\xa3\x01\n" + + "\rCheckResponse\x12,\n" + + "\x12bridge_api_version\x18\x01 \x01(\rR\x10bridgeApiVersion\x12#\n" + + "\rsource_sha256\x18\x02 \x01(\fR\fsourceSha256\x12?\n" + + "\n" + + "violations\x18\n" + + " \x03(\v2\x1f.bd2.state.control.v1.ViolationR\n" + + "violations*N\n" + + "\bSeverity\x12\x18\n" + + "\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10SEVERITY_WARNING\x10\x01\x12\x12\n" + + "\x0eSEVERITY_ERROR\x10\x02B*Z(bd2server/gen/state/control/v1;controlv1b\x06proto3" + +var ( + file_bd2_state_control_v1_control_proto_rawDescOnce sync.Once + file_bd2_state_control_v1_control_proto_rawDescData []byte +) + +func file_bd2_state_control_v1_control_proto_rawDescGZIP() []byte { + file_bd2_state_control_v1_control_proto_rawDescOnce.Do(func() { + file_bd2_state_control_v1_control_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_bd2_state_control_v1_control_proto_rawDesc), len(file_bd2_state_control_v1_control_proto_rawDesc))) + }) + return file_bd2_state_control_v1_control_proto_rawDescData +} + +var file_bd2_state_control_v1_control_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_bd2_state_control_v1_control_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_bd2_state_control_v1_control_proto_goTypes = []any{ + (Severity)(0), // 0: bd2.state.control.v1.Severity + (*CheckRequest)(nil), // 1: bd2.state.control.v1.CheckRequest + (*Violation)(nil), // 2: bd2.state.control.v1.Violation + (*CheckResponse)(nil), // 3: bd2.state.control.v1.CheckResponse + (*v1.Snapshot)(nil), // 4: bd2.state.v1.Snapshot +} +var file_bd2_state_control_v1_control_proto_depIdxs = []int32{ + 4, // 0: bd2.state.control.v1.CheckRequest.snapshot:type_name -> bd2.state.v1.Snapshot + 0, // 1: bd2.state.control.v1.Violation.severity:type_name -> bd2.state.control.v1.Severity + 2, // 2: bd2.state.control.v1.CheckResponse.violations:type_name -> bd2.state.control.v1.Violation + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_bd2_state_control_v1_control_proto_init() } +func file_bd2_state_control_v1_control_proto_init() { + if File_bd2_state_control_v1_control_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_bd2_state_control_v1_control_proto_rawDesc), len(file_bd2_state_control_v1_control_proto_rawDesc)), + NumEnums: 1, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_bd2_state_control_v1_control_proto_goTypes, + DependencyIndexes: file_bd2_state_control_v1_control_proto_depIdxs, + EnumInfos: file_bd2_state_control_v1_control_proto_enumTypes, + MessageInfos: file_bd2_state_control_v1_control_proto_msgTypes, + }.Build() + File_bd2_state_control_v1_control_proto = out.File + file_bd2_state_control_v1_control_proto_goTypes = nil + file_bd2_state_control_v1_control_proto_depIdxs = nil +} diff --git a/go/gen/state/control/v2/control.pb.go b/go/gen/state/control/v2/control.pb.go new file mode 100644 index 0000000..3b79328 --- /dev/null +++ b/go/gen/state/control/v2/control.pb.go @@ -0,0 +1,788 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v7.36.0 +// source: bd2/state/control/v2/control.proto + +package controlv2 + +import ( + v11 "bd2server/gen/state/control/v1" + v1 "bd2server/gen/state/v1" + v2 "bd2server/gen/state/v2" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type MigrateV1ToV2Request struct { + state protoimpl.MessageState `protogen:"open.v1"` + BridgeApiVersion uint32 `protobuf:"varint,1,opt,name=bridge_api_version,json=bridgeApiVersion,proto3" json:"bridge_api_version,omitempty"` + SourceSha256 []byte `protobuf:"bytes,2,opt,name=source_sha256,json=sourceSha256,proto3" json:"source_sha256,omitempty"` + Source *v1.Snapshot `protobuf:"bytes,10,opt,name=source,proto3" json:"source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MigrateV1ToV2Request) Reset() { + *x = MigrateV1ToV2Request{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MigrateV1ToV2Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MigrateV1ToV2Request) ProtoMessage() {} + +func (x *MigrateV1ToV2Request) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MigrateV1ToV2Request.ProtoReflect.Descriptor instead. +func (*MigrateV1ToV2Request) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{0} +} + +func (x *MigrateV1ToV2Request) GetBridgeApiVersion() uint32 { + if x != nil { + return x.BridgeApiVersion + } + return 0 +} + +func (x *MigrateV1ToV2Request) GetSourceSha256() []byte { + if x != nil { + return x.SourceSha256 + } + return nil +} + +func (x *MigrateV1ToV2Request) GetSource() *v1.Snapshot { + if x != nil { + return x.Source + } + return nil +} + +type MigrateV1ToV2Response struct { + state protoimpl.MessageState `protogen:"open.v1"` + BridgeApiVersion uint32 `protobuf:"varint,1,opt,name=bridge_api_version,json=bridgeApiVersion,proto3" json:"bridge_api_version,omitempty"` + SourceSha256 []byte `protobuf:"bytes,2,opt,name=source_sha256,json=sourceSha256,proto3" json:"source_sha256,omitempty"` + Violations []*v11.Violation `protobuf:"bytes,3,rep,name=violations,proto3" json:"violations,omitempty"` + Migrated bool `protobuf:"varint,10,opt,name=migrated,proto3" json:"migrated,omitempty"` + Target *v2.Snapshot `protobuf:"bytes,11,opt,name=target,proto3" json:"target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MigrateV1ToV2Response) Reset() { + *x = MigrateV1ToV2Response{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MigrateV1ToV2Response) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MigrateV1ToV2Response) ProtoMessage() {} + +func (x *MigrateV1ToV2Response) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MigrateV1ToV2Response.ProtoReflect.Descriptor instead. +func (*MigrateV1ToV2Response) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{1} +} + +func (x *MigrateV1ToV2Response) GetBridgeApiVersion() uint32 { + if x != nil { + return x.BridgeApiVersion + } + return 0 +} + +func (x *MigrateV1ToV2Response) GetSourceSha256() []byte { + if x != nil { + return x.SourceSha256 + } + return nil +} + +func (x *MigrateV1ToV2Response) GetViolations() []*v11.Violation { + if x != nil { + return x.Violations + } + return nil +} + +func (x *MigrateV1ToV2Response) GetMigrated() bool { + if x != nil { + return x.Migrated + } + return false +} + +func (x *MigrateV1ToV2Response) GetTarget() *v2.Snapshot { + if x != nil { + return x.Target + } + return nil +} + +type GachaGroupFact struct { + state protoimpl.MessageState `protogen:"open.v1"` + GachaId uint64 `protobuf:"varint,1,opt,name=gacha_id,json=gachaId,proto3" json:"gacha_id,omitempty"` + GroupId uint64 `protobuf:"varint,2,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + FixedId uint64 `protobuf:"varint,3,opt,name=fixed_id,json=fixedId,proto3" json:"fixed_id,omitempty"` + PointCount uint64 `protobuf:"varint,4,opt,name=point_count,json=pointCount,proto3" json:"point_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GachaGroupFact) Reset() { + *x = GachaGroupFact{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GachaGroupFact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GachaGroupFact) ProtoMessage() {} + +func (x *GachaGroupFact) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GachaGroupFact.ProtoReflect.Descriptor instead. +func (*GachaGroupFact) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{2} +} + +func (x *GachaGroupFact) GetGachaId() uint64 { + if x != nil { + return x.GachaId + } + return 0 +} + +func (x *GachaGroupFact) GetGroupId() uint64 { + if x != nil { + return x.GroupId + } + return 0 +} + +func (x *GachaGroupFact) GetFixedId() uint64 { + if x != nil { + return x.FixedId + } + return 0 +} + +func (x *GachaGroupFact) GetPointCount() uint64 { + if x != nil { + return x.PointCount + } + return 0 +} + +type CostumeFact struct { + state protoimpl.MessageState `protogen:"open.v1"` + CostumeId uint64 `protobuf:"varint,1,opt,name=costume_id,json=costumeId,proto3" json:"costume_id,omitempty"` + Grade uint64 `protobuf:"varint,2,opt,name=grade,proto3" json:"grade,omitempty"` + MaxLevel uint64 `protobuf:"varint,3,opt,name=max_level,json=maxLevel,proto3" json:"max_level,omitempty"` + OverflowItemType uint64 `protobuf:"varint,4,opt,name=overflow_item_type,json=overflowItemType,proto3" json:"overflow_item_type,omitempty"` + OverflowItemId uint64 `protobuf:"varint,5,opt,name=overflow_item_id,json=overflowItemId,proto3" json:"overflow_item_id,omitempty"` + OverflowItemCount uint64 `protobuf:"varint,6,opt,name=overflow_item_count,json=overflowItemCount,proto3" json:"overflow_item_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CostumeFact) Reset() { + *x = CostumeFact{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CostumeFact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CostumeFact) ProtoMessage() {} + +func (x *CostumeFact) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CostumeFact.ProtoReflect.Descriptor instead. +func (*CostumeFact) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{3} +} + +func (x *CostumeFact) GetCostumeId() uint64 { + if x != nil { + return x.CostumeId + } + return 0 +} + +func (x *CostumeFact) GetGrade() uint64 { + if x != nil { + return x.Grade + } + return 0 +} + +func (x *CostumeFact) GetMaxLevel() uint64 { + if x != nil { + return x.MaxLevel + } + return 0 +} + +func (x *CostumeFact) GetOverflowItemType() uint64 { + if x != nil { + return x.OverflowItemType + } + return 0 +} + +func (x *CostumeFact) GetOverflowItemId() uint64 { + if x != nil { + return x.OverflowItemId + } + return 0 +} + +func (x *CostumeFact) GetOverflowItemCount() uint64 { + if x != nil { + return x.OverflowItemCount + } + return 0 +} + +type BaseCostumeFact struct { + state protoimpl.MessageState `protogen:"open.v1"` + InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + CostumeId uint64 `protobuf:"varint,2,opt,name=costume_id,json=costumeId,proto3" json:"costume_id,omitempty"` + Level uint64 `protobuf:"varint,3,opt,name=level,proto3" json:"level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BaseCostumeFact) Reset() { + *x = BaseCostumeFact{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BaseCostumeFact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BaseCostumeFact) ProtoMessage() {} + +func (x *BaseCostumeFact) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BaseCostumeFact.ProtoReflect.Descriptor instead. +func (*BaseCostumeFact) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{4} +} + +func (x *BaseCostumeFact) GetInventoryIndex() uint64 { + if x != nil { + return x.InventoryIndex + } + return 0 +} + +func (x *BaseCostumeFact) GetCostumeId() uint64 { + if x != nil { + return x.CostumeId + } + return 0 +} + +func (x *BaseCostumeFact) GetLevel() uint64 { + if x != nil { + return x.Level + } + return 0 +} + +type StepUpFact struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId uint64 `protobuf:"varint,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + OrderedGachaIds []uint64 `protobuf:"varint,2,rep,packed,name=ordered_gacha_ids,json=orderedGachaIds,proto3" json:"ordered_gacha_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StepUpFact) Reset() { + *x = StepUpFact{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StepUpFact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StepUpFact) ProtoMessage() {} + +func (x *StepUpFact) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StepUpFact.ProtoReflect.Descriptor instead. +func (*StepUpFact) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{5} +} + +func (x *StepUpFact) GetGroupId() uint64 { + if x != nil { + return x.GroupId + } + return 0 +} + +func (x *StepUpFact) GetOrderedGachaIds() []uint64 { + if x != nil { + return x.OrderedGachaIds + } + return nil +} + +type ValidationContext struct { + state protoimpl.MessageState `protogen:"open.v1"` + GameDataVersion string `protobuf:"bytes,1,opt,name=game_data_version,json=gameDataVersion,proto3" json:"game_data_version,omitempty"` + GachaGroups []*GachaGroupFact `protobuf:"bytes,2,rep,name=gacha_groups,json=gachaGroups,proto3" json:"gacha_groups,omitempty"` + Costumes []*CostumeFact `protobuf:"bytes,3,rep,name=costumes,proto3" json:"costumes,omitempty"` + BaseCostumes []*BaseCostumeFact `protobuf:"bytes,4,rep,name=base_costumes,json=baseCostumes,proto3" json:"base_costumes,omitempty"` + StepUps []*StepUpFact `protobuf:"bytes,5,rep,name=step_ups,json=stepUps,proto3" json:"step_ups,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidationContext) Reset() { + *x = ValidationContext{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidationContext) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidationContext) ProtoMessage() {} + +func (x *ValidationContext) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidationContext.ProtoReflect.Descriptor instead. +func (*ValidationContext) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{6} +} + +func (x *ValidationContext) GetGameDataVersion() string { + if x != nil { + return x.GameDataVersion + } + return "" +} + +func (x *ValidationContext) GetGachaGroups() []*GachaGroupFact { + if x != nil { + return x.GachaGroups + } + return nil +} + +func (x *ValidationContext) GetCostumes() []*CostumeFact { + if x != nil { + return x.Costumes + } + return nil +} + +func (x *ValidationContext) GetBaseCostumes() []*BaseCostumeFact { + if x != nil { + return x.BaseCostumes + } + return nil +} + +func (x *ValidationContext) GetStepUps() []*StepUpFact { + if x != nil { + return x.StepUps + } + return nil +} + +type RepairRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + BridgeApiVersion uint32 `protobuf:"varint,1,opt,name=bridge_api_version,json=bridgeApiVersion,proto3" json:"bridge_api_version,omitempty"` + SourceSha256 []byte `protobuf:"bytes,2,opt,name=source_sha256,json=sourceSha256,proto3" json:"source_sha256,omitempty"` + Source *v1.Snapshot `protobuf:"bytes,10,opt,name=source,proto3" json:"source,omitempty"` + Context *ValidationContext `protobuf:"bytes,11,opt,name=context,proto3" json:"context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RepairRequest) Reset() { + *x = RepairRequest{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RepairRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RepairRequest) ProtoMessage() {} + +func (x *RepairRequest) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RepairRequest.ProtoReflect.Descriptor instead. +func (*RepairRequest) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{7} +} + +func (x *RepairRequest) GetBridgeApiVersion() uint32 { + if x != nil { + return x.BridgeApiVersion + } + return 0 +} + +func (x *RepairRequest) GetSourceSha256() []byte { + if x != nil { + return x.SourceSha256 + } + return nil +} + +func (x *RepairRequest) GetSource() *v1.Snapshot { + if x != nil { + return x.Source + } + return nil +} + +func (x *RepairRequest) GetContext() *ValidationContext { + if x != nil { + return x.Context + } + return nil +} + +type RepairResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + BridgeApiVersion uint32 `protobuf:"varint,1,opt,name=bridge_api_version,json=bridgeApiVersion,proto3" json:"bridge_api_version,omitempty"` + SourceSha256 []byte `protobuf:"bytes,2,opt,name=source_sha256,json=sourceSha256,proto3" json:"source_sha256,omitempty"` + Violations []*v11.Violation `protobuf:"bytes,3,rep,name=violations,proto3" json:"violations,omitempty"` + Repaired bool `protobuf:"varint,10,opt,name=repaired,proto3" json:"repaired,omitempty"` + Changed bool `protobuf:"varint,11,opt,name=changed,proto3" json:"changed,omitempty"` + Target *v2.Snapshot `protobuf:"bytes,12,opt,name=target,proto3" json:"target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RepairResponse) Reset() { + *x = RepairResponse{} + mi := &file_bd2_state_control_v2_control_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RepairResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RepairResponse) ProtoMessage() {} + +func (x *RepairResponse) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_control_v2_control_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RepairResponse.ProtoReflect.Descriptor instead. +func (*RepairResponse) Descriptor() ([]byte, []int) { + return file_bd2_state_control_v2_control_proto_rawDescGZIP(), []int{8} +} + +func (x *RepairResponse) GetBridgeApiVersion() uint32 { + if x != nil { + return x.BridgeApiVersion + } + return 0 +} + +func (x *RepairResponse) GetSourceSha256() []byte { + if x != nil { + return x.SourceSha256 + } + return nil +} + +func (x *RepairResponse) GetViolations() []*v11.Violation { + if x != nil { + return x.Violations + } + return nil +} + +func (x *RepairResponse) GetRepaired() bool { + if x != nil { + return x.Repaired + } + return false +} + +func (x *RepairResponse) GetChanged() bool { + if x != nil { + return x.Changed + } + return false +} + +func (x *RepairResponse) GetTarget() *v2.Snapshot { + if x != nil { + return x.Target + } + return nil +} + +var File_bd2_state_control_v2_control_proto protoreflect.FileDescriptor + +const file_bd2_state_control_v2_control_proto_rawDesc = "" + + "\n" + + "\"bd2/state/control/v2/control.proto\x12\x14bd2.state.control.v2\x1a\"bd2/state/control/v1/control.proto\x1a\x18bd2/state/v1/state.proto\x1a\x18bd2/state/v2/state.proto\"\x99\x01\n" + + "\x14MigrateV1ToV2Request\x12,\n" + + "\x12bridge_api_version\x18\x01 \x01(\rR\x10bridgeApiVersion\x12#\n" + + "\rsource_sha256\x18\x02 \x01(\fR\fsourceSha256\x12.\n" + + "\x06source\x18\n" + + " \x01(\v2\x16.bd2.state.v1.SnapshotR\x06source\"\xf7\x01\n" + + "\x15MigrateV1ToV2Response\x12,\n" + + "\x12bridge_api_version\x18\x01 \x01(\rR\x10bridgeApiVersion\x12#\n" + + "\rsource_sha256\x18\x02 \x01(\fR\fsourceSha256\x12?\n" + + "\n" + + "violations\x18\x03 \x03(\v2\x1f.bd2.state.control.v1.ViolationR\n" + + "violations\x12\x1a\n" + + "\bmigrated\x18\n" + + " \x01(\bR\bmigrated\x12.\n" + + "\x06target\x18\v \x01(\v2\x16.bd2.state.v2.SnapshotR\x06target\"\x82\x01\n" + + "\x0eGachaGroupFact\x12\x19\n" + + "\bgacha_id\x18\x01 \x01(\x04R\agachaId\x12\x19\n" + + "\bgroup_id\x18\x02 \x01(\x04R\agroupId\x12\x19\n" + + "\bfixed_id\x18\x03 \x01(\x04R\afixedId\x12\x1f\n" + + "\vpoint_count\x18\x04 \x01(\x04R\n" + + "pointCount\"\xe7\x01\n" + + "\vCostumeFact\x12\x1d\n" + + "\n" + + "costume_id\x18\x01 \x01(\x04R\tcostumeId\x12\x14\n" + + "\x05grade\x18\x02 \x01(\x04R\x05grade\x12\x1b\n" + + "\tmax_level\x18\x03 \x01(\x04R\bmaxLevel\x12,\n" + + "\x12overflow_item_type\x18\x04 \x01(\x04R\x10overflowItemType\x12(\n" + + "\x10overflow_item_id\x18\x05 \x01(\x04R\x0eoverflowItemId\x12.\n" + + "\x13overflow_item_count\x18\x06 \x01(\x04R\x11overflowItemCount\"o\n" + + "\x0fBaseCostumeFact\x12'\n" + + "\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12\x1d\n" + + "\n" + + "costume_id\x18\x02 \x01(\x04R\tcostumeId\x12\x14\n" + + "\x05level\x18\x03 \x01(\x04R\x05level\"S\n" + + "\n" + + "StepUpFact\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\x04R\agroupId\x12*\n" + + "\x11ordered_gacha_ids\x18\x02 \x03(\x04R\x0forderedGachaIds\"\xd0\x02\n" + + "\x11ValidationContext\x12*\n" + + "\x11game_data_version\x18\x01 \x01(\tR\x0fgameDataVersion\x12G\n" + + "\fgacha_groups\x18\x02 \x03(\v2$.bd2.state.control.v2.GachaGroupFactR\vgachaGroups\x12=\n" + + "\bcostumes\x18\x03 \x03(\v2!.bd2.state.control.v2.CostumeFactR\bcostumes\x12J\n" + + "\rbase_costumes\x18\x04 \x03(\v2%.bd2.state.control.v2.BaseCostumeFactR\fbaseCostumes\x12;\n" + + "\bstep_ups\x18\x05 \x03(\v2 .bd2.state.control.v2.StepUpFactR\astepUps\"\xd5\x01\n" + + "\rRepairRequest\x12,\n" + + "\x12bridge_api_version\x18\x01 \x01(\rR\x10bridgeApiVersion\x12#\n" + + "\rsource_sha256\x18\x02 \x01(\fR\fsourceSha256\x12.\n" + + "\x06source\x18\n" + + " \x01(\v2\x16.bd2.state.v1.SnapshotR\x06source\x12A\n" + + "\acontext\x18\v \x01(\v2'.bd2.state.control.v2.ValidationContextR\acontext\"\x8a\x02\n" + + "\x0eRepairResponse\x12,\n" + + "\x12bridge_api_version\x18\x01 \x01(\rR\x10bridgeApiVersion\x12#\n" + + "\rsource_sha256\x18\x02 \x01(\fR\fsourceSha256\x12?\n" + + "\n" + + "violations\x18\x03 \x03(\v2\x1f.bd2.state.control.v1.ViolationR\n" + + "violations\x12\x1a\n" + + "\brepaired\x18\n" + + " \x01(\bR\brepaired\x12\x18\n" + + "\achanged\x18\v \x01(\bR\achanged\x12.\n" + + "\x06target\x18\f \x01(\v2\x16.bd2.state.v2.SnapshotR\x06targetB*Z(bd2server/gen/state/control/v2;controlv2b\x06proto3" + +var ( + file_bd2_state_control_v2_control_proto_rawDescOnce sync.Once + file_bd2_state_control_v2_control_proto_rawDescData []byte +) + +func file_bd2_state_control_v2_control_proto_rawDescGZIP() []byte { + file_bd2_state_control_v2_control_proto_rawDescOnce.Do(func() { + file_bd2_state_control_v2_control_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_bd2_state_control_v2_control_proto_rawDesc), len(file_bd2_state_control_v2_control_proto_rawDesc))) + }) + return file_bd2_state_control_v2_control_proto_rawDescData +} + +var file_bd2_state_control_v2_control_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_bd2_state_control_v2_control_proto_goTypes = []any{ + (*MigrateV1ToV2Request)(nil), // 0: bd2.state.control.v2.MigrateV1ToV2Request + (*MigrateV1ToV2Response)(nil), // 1: bd2.state.control.v2.MigrateV1ToV2Response + (*GachaGroupFact)(nil), // 2: bd2.state.control.v2.GachaGroupFact + (*CostumeFact)(nil), // 3: bd2.state.control.v2.CostumeFact + (*BaseCostumeFact)(nil), // 4: bd2.state.control.v2.BaseCostumeFact + (*StepUpFact)(nil), // 5: bd2.state.control.v2.StepUpFact + (*ValidationContext)(nil), // 6: bd2.state.control.v2.ValidationContext + (*RepairRequest)(nil), // 7: bd2.state.control.v2.RepairRequest + (*RepairResponse)(nil), // 8: bd2.state.control.v2.RepairResponse + (*v1.Snapshot)(nil), // 9: bd2.state.v1.Snapshot + (*v11.Violation)(nil), // 10: bd2.state.control.v1.Violation + (*v2.Snapshot)(nil), // 11: bd2.state.v2.Snapshot +} +var file_bd2_state_control_v2_control_proto_depIdxs = []int32{ + 9, // 0: bd2.state.control.v2.MigrateV1ToV2Request.source:type_name -> bd2.state.v1.Snapshot + 10, // 1: bd2.state.control.v2.MigrateV1ToV2Response.violations:type_name -> bd2.state.control.v1.Violation + 11, // 2: bd2.state.control.v2.MigrateV1ToV2Response.target:type_name -> bd2.state.v2.Snapshot + 2, // 3: bd2.state.control.v2.ValidationContext.gacha_groups:type_name -> bd2.state.control.v2.GachaGroupFact + 3, // 4: bd2.state.control.v2.ValidationContext.costumes:type_name -> bd2.state.control.v2.CostumeFact + 4, // 5: bd2.state.control.v2.ValidationContext.base_costumes:type_name -> bd2.state.control.v2.BaseCostumeFact + 5, // 6: bd2.state.control.v2.ValidationContext.step_ups:type_name -> bd2.state.control.v2.StepUpFact + 9, // 7: bd2.state.control.v2.RepairRequest.source:type_name -> bd2.state.v1.Snapshot + 6, // 8: bd2.state.control.v2.RepairRequest.context:type_name -> bd2.state.control.v2.ValidationContext + 10, // 9: bd2.state.control.v2.RepairResponse.violations:type_name -> bd2.state.control.v1.Violation + 11, // 10: bd2.state.control.v2.RepairResponse.target:type_name -> bd2.state.v2.Snapshot + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_bd2_state_control_v2_control_proto_init() } +func file_bd2_state_control_v2_control_proto_init() { + if File_bd2_state_control_v2_control_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_bd2_state_control_v2_control_proto_rawDesc), len(file_bd2_state_control_v2_control_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_bd2_state_control_v2_control_proto_goTypes, + DependencyIndexes: file_bd2_state_control_v2_control_proto_depIdxs, + MessageInfos: file_bd2_state_control_v2_control_proto_msgTypes, + }.Build() + File_bd2_state_control_v2_control_proto = out.File + file_bd2_state_control_v2_control_proto_goTypes = nil + file_bd2_state_control_v2_control_proto_depIdxs = nil +} diff --git a/go/gen/state/v1/state.pb.go b/go/gen/state/v1/state.pb.go new file mode 100644 index 0000000..588c513 --- /dev/null +++ b/go/gen/state/v1/state.pb.go @@ -0,0 +1,3224 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v7.36.0 +// source: bd2/state/v1/state.proto + +package statev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Frozen bridge DTO for the nine existing JSON persistence files. Version 1 +// describes today's save; changes require state/v2, never editing old tags. +type Snapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + FormatVersion uint32 `protobuf:"varint,1,opt,name=format_version,json=formatVersion,proto3" json:"format_version,omitempty"` + ClientVersion string `protobuf:"bytes,2,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + GameDataVersion string `protobuf:"bytes,3,opt,name=game_data_version,json=gameDataVersion,proto3" json:"game_data_version,omitempty"` + Progress *Progress `protobuf:"bytes,10,opt,name=progress,proto3" json:"progress,omitempty"` + Deck *Deck `protobuf:"bytes,11,opt,name=deck,proto3" json:"deck,omitempty"` + Inventory *Inventory `protobuf:"bytes,12,opt,name=inventory,proto3" json:"inventory,omitempty"` + Equipment *EquipmentInventory `protobuf:"bytes,13,opt,name=equipment,proto3" json:"equipment,omitempty"` + Characters *CharacterState `protobuf:"bytes,14,opt,name=characters,proto3" json:"characters,omitempty"` + Collection *Collection `protobuf:"bytes,15,opt,name=collection,proto3" json:"collection,omitempty"` + Wallet *Wallet `protobuf:"bytes,16,opt,name=wallet,proto3" json:"wallet,omitempty"` + Mail *MailState `protobuf:"bytes,17,opt,name=mail,proto3" json:"mail,omitempty"` + Missions *MissionState `protobuf:"bytes,18,opt,name=missions,proto3" json:"missions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Snapshot) Reset() { + *x = Snapshot{} + mi := &file_bd2_state_v1_state_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Snapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Snapshot) ProtoMessage() {} + +func (x *Snapshot) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Snapshot.ProtoReflect.Descriptor instead. +func (*Snapshot) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{0} +} + +func (x *Snapshot) GetFormatVersion() uint32 { + if x != nil { + return x.FormatVersion + } + return 0 +} + +func (x *Snapshot) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *Snapshot) GetGameDataVersion() string { + if x != nil { + return x.GameDataVersion + } + return "" +} + +func (x *Snapshot) GetProgress() *Progress { + if x != nil { + return x.Progress + } + return nil +} + +func (x *Snapshot) GetDeck() *Deck { + if x != nil { + return x.Deck + } + return nil +} + +func (x *Snapshot) GetInventory() *Inventory { + if x != nil { + return x.Inventory + } + return nil +} + +func (x *Snapshot) GetEquipment() *EquipmentInventory { + if x != nil { + return x.Equipment + } + return nil +} + +func (x *Snapshot) GetCharacters() *CharacterState { + if x != nil { + return x.Characters + } + return nil +} + +func (x *Snapshot) GetCollection() *Collection { + if x != nil { + return x.Collection + } + return nil +} + +func (x *Snapshot) GetWallet() *Wallet { + if x != nil { + return x.Wallet + } + return nil +} + +func (x *Snapshot) GetMail() *MailState { + if x != nil { + return x.Mail + } + return nil +} + +func (x *Snapshot) GetMissions() *MissionState { + if x != nil { + return x.Missions + } + return nil +} + +type QuestKey struct { + state protoimpl.MessageState `protogen:"open.v1"` + PackId uint32 `protobuf:"varint,1,opt,name=pack_id,json=packId,proto3" json:"pack_id,omitempty"` + QuestId uint32 `protobuf:"varint,2,opt,name=quest_id,json=questId,proto3" json:"quest_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QuestKey) Reset() { + *x = QuestKey{} + mi := &file_bd2_state_v1_state_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QuestKey) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QuestKey) ProtoMessage() {} + +func (x *QuestKey) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QuestKey.ProtoReflect.Descriptor instead. +func (*QuestKey) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{1} +} + +func (x *QuestKey) GetPackId() uint32 { + if x != nil { + return x.PackId + } + return 0 +} + +func (x *QuestKey) GetQuestId() uint32 { + if x != nil { + return x.QuestId + } + return 0 +} + +type Vector3 struct { + state protoimpl.MessageState `protogen:"open.v1"` + X float64 `protobuf:"fixed64,1,opt,name=x,proto3" json:"x,omitempty"` + Y float64 `protobuf:"fixed64,2,opt,name=y,proto3" json:"y,omitempty"` + Z float64 `protobuf:"fixed64,3,opt,name=z,proto3" json:"z,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Vector3) Reset() { + *x = Vector3{} + mi := &file_bd2_state_v1_state_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Vector3) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Vector3) ProtoMessage() {} + +func (x *Vector3) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Vector3.ProtoReflect.Descriptor instead. +func (*Vector3) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{2} +} + +func (x *Vector3) GetX() float64 { + if x != nil { + return x.X + } + return 0 +} + +func (x *Vector3) GetY() float64 { + if x != nil { + return x.Y + } + return 0 +} + +func (x *Vector3) GetZ() float64 { + if x != nil { + return x.Z + } + return 0 +} + +type Position struct { + state protoimpl.MessageState `protogen:"open.v1"` + PackId uint32 `protobuf:"varint,1,opt,name=pack_id,json=packId,proto3" json:"pack_id,omitempty"` + MapId uint32 `protobuf:"varint,2,opt,name=map_id,json=mapId,proto3" json:"map_id,omitempty"` + Player *Vector3 `protobuf:"bytes,3,opt,name=player,proto3" json:"player,omitempty"` + // Client JSON must be echoed without accidental normalization. + OriginalJson []byte `protobuf:"bytes,10,opt,name=original_json,json=originalJson,proto3" json:"original_json,omitempty"` + ColleaguePositionsJson []byte `protobuf:"bytes,11,opt,name=colleague_positions_json,json=colleaguePositionsJson,proto3" json:"colleague_positions_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Position) Reset() { + *x = Position{} + mi := &file_bd2_state_v1_state_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Position) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Position) ProtoMessage() {} + +func (x *Position) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Position.ProtoReflect.Descriptor instead. +func (*Position) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{3} +} + +func (x *Position) GetPackId() uint32 { + if x != nil { + return x.PackId + } + return 0 +} + +func (x *Position) GetMapId() uint32 { + if x != nil { + return x.MapId + } + return 0 +} + +func (x *Position) GetPlayer() *Vector3 { + if x != nil { + return x.Player + } + return nil +} + +func (x *Position) GetOriginalJson() []byte { + if x != nil { + return x.OriginalJson + } + return nil +} + +func (x *Position) GetColleaguePositionsJson() []byte { + if x != nil { + return x.ColleaguePositionsJson + } + return nil +} + +type QuestProgress struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key *QuestKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Values []uint32 `protobuf:"varint,2,rep,packed,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QuestProgress) Reset() { + *x = QuestProgress{} + mi := &file_bd2_state_v1_state_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QuestProgress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QuestProgress) ProtoMessage() {} + +func (x *QuestProgress) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QuestProgress.ProtoReflect.Descriptor instead. +func (*QuestProgress) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{4} +} + +func (x *QuestProgress) GetKey() *QuestKey { + if x != nil { + return x.Key + } + return nil +} + +func (x *QuestProgress) GetValues() []uint32 { + if x != nil { + return x.Values + } + return nil +} + +type Progress struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceFormatVersion uint32 `protobuf:"varint,1,opt,name=source_format_version,json=sourceFormatVersion,proto3" json:"source_format_version,omitempty"` + Position *Position `protobuf:"bytes,2,opt,name=position,proto3" json:"position,omitempty"` + ClearedTutorialIds []uint32 `protobuf:"varint,3,rep,packed,name=cleared_tutorial_ids,json=clearedTutorialIds,proto3" json:"cleared_tutorial_ids,omitempty"` + Quests []*QuestProgress `protobuf:"bytes,4,rep,name=quests,proto3" json:"quests,omitempty"` + ClearedQuests []*QuestKey `protobuf:"bytes,5,rep,name=cleared_quests,json=clearedQuests,proto3" json:"cleared_quests,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Progress) Reset() { + *x = Progress{} + mi := &file_bd2_state_v1_state_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Progress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Progress) ProtoMessage() {} + +func (x *Progress) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Progress.ProtoReflect.Descriptor instead. +func (*Progress) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{5} +} + +func (x *Progress) GetSourceFormatVersion() uint32 { + if x != nil { + return x.SourceFormatVersion + } + return 0 +} + +func (x *Progress) GetPosition() *Position { + if x != nil { + return x.Position + } + return nil +} + +func (x *Progress) GetClearedTutorialIds() []uint32 { + if x != nil { + return x.ClearedTutorialIds + } + return nil +} + +func (x *Progress) GetQuests() []*QuestProgress { + if x != nil { + return x.Quests + } + return nil +} + +func (x *Progress) GetClearedQuests() []*QuestKey { + if x != nil { + return x.ClearedQuests + } + return nil +} + +type DeckSlot struct { + state protoimpl.MessageState `protogen:"open.v1"` + CharacterIndex uint64 `protobuf:"varint,1,opt,name=character_index,json=characterIndex,proto3" json:"character_index,omitempty"` + CostumeIndex uint64 `protobuf:"varint,2,opt,name=costume_index,json=costumeIndex,proto3" json:"costume_index,omitempty"` + Slot uint64 `protobuf:"varint,3,opt,name=slot,proto3" json:"slot,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeckSlot) Reset() { + *x = DeckSlot{} + mi := &file_bd2_state_v1_state_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeckSlot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeckSlot) ProtoMessage() {} + +func (x *DeckSlot) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeckSlot.ProtoReflect.Descriptor instead. +func (*DeckSlot) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{6} +} + +func (x *DeckSlot) GetCharacterIndex() uint64 { + if x != nil { + return x.CharacterIndex + } + return 0 +} + +func (x *DeckSlot) GetCostumeIndex() uint64 { + if x != nil { + return x.CostumeIndex + } + return 0 +} + +func (x *DeckSlot) GetSlot() uint64 { + if x != nil { + return x.Slot + } + return 0 +} + +type Pair struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key uint64 `protobuf:"varint,1,opt,name=key,proto3" json:"key,omitempty"` + Value uint64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Pair) Reset() { + *x = Pair{} + mi := &file_bd2_state_v1_state_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Pair) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Pair) ProtoMessage() {} + +func (x *Pair) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Pair.ProtoReflect.Descriptor instead. +func (*Pair) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{7} +} + +func (x *Pair) GetKey() uint64 { + if x != nil { + return x.Key + } + return 0 +} + +func (x *Pair) GetValue() uint64 { + if x != nil { + return x.Value + } + return 0 +} + +type Deck struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + Battle []*DeckSlot `protobuf:"bytes,2,rep,name=battle,proto3" json:"battle,omitempty"` + Field []*DeckSlot `protobuf:"bytes,3,rep,name=field,proto3" json:"field,omitempty"` + FieldControlType uint64 `protobuf:"varint,4,opt,name=field_control_type,json=fieldControlType,proto3" json:"field_control_type,omitempty"` + Waypoints []*Pair `protobuf:"bytes,5,rep,name=waypoints,proto3" json:"waypoints,omitempty"` + SelectedCostumes []*Pair `protobuf:"bytes,6,rep,name=selected_costumes,json=selectedCostumes,proto3" json:"selected_costumes,omitempty"` + BoughtPacks []*Pair `protobuf:"bytes,7,rep,name=bought_packs,json=boughtPacks,proto3" json:"bought_packs,omitempty"` + HighestBattlePower uint64 `protobuf:"varint,8,opt,name=highest_battle_power,json=highestBattlePower,proto3" json:"highest_battle_power,omitempty"` + PortraitCostumeId uint64 `protobuf:"varint,9,opt,name=portrait_costume_id,json=portraitCostumeId,proto3" json:"portrait_costume_id,omitempty"` + AutoReviveCatalyst uint64 `protobuf:"varint,10,opt,name=auto_revive_catalyst,json=autoReviveCatalyst,proto3" json:"auto_revive_catalyst,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Deck) Reset() { + *x = Deck{} + mi := &file_bd2_state_v1_state_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Deck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Deck) ProtoMessage() {} + +func (x *Deck) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Deck.ProtoReflect.Descriptor instead. +func (*Deck) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{8} +} + +func (x *Deck) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *Deck) GetBattle() []*DeckSlot { + if x != nil { + return x.Battle + } + return nil +} + +func (x *Deck) GetField() []*DeckSlot { + if x != nil { + return x.Field + } + return nil +} + +func (x *Deck) GetFieldControlType() uint64 { + if x != nil { + return x.FieldControlType + } + return 0 +} + +func (x *Deck) GetWaypoints() []*Pair { + if x != nil { + return x.Waypoints + } + return nil +} + +func (x *Deck) GetSelectedCostumes() []*Pair { + if x != nil { + return x.SelectedCostumes + } + return nil +} + +func (x *Deck) GetBoughtPacks() []*Pair { + if x != nil { + return x.BoughtPacks + } + return nil +} + +func (x *Deck) GetHighestBattlePower() uint64 { + if x != nil { + return x.HighestBattlePower + } + return 0 +} + +func (x *Deck) GetPortraitCostumeId() uint64 { + if x != nil { + return x.PortraitCostumeId + } + return 0 +} + +func (x *Deck) GetAutoReviveCatalyst() uint64 { + if x != nil { + return x.AutoReviveCatalyst + } + return 0 +} + +type Pictorial struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + GroupId uint64 `protobuf:"varint,2,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Pictorial) Reset() { + *x = Pictorial{} + mi := &file_bd2_state_v1_state_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Pictorial) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Pictorial) ProtoMessage() {} + +func (x *Pictorial) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Pictorial.ProtoReflect.Descriptor instead. +func (*Pictorial) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{9} +} + +func (x *Pictorial) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Pictorial) GetGroupId() uint64 { + if x != nil { + return x.GroupId + } + return 0 +} + +type Item struct { + state protoimpl.MessageState `protogen:"open.v1"` + InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + Type uint64 `protobuf:"varint,3,opt,name=type,proto3" json:"type,omitempty"` + Count uint64 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` + KeepFlag uint64 `protobuf:"varint,5,opt,name=keep_flag,json=keepFlag,proto3" json:"keep_flag,omitempty"` + TimeValue uint64 `protobuf:"varint,6,opt,name=time_value,json=timeValue,proto3" json:"time_value,omitempty"` + Pictorial *Pictorial `protobuf:"bytes,7,opt,name=pictorial,proto3" json:"pictorial,omitempty"` + SortId uint64 `protobuf:"varint,8,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"` + UseCount uint64 `protobuf:"varint,9,opt,name=use_count,json=useCount,proto3" json:"use_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Item) Reset() { + *x = Item{} + mi := &file_bd2_state_v1_state_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Item) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Item) ProtoMessage() {} + +func (x *Item) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Item.ProtoReflect.Descriptor instead. +func (*Item) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{10} +} + +func (x *Item) GetInventoryIndex() uint64 { + if x != nil { + return x.InventoryIndex + } + return 0 +} + +func (x *Item) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Item) GetType() uint64 { + if x != nil { + return x.Type + } + return 0 +} + +func (x *Item) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *Item) GetKeepFlag() uint64 { + if x != nil { + return x.KeepFlag + } + return 0 +} + +func (x *Item) GetTimeValue() uint64 { + if x != nil { + return x.TimeValue + } + return 0 +} + +func (x *Item) GetPictorial() *Pictorial { + if x != nil { + return x.Pictorial + } + return nil +} + +func (x *Item) GetSortId() uint64 { + if x != nil { + return x.SortId + } + return 0 +} + +func (x *Item) GetUseCount() uint64 { + if x != nil { + return x.UseCount + } + return 0 +} + +type IndexedGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + InventoryIndices []uint64 `protobuf:"varint,2,rep,packed,name=inventory_indices,json=inventoryIndices,proto3" json:"inventory_indices,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IndexedGrant) Reset() { + *x = IndexedGrant{} + mi := &file_bd2_state_v1_state_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IndexedGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IndexedGrant) ProtoMessage() {} + +func (x *IndexedGrant) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IndexedGrant.ProtoReflect.Descriptor instead. +func (*IndexedGrant) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{11} +} + +func (x *IndexedGrant) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *IndexedGrant) GetInventoryIndices() []uint64 { + if x != nil { + return x.InventoryIndices + } + return nil +} + +type Inventory struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + NextIndex uint64 `protobuf:"varint,2,opt,name=next_index,json=nextIndex,proto3" json:"next_index,omitempty"` + Items []*Item `protobuf:"bytes,3,rep,name=items,proto3" json:"items,omitempty"` + GrantedIdentities []string `protobuf:"bytes,4,rep,name=granted_identities,json=grantedIdentities,proto3" json:"granted_identities,omitempty"` + GrantItems []*IndexedGrant `protobuf:"bytes,5,rep,name=grant_items,json=grantItems,proto3" json:"grant_items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Inventory) Reset() { + *x = Inventory{} + mi := &file_bd2_state_v1_state_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Inventory) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Inventory) ProtoMessage() {} + +func (x *Inventory) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Inventory.ProtoReflect.Descriptor instead. +func (*Inventory) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{12} +} + +func (x *Inventory) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *Inventory) GetNextIndex() uint64 { + if x != nil { + return x.NextIndex + } + return 0 +} + +func (x *Inventory) GetItems() []*Item { + if x != nil { + return x.Items + } + return nil +} + +func (x *Inventory) GetGrantedIdentities() []string { + if x != nil { + return x.GrantedIdentities + } + return nil +} + +func (x *Inventory) GetGrantItems() []*IndexedGrant { + if x != nil { + return x.GrantItems + } + return nil +} + +type EquipmentOption struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId uint64 `protobuf:"varint,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EquipmentOption) Reset() { + *x = EquipmentOption{} + mi := &file_bd2_state_v1_state_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EquipmentOption) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EquipmentOption) ProtoMessage() {} + +func (x *EquipmentOption) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EquipmentOption.ProtoReflect.Descriptor instead. +func (*EquipmentOption) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{13} +} + +func (x *EquipmentOption) GetGroupId() uint64 { + if x != nil { + return x.GroupId + } + return 0 +} + +func (x *EquipmentOption) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +type Equipment struct { + state protoimpl.MessageState `protogen:"open.v1"` + InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + Level uint64 `protobuf:"varint,3,opt,name=level,proto3" json:"level,omitempty"` + UseChar uint64 `protobuf:"varint,4,opt,name=use_char,json=useChar,proto3" json:"use_char,omitempty"` + KeepFlag uint64 `protobuf:"varint,5,opt,name=keep_flag,json=keepFlag,proto3" json:"keep_flag,omitempty"` + LockFlag uint64 `protobuf:"varint,6,opt,name=lock_flag,json=lockFlag,proto3" json:"lock_flag,omitempty"` + SortId uint64 `protobuf:"varint,7,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"` + MainOptions []*EquipmentOption `protobuf:"bytes,8,rep,name=main_options,json=mainOptions,proto3" json:"main_options,omitempty"` + SubOptions []*EquipmentOption `protobuf:"bytes,9,rep,name=sub_options,json=subOptions,proto3" json:"sub_options,omitempty"` + PrivateOption *EquipmentOption `protobuf:"bytes,10,opt,name=private_option,json=privateOption,proto3" json:"private_option,omitempty"` + Ranks []uint64 `protobuf:"varint,11,rep,packed,name=ranks,proto3" json:"ranks,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Equipment) Reset() { + *x = Equipment{} + mi := &file_bd2_state_v1_state_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Equipment) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Equipment) ProtoMessage() {} + +func (x *Equipment) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Equipment.ProtoReflect.Descriptor instead. +func (*Equipment) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{14} +} + +func (x *Equipment) GetInventoryIndex() uint64 { + if x != nil { + return x.InventoryIndex + } + return 0 +} + +func (x *Equipment) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Equipment) GetLevel() uint64 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *Equipment) GetUseChar() uint64 { + if x != nil { + return x.UseChar + } + return 0 +} + +func (x *Equipment) GetKeepFlag() uint64 { + if x != nil { + return x.KeepFlag + } + return 0 +} + +func (x *Equipment) GetLockFlag() uint64 { + if x != nil { + return x.LockFlag + } + return 0 +} + +func (x *Equipment) GetSortId() uint64 { + if x != nil { + return x.SortId + } + return 0 +} + +func (x *Equipment) GetMainOptions() []*EquipmentOption { + if x != nil { + return x.MainOptions + } + return nil +} + +func (x *Equipment) GetSubOptions() []*EquipmentOption { + if x != nil { + return x.SubOptions + } + return nil +} + +func (x *Equipment) GetPrivateOption() *EquipmentOption { + if x != nil { + return x.PrivateOption + } + return nil +} + +func (x *Equipment) GetRanks() []uint64 { + if x != nil { + return x.Ranks + } + return nil +} + +type NamedIndex struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + InventoryIndex uint64 `protobuf:"varint,2,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NamedIndex) Reset() { + *x = NamedIndex{} + mi := &file_bd2_state_v1_state_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NamedIndex) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NamedIndex) ProtoMessage() {} + +func (x *NamedIndex) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NamedIndex.ProtoReflect.Descriptor instead. +func (*NamedIndex) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{15} +} + +func (x *NamedIndex) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *NamedIndex) GetInventoryIndex() uint64 { + if x != nil { + return x.InventoryIndex + } + return 0 +} + +type EquipmentInventory struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + NextIndex uint64 `protobuf:"varint,2,opt,name=next_index,json=nextIndex,proto3" json:"next_index,omitempty"` + Equipment []*Equipment `protobuf:"bytes,3,rep,name=equipment,proto3" json:"equipment,omitempty"` + Grants []*NamedIndex `protobuf:"bytes,4,rep,name=grants,proto3" json:"grants,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EquipmentInventory) Reset() { + *x = EquipmentInventory{} + mi := &file_bd2_state_v1_state_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EquipmentInventory) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EquipmentInventory) ProtoMessage() {} + +func (x *EquipmentInventory) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EquipmentInventory.ProtoReflect.Descriptor instead. +func (*EquipmentInventory) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{16} +} + +func (x *EquipmentInventory) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *EquipmentInventory) GetNextIndex() uint64 { + if x != nil { + return x.NextIndex + } + return 0 +} + +func (x *EquipmentInventory) GetEquipment() []*Equipment { + if x != nil { + return x.Equipment + } + return nil +} + +func (x *EquipmentInventory) GetGrants() []*NamedIndex { + if x != nil { + return x.Grants + } + return nil +} + +type Character struct { + state protoimpl.MessageState `protogen:"open.v1"` + InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + Hp uint64 `protobuf:"varint,3,opt,name=hp,proto3" json:"hp,omitempty"` + Level uint64 `protobuf:"varint,4,opt,name=level,proto3" json:"level,omitempty"` + CostumeId uint64 `protobuf:"varint,5,opt,name=costume_id,json=costumeId,proto3" json:"costume_id,omitempty"` + Exp uint64 `protobuf:"varint,6,opt,name=exp,proto3" json:"exp,omitempty"` + UseCostume uint64 `protobuf:"varint,7,opt,name=use_costume,json=useCostume,proto3" json:"use_costume,omitempty"` + TalentLevel uint64 `protobuf:"varint,8,opt,name=talent_level,json=talentLevel,proto3" json:"talent_level,omitempty"` + TalentExp uint64 `protobuf:"varint,9,opt,name=talent_exp,json=talentExp,proto3" json:"talent_exp,omitempty"` + SolidarityReward uint64 `protobuf:"varint,10,opt,name=solidarity_reward,json=solidarityReward,proto3" json:"solidarity_reward,omitempty"` + ExpiryTime uint64 `protobuf:"varint,11,opt,name=expiry_time,json=expiryTime,proto3" json:"expiry_time,omitempty"` + ConnectPotentialCostume uint64 `protobuf:"varint,12,opt,name=connect_potential_costume,json=connectPotentialCostume,proto3" json:"connect_potential_costume,omitempty"` + Pictorial []*Pictorial `protobuf:"bytes,13,rep,name=pictorial,proto3" json:"pictorial,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Character) Reset() { + *x = Character{} + mi := &file_bd2_state_v1_state_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Character) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Character) ProtoMessage() {} + +func (x *Character) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Character.ProtoReflect.Descriptor instead. +func (*Character) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{17} +} + +func (x *Character) GetInventoryIndex() uint64 { + if x != nil { + return x.InventoryIndex + } + return 0 +} + +func (x *Character) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Character) GetHp() uint64 { + if x != nil { + return x.Hp + } + return 0 +} + +func (x *Character) GetLevel() uint64 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *Character) GetCostumeId() uint64 { + if x != nil { + return x.CostumeId + } + return 0 +} + +func (x *Character) GetExp() uint64 { + if x != nil { + return x.Exp + } + return 0 +} + +func (x *Character) GetUseCostume() uint64 { + if x != nil { + return x.UseCostume + } + return 0 +} + +func (x *Character) GetTalentLevel() uint64 { + if x != nil { + return x.TalentLevel + } + return 0 +} + +func (x *Character) GetTalentExp() uint64 { + if x != nil { + return x.TalentExp + } + return 0 +} + +func (x *Character) GetSolidarityReward() uint64 { + if x != nil { + return x.SolidarityReward + } + return 0 +} + +func (x *Character) GetExpiryTime() uint64 { + if x != nil { + return x.ExpiryTime + } + return 0 +} + +func (x *Character) GetConnectPotentialCostume() uint64 { + if x != nil { + return x.ConnectPotentialCostume + } + return 0 +} + +func (x *Character) GetPictorial() []*Pictorial { + if x != nil { + return x.Pictorial + } + return nil +} + +type Costume struct { + state protoimpl.MessageState `protogen:"open.v1"` + InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + Level uint64 `protobuf:"varint,3,opt,name=level,proto3" json:"level,omitempty"` + UseChar uint64 `protobuf:"varint,4,opt,name=use_char,json=useChar,proto3" json:"use_char,omitempty"` + SortId uint64 `protobuf:"varint,5,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"` + PotentialId uint64 `protobuf:"varint,6,opt,name=potential_id,json=potentialId,proto3" json:"potential_id,omitempty"` + DesignId uint64 `protobuf:"varint,7,opt,name=design_id,json=designId,proto3" json:"design_id,omitempty"` + BurstLevel uint64 `protobuf:"varint,8,opt,name=burst_level,json=burstLevel,proto3" json:"burst_level,omitempty"` + TimeValue uint64 `protobuf:"varint,9,opt,name=time_value,json=timeValue,proto3" json:"time_value,omitempty"` + Pictorial []*Pictorial `protobuf:"bytes,10,rep,name=pictorial,proto3" json:"pictorial,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Costume) Reset() { + *x = Costume{} + mi := &file_bd2_state_v1_state_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Costume) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Costume) ProtoMessage() {} + +func (x *Costume) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Costume.ProtoReflect.Descriptor instead. +func (*Costume) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{18} +} + +func (x *Costume) GetInventoryIndex() uint64 { + if x != nil { + return x.InventoryIndex + } + return 0 +} + +func (x *Costume) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Costume) GetLevel() uint64 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *Costume) GetUseChar() uint64 { + if x != nil { + return x.UseChar + } + return 0 +} + +func (x *Costume) GetSortId() uint64 { + if x != nil { + return x.SortId + } + return 0 +} + +func (x *Costume) GetPotentialId() uint64 { + if x != nil { + return x.PotentialId + } + return 0 +} + +func (x *Costume) GetDesignId() uint64 { + if x != nil { + return x.DesignId + } + return 0 +} + +func (x *Costume) GetBurstLevel() uint64 { + if x != nil { + return x.BurstLevel + } + return 0 +} + +func (x *Costume) GetTimeValue() uint64 { + if x != nil { + return x.TimeValue + } + return 0 +} + +func (x *Costume) GetPictorial() []*Pictorial { + if x != nil { + return x.Pictorial + } + return nil +} + +type CharacterState struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + Characters []*Character `protobuf:"bytes,2,rep,name=characters,proto3" json:"characters,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CharacterState) Reset() { + *x = CharacterState{} + mi := &file_bd2_state_v1_state_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CharacterState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CharacterState) ProtoMessage() {} + +func (x *CharacterState) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CharacterState.ProtoReflect.Descriptor instead. +func (*CharacterState) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{19} +} + +func (x *CharacterState) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *CharacterState) GetCharacters() []*Character { + if x != nil { + return x.Characters + } + return nil +} + +type NamedCount struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + Value uint64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NamedCount) Reset() { + *x = NamedCount{} + mi := &file_bd2_state_v1_state_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NamedCount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NamedCount) ProtoMessage() {} + +func (x *NamedCount) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NamedCount.ProtoReflect.Descriptor instead. +func (*NamedCount) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{20} +} + +func (x *NamedCount) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *NamedCount) GetValue() uint64 { + if x != nil { + return x.Value + } + return 0 +} + +type GachaSelection struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId uint64 `protobuf:"varint,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + Slot uint64 `protobuf:"varint,2,opt,name=slot,proto3" json:"slot,omitempty"` + ItemId uint64 `protobuf:"varint,3,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GachaSelection) Reset() { + *x = GachaSelection{} + mi := &file_bd2_state_v1_state_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GachaSelection) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GachaSelection) ProtoMessage() {} + +func (x *GachaSelection) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GachaSelection.ProtoReflect.Descriptor instead. +func (*GachaSelection) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{21} +} + +func (x *GachaSelection) GetGroupId() uint64 { + if x != nil { + return x.GroupId + } + return 0 +} + +func (x *GachaSelection) GetSlot() uint64 { + if x != nil { + return x.Slot + } + return 0 +} + +func (x *GachaSelection) GetItemId() uint64 { + if x != nil { + return x.ItemId + } + return 0 +} + +type NamedSelections struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + Selections []*GachaSelection `protobuf:"bytes,2,rep,name=selections,proto3" json:"selections,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NamedSelections) Reset() { + *x = NamedSelections{} + mi := &file_bd2_state_v1_state_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NamedSelections) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NamedSelections) ProtoMessage() {} + +func (x *NamedSelections) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NamedSelections.ProtoReflect.Descriptor instead. +func (*NamedSelections) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{22} +} + +func (x *NamedSelections) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *NamedSelections) GetSelections() []*GachaSelection { + if x != nil { + return x.Selections + } + return nil +} + +type GachaUser struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId uint64 `protobuf:"varint,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + Point uint64 `protobuf:"varint,2,opt,name=point,proto3" json:"point,omitempty"` + TotalBuyCount uint64 `protobuf:"varint,3,opt,name=total_buy_count,json=totalBuyCount,proto3" json:"total_buy_count,omitempty"` + OneFreePickCount uint64 `protobuf:"varint,4,opt,name=one_free_pick_count,json=oneFreePickCount,proto3" json:"one_free_pick_count,omitempty"` + OneCashPickCount uint64 `protobuf:"varint,5,opt,name=one_cash_pick_count,json=oneCashPickCount,proto3" json:"one_cash_pick_count,omitempty"` + TenFreePickCount uint64 `protobuf:"varint,6,opt,name=ten_free_pick_count,json=tenFreePickCount,proto3" json:"ten_free_pick_count,omitempty"` + TenCashPickCount uint64 `protobuf:"varint,7,opt,name=ten_cash_pick_count,json=tenCashPickCount,proto3" json:"ten_cash_pick_count,omitempty"` + ExchangeItemCount uint64 `protobuf:"varint,8,opt,name=exchange_item_count,json=exchangeItemCount,proto3" json:"exchange_item_count,omitempty"` + ExchangeMileageCount uint64 `protobuf:"varint,9,opt,name=exchange_mileage_count,json=exchangeMileageCount,proto3" json:"exchange_mileage_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GachaUser) Reset() { + *x = GachaUser{} + mi := &file_bd2_state_v1_state_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GachaUser) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GachaUser) ProtoMessage() {} + +func (x *GachaUser) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GachaUser.ProtoReflect.Descriptor instead. +func (*GachaUser) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{23} +} + +func (x *GachaUser) GetGroupId() uint64 { + if x != nil { + return x.GroupId + } + return 0 +} + +func (x *GachaUser) GetPoint() uint64 { + if x != nil { + return x.Point + } + return 0 +} + +func (x *GachaUser) GetTotalBuyCount() uint64 { + if x != nil { + return x.TotalBuyCount + } + return 0 +} + +func (x *GachaUser) GetOneFreePickCount() uint64 { + if x != nil { + return x.OneFreePickCount + } + return 0 +} + +func (x *GachaUser) GetOneCashPickCount() uint64 { + if x != nil { + return x.OneCashPickCount + } + return 0 +} + +func (x *GachaUser) GetTenFreePickCount() uint64 { + if x != nil { + return x.TenFreePickCount + } + return 0 +} + +func (x *GachaUser) GetTenCashPickCount() uint64 { + if x != nil { + return x.TenCashPickCount + } + return 0 +} + +func (x *GachaUser) GetExchangeItemCount() uint64 { + if x != nil { + return x.ExchangeItemCount + } + return 0 +} + +func (x *GachaUser) GetExchangeMileageCount() uint64 { + if x != nil { + return x.ExchangeMileageCount + } + return 0 +} + +type NamedGachaUser struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + User *GachaUser `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NamedGachaUser) Reset() { + *x = NamedGachaUser{} + mi := &file_bd2_state_v1_state_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NamedGachaUser) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NamedGachaUser) ProtoMessage() {} + +func (x *NamedGachaUser) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NamedGachaUser.ProtoReflect.Descriptor instead. +func (*NamedGachaUser) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{24} +} + +func (x *NamedGachaUser) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *NamedGachaUser) GetUser() *GachaUser { + if x != nil { + return x.User + } + return nil +} + +type GachaFixed struct { + state protoimpl.MessageState `protogen:"open.v1"` + FixedId uint64 `protobuf:"varint,1,opt,name=fixed_id,json=fixedId,proto3" json:"fixed_id,omitempty"` + Type uint64 `protobuf:"varint,2,opt,name=type,proto3" json:"type,omitempty"` + Count uint64 `protobuf:"varint,3,opt,name=count,proto3" json:"count,omitempty"` + ApplySortId int32 `protobuf:"varint,4,opt,name=apply_sort_id,json=applySortId,proto3" json:"apply_sort_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GachaFixed) Reset() { + *x = GachaFixed{} + mi := &file_bd2_state_v1_state_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GachaFixed) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GachaFixed) ProtoMessage() {} + +func (x *GachaFixed) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GachaFixed.ProtoReflect.Descriptor instead. +func (*GachaFixed) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{25} +} + +func (x *GachaFixed) GetFixedId() uint64 { + if x != nil { + return x.FixedId + } + return 0 +} + +func (x *GachaFixed) GetType() uint64 { + if x != nil { + return x.Type + } + return 0 +} + +func (x *GachaFixed) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *GachaFixed) GetApplySortId() int32 { + if x != nil { + return x.ApplySortId + } + return 0 +} + +type NamedGachaFixed struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + Fixed *GachaFixed `protobuf:"bytes,2,opt,name=fixed,proto3" json:"fixed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NamedGachaFixed) Reset() { + *x = NamedGachaFixed{} + mi := &file_bd2_state_v1_state_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NamedGachaFixed) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NamedGachaFixed) ProtoMessage() {} + +func (x *NamedGachaFixed) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NamedGachaFixed.ProtoReflect.Descriptor instead. +func (*NamedGachaFixed) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{26} +} + +func (x *NamedGachaFixed) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *NamedGachaFixed) GetFixed() *GachaFixed { + if x != nil { + return x.Fixed + } + return nil +} + +type GachaPointExchange struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId uint64 `protobuf:"varint,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + Count uint64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GachaPointExchange) Reset() { + *x = GachaPointExchange{} + mi := &file_bd2_state_v1_state_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GachaPointExchange) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GachaPointExchange) ProtoMessage() {} + +func (x *GachaPointExchange) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GachaPointExchange.ProtoReflect.Descriptor instead. +func (*GachaPointExchange) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{27} +} + +func (x *GachaPointExchange) GetGroupId() uint64 { + if x != nil { + return x.GroupId + } + return 0 +} + +func (x *GachaPointExchange) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +type NamedGachaPointExchange struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + Exchange *GachaPointExchange `protobuf:"bytes,2,opt,name=exchange,proto3" json:"exchange,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NamedGachaPointExchange) Reset() { + *x = NamedGachaPointExchange{} + mi := &file_bd2_state_v1_state_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NamedGachaPointExchange) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NamedGachaPointExchange) ProtoMessage() {} + +func (x *NamedGachaPointExchange) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NamedGachaPointExchange.ProtoReflect.Descriptor instead. +func (*NamedGachaPointExchange) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{28} +} + +func (x *NamedGachaPointExchange) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *NamedGachaPointExchange) GetExchange() *GachaPointExchange { + if x != nil { + return x.Exchange + } + return nil +} + +type CostumeUpgrade struct { + state protoimpl.MessageState `protogen:"open.v1"` + InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + CostumeId uint64 `protobuf:"varint,2,opt,name=costume_id,json=costumeId,proto3" json:"costume_id,omitempty"` + Before uint64 `protobuf:"varint,3,opt,name=before,proto3" json:"before,omitempty"` + After uint64 `protobuf:"varint,4,opt,name=after,proto3" json:"after,omitempty"` + SortId uint64 `protobuf:"varint,5,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CostumeUpgrade) Reset() { + *x = CostumeUpgrade{} + mi := &file_bd2_state_v1_state_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CostumeUpgrade) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CostumeUpgrade) ProtoMessage() {} + +func (x *CostumeUpgrade) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CostumeUpgrade.ProtoReflect.Descriptor instead. +func (*CostumeUpgrade) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{29} +} + +func (x *CostumeUpgrade) GetInventoryIndex() uint64 { + if x != nil { + return x.InventoryIndex + } + return 0 +} + +func (x *CostumeUpgrade) GetCostumeId() uint64 { + if x != nil { + return x.CostumeId + } + return 0 +} + +func (x *CostumeUpgrade) GetBefore() uint64 { + if x != nil { + return x.Before + } + return 0 +} + +func (x *CostumeUpgrade) GetAfter() uint64 { + if x != nil { + return x.After + } + return 0 +} + +func (x *CostumeUpgrade) GetSortId() uint64 { + if x != nil { + return x.SortId + } + return 0 +} + +type CostumeExchange struct { + state protoimpl.MessageState `protogen:"open.v1"` + InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + OriginalItemType uint64 `protobuf:"varint,2,opt,name=original_item_type,json=originalItemType,proto3" json:"original_item_type,omitempty"` + OriginalItemId uint64 `protobuf:"varint,3,opt,name=original_item_id,json=originalItemId,proto3" json:"original_item_id,omitempty"` + OriginalCount uint64 `protobuf:"varint,4,opt,name=original_count,json=originalCount,proto3" json:"original_count,omitempty"` + ExchangeItemType uint64 `protobuf:"varint,5,opt,name=exchange_item_type,json=exchangeItemType,proto3" json:"exchange_item_type,omitempty"` + ExchangeItemId uint64 `protobuf:"varint,6,opt,name=exchange_item_id,json=exchangeItemId,proto3" json:"exchange_item_id,omitempty"` + ExchangeCount uint64 `protobuf:"varint,7,opt,name=exchange_count,json=exchangeCount,proto3" json:"exchange_count,omitempty"` + SortId uint64 `protobuf:"varint,8,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CostumeExchange) Reset() { + *x = CostumeExchange{} + mi := &file_bd2_state_v1_state_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CostumeExchange) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CostumeExchange) ProtoMessage() {} + +func (x *CostumeExchange) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CostumeExchange.ProtoReflect.Descriptor instead. +func (*CostumeExchange) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{30} +} + +func (x *CostumeExchange) GetInventoryIndex() uint64 { + if x != nil { + return x.InventoryIndex + } + return 0 +} + +func (x *CostumeExchange) GetOriginalItemType() uint64 { + if x != nil { + return x.OriginalItemType + } + return 0 +} + +func (x *CostumeExchange) GetOriginalItemId() uint64 { + if x != nil { + return x.OriginalItemId + } + return 0 +} + +func (x *CostumeExchange) GetOriginalCount() uint64 { + if x != nil { + return x.OriginalCount + } + return 0 +} + +func (x *CostumeExchange) GetExchangeItemType() uint64 { + if x != nil { + return x.ExchangeItemType + } + return 0 +} + +func (x *CostumeExchange) GetExchangeItemId() uint64 { + if x != nil { + return x.ExchangeItemId + } + return 0 +} + +func (x *CostumeExchange) GetExchangeCount() uint64 { + if x != nil { + return x.ExchangeCount + } + return 0 +} + +func (x *CostumeExchange) GetSortId() uint64 { + if x != nil { + return x.SortId + } + return 0 +} + +type CollectionGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + CharacterIndices []uint64 `protobuf:"varint,2,rep,packed,name=character_indices,json=characterIndices,proto3" json:"character_indices,omitempty"` + CostumeIndices []uint64 `protobuf:"varint,3,rep,packed,name=costume_indices,json=costumeIndices,proto3" json:"costume_indices,omitempty"` + Upgrades []*CostumeUpgrade `protobuf:"bytes,4,rep,name=upgrades,proto3" json:"upgrades,omitempty"` + Exchanges []*CostumeExchange `protobuf:"bytes,5,rep,name=exchanges,proto3" json:"exchanges,omitempty"` + ViewCostumeIds []uint64 `protobuf:"varint,6,rep,packed,name=view_costume_ids,json=viewCostumeIds,proto3" json:"view_costume_ids,omitempty"` + GachaGroupId uint64 `protobuf:"varint,7,opt,name=gacha_group_id,json=gachaGroupId,proto3" json:"gacha_group_id,omitempty"` + GachaPoint uint64 `protobuf:"varint,8,opt,name=gacha_point,json=gachaPoint,proto3" json:"gacha_point,omitempty"` + GachaFixed []*GachaFixed `protobuf:"bytes,9,rep,name=gacha_fixed,json=gachaFixed,proto3" json:"gacha_fixed,omitempty"` + SelectionApplySortIds []uint64 `protobuf:"varint,10,rep,packed,name=selection_apply_sort_ids,json=selectionApplySortIds,proto3" json:"selection_apply_sort_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CollectionGrant) Reset() { + *x = CollectionGrant{} + mi := &file_bd2_state_v1_state_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CollectionGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CollectionGrant) ProtoMessage() {} + +func (x *CollectionGrant) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CollectionGrant.ProtoReflect.Descriptor instead. +func (*CollectionGrant) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{31} +} + +func (x *CollectionGrant) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *CollectionGrant) GetCharacterIndices() []uint64 { + if x != nil { + return x.CharacterIndices + } + return nil +} + +func (x *CollectionGrant) GetCostumeIndices() []uint64 { + if x != nil { + return x.CostumeIndices + } + return nil +} + +func (x *CollectionGrant) GetUpgrades() []*CostumeUpgrade { + if x != nil { + return x.Upgrades + } + return nil +} + +func (x *CollectionGrant) GetExchanges() []*CostumeExchange { + if x != nil { + return x.Exchanges + } + return nil +} + +func (x *CollectionGrant) GetViewCostumeIds() []uint64 { + if x != nil { + return x.ViewCostumeIds + } + return nil +} + +func (x *CollectionGrant) GetGachaGroupId() uint64 { + if x != nil { + return x.GachaGroupId + } + return 0 +} + +func (x *CollectionGrant) GetGachaPoint() uint64 { + if x != nil { + return x.GachaPoint + } + return 0 +} + +func (x *CollectionGrant) GetGachaFixed() []*GachaFixed { + if x != nil { + return x.GachaFixed + } + return nil +} + +func (x *CollectionGrant) GetSelectionApplySortIds() []uint64 { + if x != nil { + return x.SelectionApplySortIds + } + return nil +} + +type Collection struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + NextCharacterIndex uint64 `protobuf:"varint,2,opt,name=next_character_index,json=nextCharacterIndex,proto3" json:"next_character_index,omitempty"` + NextCostumeIndex uint64 `protobuf:"varint,3,opt,name=next_costume_index,json=nextCostumeIndex,proto3" json:"next_costume_index,omitempty"` + LatestPreview []uint64 `protobuf:"varint,4,rep,packed,name=latest_preview,json=latestPreview,proto3" json:"latest_preview,omitempty"` + PreviewEventIndex uint64 `protobuf:"varint,5,opt,name=preview_event_index,json=previewEventIndex,proto3" json:"preview_event_index,omitempty"` + PreviewLocked bool `protobuf:"varint,6,opt,name=preview_locked,json=previewLocked,proto3" json:"preview_locked,omitempty"` + Characters []*Character `protobuf:"bytes,7,rep,name=characters,proto3" json:"characters,omitempty"` + Costumes []*Costume `protobuf:"bytes,8,rep,name=costumes,proto3" json:"costumes,omitempty"` + BaseCostumeLevels []*NamedCount `protobuf:"bytes,9,rep,name=base_costume_levels,json=baseCostumeLevels,proto3" json:"base_costume_levels,omitempty"` + GachaSelections []*NamedSelections `protobuf:"bytes,10,rep,name=gacha_selections,json=gachaSelections,proto3" json:"gacha_selections,omitempty"` + StepUpProgress []*NamedCount `protobuf:"bytes,11,rep,name=step_up_progress,json=stepUpProgress,proto3" json:"step_up_progress,omitempty"` + GachaUsers []*NamedGachaUser `protobuf:"bytes,12,rep,name=gacha_users,json=gachaUsers,proto3" json:"gacha_users,omitempty"` + GachaFixed []*NamedGachaFixed `protobuf:"bytes,13,rep,name=gacha_fixed,json=gachaFixed,proto3" json:"gacha_fixed,omitempty"` + GachaApplied []string `protobuf:"bytes,14,rep,name=gacha_applied,json=gachaApplied,proto3" json:"gacha_applied,omitempty"` + GachaPointExchanges []*NamedGachaPointExchange `protobuf:"bytes,15,rep,name=gacha_point_exchanges,json=gachaPointExchanges,proto3" json:"gacha_point_exchanges,omitempty"` + GachaCountCorrected bool `protobuf:"varint,16,opt,name=gacha_count_corrected,json=gachaCountCorrected,proto3" json:"gacha_count_corrected,omitempty"` + Grants []*CollectionGrant `protobuf:"bytes,17,rep,name=grants,proto3" json:"grants,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Collection) Reset() { + *x = Collection{} + mi := &file_bd2_state_v1_state_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Collection) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Collection) ProtoMessage() {} + +func (x *Collection) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Collection.ProtoReflect.Descriptor instead. +func (*Collection) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{32} +} + +func (x *Collection) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *Collection) GetNextCharacterIndex() uint64 { + if x != nil { + return x.NextCharacterIndex + } + return 0 +} + +func (x *Collection) GetNextCostumeIndex() uint64 { + if x != nil { + return x.NextCostumeIndex + } + return 0 +} + +func (x *Collection) GetLatestPreview() []uint64 { + if x != nil { + return x.LatestPreview + } + return nil +} + +func (x *Collection) GetPreviewEventIndex() uint64 { + if x != nil { + return x.PreviewEventIndex + } + return 0 +} + +func (x *Collection) GetPreviewLocked() bool { + if x != nil { + return x.PreviewLocked + } + return false +} + +func (x *Collection) GetCharacters() []*Character { + if x != nil { + return x.Characters + } + return nil +} + +func (x *Collection) GetCostumes() []*Costume { + if x != nil { + return x.Costumes + } + return nil +} + +func (x *Collection) GetBaseCostumeLevels() []*NamedCount { + if x != nil { + return x.BaseCostumeLevels + } + return nil +} + +func (x *Collection) GetGachaSelections() []*NamedSelections { + if x != nil { + return x.GachaSelections + } + return nil +} + +func (x *Collection) GetStepUpProgress() []*NamedCount { + if x != nil { + return x.StepUpProgress + } + return nil +} + +func (x *Collection) GetGachaUsers() []*NamedGachaUser { + if x != nil { + return x.GachaUsers + } + return nil +} + +func (x *Collection) GetGachaFixed() []*NamedGachaFixed { + if x != nil { + return x.GachaFixed + } + return nil +} + +func (x *Collection) GetGachaApplied() []string { + if x != nil { + return x.GachaApplied + } + return nil +} + +func (x *Collection) GetGachaPointExchanges() []*NamedGachaPointExchange { + if x != nil { + return x.GachaPointExchanges + } + return nil +} + +func (x *Collection) GetGachaCountCorrected() bool { + if x != nil { + return x.GachaCountCorrected + } + return false +} + +func (x *Collection) GetGrants() []*CollectionGrant { + if x != nil { + return x.Grants + } + return nil +} + +type Wallet struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + Gold uint64 `protobuf:"varint,2,opt,name=gold,proto3" json:"gold,omitempty"` + FreeJewelry uint64 `protobuf:"varint,3,opt,name=free_jewelry,json=freeJewelry,proto3" json:"free_jewelry,omitempty"` + Jewelry uint64 `protobuf:"varint,4,opt,name=jewelry,proto3" json:"jewelry,omitempty"` + Mileage uint64 `protobuf:"varint,5,opt,name=mileage,proto3" json:"mileage,omitempty"` + HopePowder uint64 `protobuf:"varint,6,opt,name=hope_powder,json=hopePowder,proto3" json:"hope_powder,omitempty"` + GrantedIdentities []string `protobuf:"bytes,7,rep,name=granted_identities,json=grantedIdentities,proto3" json:"granted_identities,omitempty"` + SpentIdentities []string `protobuf:"bytes,8,rep,name=spent_identities,json=spentIdentities,proto3" json:"spent_identities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Wallet) Reset() { + *x = Wallet{} + mi := &file_bd2_state_v1_state_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Wallet) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Wallet) ProtoMessage() {} + +func (x *Wallet) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Wallet.ProtoReflect.Descriptor instead. +func (*Wallet) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{33} +} + +func (x *Wallet) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *Wallet) GetGold() uint64 { + if x != nil { + return x.Gold + } + return 0 +} + +func (x *Wallet) GetFreeJewelry() uint64 { + if x != nil { + return x.FreeJewelry + } + return 0 +} + +func (x *Wallet) GetJewelry() uint64 { + if x != nil { + return x.Jewelry + } + return 0 +} + +func (x *Wallet) GetMileage() uint64 { + if x != nil { + return x.Mileage + } + return 0 +} + +func (x *Wallet) GetHopePowder() uint64 { + if x != nil { + return x.HopePowder + } + return 0 +} + +func (x *Wallet) GetGrantedIdentities() []string { + if x != nil { + return x.GrantedIdentities + } + return nil +} + +func (x *Wallet) GetSpentIdentities() []string { + if x != nil { + return x.SpentIdentities + } + return nil +} + +type MailState struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + OpenedMailIds []uint64 `protobuf:"varint,2,rep,packed,name=opened_mail_ids,json=openedMailIds,proto3" json:"opened_mail_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MailState) Reset() { + *x = MailState{} + mi := &file_bd2_state_v1_state_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MailState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MailState) ProtoMessage() {} + +func (x *MailState) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MailState.ProtoReflect.Descriptor instead. +func (*MailState) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{34} +} + +func (x *MailState) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *MailState) GetOpenedMailIds() []uint64 { + if x != nil { + return x.OpenedMailIds + } + return nil +} + +type MissionState struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + Completed []string `protobuf:"bytes,2,rep,name=completed,proto3" json:"completed,omitempty"` + Claimed []string `protobuf:"bytes,3,rep,name=claimed,proto3" json:"claimed,omitempty"` + Progress []*NamedCount `protobuf:"bytes,4,rep,name=progress,proto3" json:"progress,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MissionState) Reset() { + *x = MissionState{} + mi := &file_bd2_state_v1_state_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MissionState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MissionState) ProtoMessage() {} + +func (x *MissionState) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v1_state_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MissionState.ProtoReflect.Descriptor instead. +func (*MissionState) Descriptor() ([]byte, []int) { + return file_bd2_state_v1_state_proto_rawDescGZIP(), []int{35} +} + +func (x *MissionState) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *MissionState) GetCompleted() []string { + if x != nil { + return x.Completed + } + return nil +} + +func (x *MissionState) GetClaimed() []string { + if x != nil { + return x.Claimed + } + return nil +} + +func (x *MissionState) GetProgress() []*NamedCount { + if x != nil { + return x.Progress + } + return nil +} + +var File_bd2_state_v1_state_proto protoreflect.FileDescriptor + +const file_bd2_state_v1_state_proto_rawDesc = "" + + "\n" + + "\x18bd2/state/v1/state.proto\x12\fbd2.state.v1\"\xe2\x04\n" + + "\bSnapshot\x12%\n" + + "\x0eformat_version\x18\x01 \x01(\rR\rformatVersion\x12%\n" + + "\x0eclient_version\x18\x02 \x01(\tR\rclientVersion\x12*\n" + + "\x11game_data_version\x18\x03 \x01(\tR\x0fgameDataVersion\x122\n" + + "\bprogress\x18\n" + + " \x01(\v2\x16.bd2.state.v1.ProgressR\bprogress\x12&\n" + + "\x04deck\x18\v \x01(\v2\x12.bd2.state.v1.DeckR\x04deck\x125\n" + + "\tinventory\x18\f \x01(\v2\x17.bd2.state.v1.InventoryR\tinventory\x12>\n" + + "\tequipment\x18\r \x01(\v2 .bd2.state.v1.EquipmentInventoryR\tequipment\x12<\n" + + "\n" + + "characters\x18\x0e \x01(\v2\x1c.bd2.state.v1.CharacterStateR\n" + + "characters\x128\n" + + "\n" + + "collection\x18\x0f \x01(\v2\x18.bd2.state.v1.CollectionR\n" + + "collection\x12,\n" + + "\x06wallet\x18\x10 \x01(\v2\x14.bd2.state.v1.WalletR\x06wallet\x12+\n" + + "\x04mail\x18\x11 \x01(\v2\x17.bd2.state.v1.MailStateR\x04mail\x126\n" + + "\bmissions\x18\x12 \x01(\v2\x1a.bd2.state.v1.MissionStateR\bmissions\">\n" + + "\bQuestKey\x12\x17\n" + + "\apack_id\x18\x01 \x01(\rR\x06packId\x12\x19\n" + + "\bquest_id\x18\x02 \x01(\rR\aquestId\"3\n" + + "\aVector3\x12\f\n" + + "\x01x\x18\x01 \x01(\x01R\x01x\x12\f\n" + + "\x01y\x18\x02 \x01(\x01R\x01y\x12\f\n" + + "\x01z\x18\x03 \x01(\x01R\x01z\"\xc8\x01\n" + + "\bPosition\x12\x17\n" + + "\apack_id\x18\x01 \x01(\rR\x06packId\x12\x15\n" + + "\x06map_id\x18\x02 \x01(\rR\x05mapId\x12-\n" + + "\x06player\x18\x03 \x01(\v2\x15.bd2.state.v1.Vector3R\x06player\x12#\n" + + "\roriginal_json\x18\n" + + " \x01(\fR\foriginalJson\x128\n" + + "\x18colleague_positions_json\x18\v \x01(\fR\x16colleaguePositionsJson\"Q\n" + + "\rQuestProgress\x12(\n" + + "\x03key\x18\x01 \x01(\v2\x16.bd2.state.v1.QuestKeyR\x03key\x12\x16\n" + + "\x06values\x18\x02 \x03(\rR\x06values\"\x98\x02\n" + + "\bProgress\x122\n" + + "\x15source_format_version\x18\x01 \x01(\rR\x13sourceFormatVersion\x122\n" + + "\bposition\x18\x02 \x01(\v2\x16.bd2.state.v1.PositionR\bposition\x120\n" + + "\x14cleared_tutorial_ids\x18\x03 \x03(\rR\x12clearedTutorialIds\x123\n" + + "\x06quests\x18\x04 \x03(\v2\x1b.bd2.state.v1.QuestProgressR\x06quests\x12=\n" + + "\x0ecleared_quests\x18\x05 \x03(\v2\x16.bd2.state.v1.QuestKeyR\rclearedQuests\"l\n" + + "\bDeckSlot\x12'\n" + + "\x0fcharacter_index\x18\x01 \x01(\x04R\x0echaracterIndex\x12#\n" + + "\rcostume_index\x18\x02 \x01(\x04R\fcostumeIndex\x12\x12\n" + + "\x04slot\x18\x03 \x01(\x04R\x04slot\".\n" + + "\x04Pair\x12\x10\n" + + "\x03key\x18\x01 \x01(\x04R\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x04R\x05value\"\xf7\x03\n" + + "\x04Deck\x12%\n" + + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12.\n" + + "\x06battle\x18\x02 \x03(\v2\x16.bd2.state.v1.DeckSlotR\x06battle\x12,\n" + + "\x05field\x18\x03 \x03(\v2\x16.bd2.state.v1.DeckSlotR\x05field\x12,\n" + + "\x12field_control_type\x18\x04 \x01(\x04R\x10fieldControlType\x120\n" + + "\twaypoints\x18\x05 \x03(\v2\x12.bd2.state.v1.PairR\twaypoints\x12?\n" + + "\x11selected_costumes\x18\x06 \x03(\v2\x12.bd2.state.v1.PairR\x10selectedCostumes\x125\n" + + "\fbought_packs\x18\a \x03(\v2\x12.bd2.state.v1.PairR\vboughtPacks\x120\n" + + "\x14highest_battle_power\x18\b \x01(\x04R\x12highestBattlePower\x12.\n" + + "\x13portrait_costume_id\x18\t \x01(\x04R\x11portraitCostumeId\x120\n" + + "\x14auto_revive_catalyst\x18\n" + + " \x01(\x04R\x12autoReviveCatalyst\"6\n" + + "\tPictorial\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x19\n" + + "\bgroup_id\x18\x02 \x01(\x04R\agroupId\"\x92\x02\n" + + "\x04Item\x12'\n" + + "\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12\x0e\n" + + "\x02id\x18\x02 \x01(\x04R\x02id\x12\x12\n" + + "\x04type\x18\x03 \x01(\x04R\x04type\x12\x14\n" + + "\x05count\x18\x04 \x01(\x04R\x05count\x12\x1b\n" + + "\tkeep_flag\x18\x05 \x01(\x04R\bkeepFlag\x12\x1d\n" + + "\n" + + "time_value\x18\x06 \x01(\x04R\ttimeValue\x125\n" + + "\tpictorial\x18\a \x01(\v2\x17.bd2.state.v1.PictorialR\tpictorial\x12\x17\n" + + "\asort_id\x18\b \x01(\x04R\x06sortId\x12\x1b\n" + + "\tuse_count\x18\t \x01(\x04R\buseCount\"W\n" + + "\fIndexedGrant\x12\x1a\n" + + "\bidentity\x18\x01 \x01(\tR\bidentity\x12+\n" + + "\x11inventory_indices\x18\x02 \x03(\x04R\x10inventoryIndices\"\xe7\x01\n" + + "\tInventory\x12%\n" + + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12\x1d\n" + + "\n" + + "next_index\x18\x02 \x01(\x04R\tnextIndex\x12(\n" + + "\x05items\x18\x03 \x03(\v2\x12.bd2.state.v1.ItemR\x05items\x12-\n" + + "\x12granted_identities\x18\x04 \x03(\tR\x11grantedIdentities\x12;\n" + + "\vgrant_items\x18\x05 \x03(\v2\x1a.bd2.state.v1.IndexedGrantR\n" + + "grantItems\"<\n" + + "\x0fEquipmentOption\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\x04R\agroupId\x12\x0e\n" + + "\x02id\x18\x02 \x01(\x04R\x02id\"\xa6\x03\n" + + "\tEquipment\x12'\n" + + "\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12\x0e\n" + + "\x02id\x18\x02 \x01(\x04R\x02id\x12\x14\n" + + "\x05level\x18\x03 \x01(\x04R\x05level\x12\x19\n" + + "\buse_char\x18\x04 \x01(\x04R\auseChar\x12\x1b\n" + + "\tkeep_flag\x18\x05 \x01(\x04R\bkeepFlag\x12\x1b\n" + + "\tlock_flag\x18\x06 \x01(\x04R\blockFlag\x12\x17\n" + + "\asort_id\x18\a \x01(\x04R\x06sortId\x12@\n" + + "\fmain_options\x18\b \x03(\v2\x1d.bd2.state.v1.EquipmentOptionR\vmainOptions\x12>\n" + + "\vsub_options\x18\t \x03(\v2\x1d.bd2.state.v1.EquipmentOptionR\n" + + "subOptions\x12D\n" + + "\x0eprivate_option\x18\n" + + " \x01(\v2\x1d.bd2.state.v1.EquipmentOptionR\rprivateOption\x12\x14\n" + + "\x05ranks\x18\v \x03(\x04R\x05ranks\"Q\n" + + "\n" + + "NamedIndex\x12\x1a\n" + + "\bidentity\x18\x01 \x01(\tR\bidentity\x12'\n" + + "\x0finventory_index\x18\x02 \x01(\x04R\x0einventoryIndex\"\xc3\x01\n" + + "\x12EquipmentInventory\x12%\n" + + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12\x1d\n" + + "\n" + + "next_index\x18\x02 \x01(\x04R\tnextIndex\x125\n" + + "\tequipment\x18\x03 \x03(\v2\x17.bd2.state.v1.EquipmentR\tequipment\x120\n" + + "\x06grants\x18\x04 \x03(\v2\x18.bd2.state.v1.NamedIndexR\x06grants\"\xbf\x03\n" + + "\tCharacter\x12'\n" + + "\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12\x0e\n" + + "\x02id\x18\x02 \x01(\x04R\x02id\x12\x0e\n" + + "\x02hp\x18\x03 \x01(\x04R\x02hp\x12\x14\n" + + "\x05level\x18\x04 \x01(\x04R\x05level\x12\x1d\n" + + "\n" + + "costume_id\x18\x05 \x01(\x04R\tcostumeId\x12\x10\n" + + "\x03exp\x18\x06 \x01(\x04R\x03exp\x12\x1f\n" + + "\vuse_costume\x18\a \x01(\x04R\n" + + "useCostume\x12!\n" + + "\ftalent_level\x18\b \x01(\x04R\vtalentLevel\x12\x1d\n" + + "\n" + + "talent_exp\x18\t \x01(\x04R\ttalentExp\x12+\n" + + "\x11solidarity_reward\x18\n" + + " \x01(\x04R\x10solidarityReward\x12\x1f\n" + + "\vexpiry_time\x18\v \x01(\x04R\n" + + "expiryTime\x12:\n" + + "\x19connect_potential_costume\x18\f \x01(\x04R\x17connectPotentialCostume\x125\n" + + "\tpictorial\x18\r \x03(\v2\x17.bd2.state.v1.PictorialR\tpictorial\"\xc3\x02\n" + + "\aCostume\x12'\n" + + "\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12\x0e\n" + + "\x02id\x18\x02 \x01(\x04R\x02id\x12\x14\n" + + "\x05level\x18\x03 \x01(\x04R\x05level\x12\x19\n" + + "\buse_char\x18\x04 \x01(\x04R\auseChar\x12\x17\n" + + "\asort_id\x18\x05 \x01(\x04R\x06sortId\x12!\n" + + "\fpotential_id\x18\x06 \x01(\x04R\vpotentialId\x12\x1b\n" + + "\tdesign_id\x18\a \x01(\x04R\bdesignId\x12\x1f\n" + + "\vburst_level\x18\b \x01(\x04R\n" + + "burstLevel\x12\x1d\n" + + "\n" + + "time_value\x18\t \x01(\x04R\ttimeValue\x125\n" + + "\tpictorial\x18\n" + + " \x03(\v2\x17.bd2.state.v1.PictorialR\tpictorial\"p\n" + + "\x0eCharacterState\x12%\n" + + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x127\n" + + "\n" + + "characters\x18\x02 \x03(\v2\x17.bd2.state.v1.CharacterR\n" + + "characters\">\n" + + "\n" + + "NamedCount\x12\x1a\n" + + "\bidentity\x18\x01 \x01(\tR\bidentity\x12\x14\n" + + "\x05value\x18\x02 \x01(\x04R\x05value\"X\n" + + "\x0eGachaSelection\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\x04R\agroupId\x12\x12\n" + + "\x04slot\x18\x02 \x01(\x04R\x04slot\x12\x17\n" + + "\aitem_id\x18\x03 \x01(\x04R\x06itemId\"k\n" + + "\x0fNamedSelections\x12\x1a\n" + + "\bidentity\x18\x01 \x01(\tR\bidentity\x12<\n" + + "\n" + + "selections\x18\x02 \x03(\v2\x1c.bd2.state.v1.GachaSelectionR\n" + + "selections\"\x86\x03\n" + + "\tGachaUser\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\x04R\agroupId\x12\x14\n" + + "\x05point\x18\x02 \x01(\x04R\x05point\x12&\n" + + "\x0ftotal_buy_count\x18\x03 \x01(\x04R\rtotalBuyCount\x12-\n" + + "\x13one_free_pick_count\x18\x04 \x01(\x04R\x10oneFreePickCount\x12-\n" + + "\x13one_cash_pick_count\x18\x05 \x01(\x04R\x10oneCashPickCount\x12-\n" + + "\x13ten_free_pick_count\x18\x06 \x01(\x04R\x10tenFreePickCount\x12-\n" + + "\x13ten_cash_pick_count\x18\a \x01(\x04R\x10tenCashPickCount\x12.\n" + + "\x13exchange_item_count\x18\b \x01(\x04R\x11exchangeItemCount\x124\n" + + "\x16exchange_mileage_count\x18\t \x01(\x04R\x14exchangeMileageCount\"Y\n" + + "\x0eNamedGachaUser\x12\x1a\n" + + "\bidentity\x18\x01 \x01(\tR\bidentity\x12+\n" + + "\x04user\x18\x02 \x01(\v2\x17.bd2.state.v1.GachaUserR\x04user\"u\n" + + "\n" + + "GachaFixed\x12\x19\n" + + "\bfixed_id\x18\x01 \x01(\x04R\afixedId\x12\x12\n" + + "\x04type\x18\x02 \x01(\x04R\x04type\x12\x14\n" + + "\x05count\x18\x03 \x01(\x04R\x05count\x12\"\n" + + "\rapply_sort_id\x18\x04 \x01(\x05R\vapplySortId\"]\n" + + "\x0fNamedGachaFixed\x12\x1a\n" + + "\bidentity\x18\x01 \x01(\tR\bidentity\x12.\n" + + "\x05fixed\x18\x02 \x01(\v2\x18.bd2.state.v1.GachaFixedR\x05fixed\"E\n" + + "\x12GachaPointExchange\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\x04R\agroupId\x12\x14\n" + + "\x05count\x18\x02 \x01(\x04R\x05count\"s\n" + + "\x17NamedGachaPointExchange\x12\x1a\n" + + "\bidentity\x18\x01 \x01(\tR\bidentity\x12<\n" + + "\bexchange\x18\x02 \x01(\v2 .bd2.state.v1.GachaPointExchangeR\bexchange\"\x9f\x01\n" + + "\x0eCostumeUpgrade\x12'\n" + + "\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12\x1d\n" + + "\n" + + "costume_id\x18\x02 \x01(\x04R\tcostumeId\x12\x16\n" + + "\x06before\x18\x03 \x01(\x04R\x06before\x12\x14\n" + + "\x05after\x18\x04 \x01(\x04R\x05after\x12\x17\n" + + "\asort_id\x18\x05 \x01(\x04R\x06sortId\"\xd1\x02\n" + + "\x0fCostumeExchange\x12'\n" + + "\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12,\n" + + "\x12original_item_type\x18\x02 \x01(\x04R\x10originalItemType\x12(\n" + + "\x10original_item_id\x18\x03 \x01(\x04R\x0eoriginalItemId\x12%\n" + + "\x0eoriginal_count\x18\x04 \x01(\x04R\roriginalCount\x12,\n" + + "\x12exchange_item_type\x18\x05 \x01(\x04R\x10exchangeItemType\x12(\n" + + "\x10exchange_item_id\x18\x06 \x01(\x04R\x0eexchangeItemId\x12%\n" + + "\x0eexchange_count\x18\a \x01(\x04R\rexchangeCount\x12\x17\n" + + "\asort_id\x18\b \x01(\x04R\x06sortId\"\xdf\x03\n" + + "\x0fCollectionGrant\x12\x1a\n" + + "\bidentity\x18\x01 \x01(\tR\bidentity\x12+\n" + + "\x11character_indices\x18\x02 \x03(\x04R\x10characterIndices\x12'\n" + + "\x0fcostume_indices\x18\x03 \x03(\x04R\x0ecostumeIndices\x128\n" + + "\bupgrades\x18\x04 \x03(\v2\x1c.bd2.state.v1.CostumeUpgradeR\bupgrades\x12;\n" + + "\texchanges\x18\x05 \x03(\v2\x1d.bd2.state.v1.CostumeExchangeR\texchanges\x12(\n" + + "\x10view_costume_ids\x18\x06 \x03(\x04R\x0eviewCostumeIds\x12$\n" + + "\x0egacha_group_id\x18\a \x01(\x04R\fgachaGroupId\x12\x1f\n" + + "\vgacha_point\x18\b \x01(\x04R\n" + + "gachaPoint\x129\n" + + "\vgacha_fixed\x18\t \x03(\v2\x18.bd2.state.v1.GachaFixedR\n" + + "gachaFixed\x127\n" + + "\x18selection_apply_sort_ids\x18\n" + + " \x03(\x04R\x15selectionApplySortIds\"\xbf\a\n" + + "\n" + + "Collection\x12%\n" + + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x120\n" + + "\x14next_character_index\x18\x02 \x01(\x04R\x12nextCharacterIndex\x12,\n" + + "\x12next_costume_index\x18\x03 \x01(\x04R\x10nextCostumeIndex\x12%\n" + + "\x0elatest_preview\x18\x04 \x03(\x04R\rlatestPreview\x12.\n" + + "\x13preview_event_index\x18\x05 \x01(\x04R\x11previewEventIndex\x12%\n" + + "\x0epreview_locked\x18\x06 \x01(\bR\rpreviewLocked\x127\n" + + "\n" + + "characters\x18\a \x03(\v2\x17.bd2.state.v1.CharacterR\n" + + "characters\x121\n" + + "\bcostumes\x18\b \x03(\v2\x15.bd2.state.v1.CostumeR\bcostumes\x12H\n" + + "\x13base_costume_levels\x18\t \x03(\v2\x18.bd2.state.v1.NamedCountR\x11baseCostumeLevels\x12H\n" + + "\x10gacha_selections\x18\n" + + " \x03(\v2\x1d.bd2.state.v1.NamedSelectionsR\x0fgachaSelections\x12B\n" + + "\x10step_up_progress\x18\v \x03(\v2\x18.bd2.state.v1.NamedCountR\x0estepUpProgress\x12=\n" + + "\vgacha_users\x18\f \x03(\v2\x1c.bd2.state.v1.NamedGachaUserR\n" + + "gachaUsers\x12>\n" + + "\vgacha_fixed\x18\r \x03(\v2\x1d.bd2.state.v1.NamedGachaFixedR\n" + + "gachaFixed\x12#\n" + + "\rgacha_applied\x18\x0e \x03(\tR\fgachaApplied\x12Y\n" + + "\x15gacha_point_exchanges\x18\x0f \x03(\v2%.bd2.state.v1.NamedGachaPointExchangeR\x13gachaPointExchanges\x122\n" + + "\x15gacha_count_corrected\x18\x10 \x01(\bR\x13gachaCountCorrected\x125\n" + + "\x06grants\x18\x11 \x03(\v2\x1d.bd2.state.v1.CollectionGrantR\x06grants\"\x95\x02\n" + + "\x06Wallet\x12%\n" + + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12\x12\n" + + "\x04gold\x18\x02 \x01(\x04R\x04gold\x12!\n" + + "\ffree_jewelry\x18\x03 \x01(\x04R\vfreeJewelry\x12\x18\n" + + "\ajewelry\x18\x04 \x01(\x04R\ajewelry\x12\x18\n" + + "\amileage\x18\x05 \x01(\x04R\amileage\x12\x1f\n" + + "\vhope_powder\x18\x06 \x01(\x04R\n" + + "hopePowder\x12-\n" + + "\x12granted_identities\x18\a \x03(\tR\x11grantedIdentities\x12)\n" + + "\x10spent_identities\x18\b \x03(\tR\x0fspentIdentities\"Z\n" + + "\tMailState\x12%\n" + + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12&\n" + + "\x0fopened_mail_ids\x18\x02 \x03(\x04R\ropenedMailIds\"\xa3\x01\n" + + "\fMissionState\x12%\n" + + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12\x1c\n" + + "\tcompleted\x18\x02 \x03(\tR\tcompleted\x12\x18\n" + + "\aclaimed\x18\x03 \x03(\tR\aclaimed\x124\n" + + "\bprogress\x18\x04 \x03(\v2\x18.bd2.state.v1.NamedCountR\bprogressB Z\x1ebd2server/gen/state/v1;statev1b\x06proto3" + +var ( + file_bd2_state_v1_state_proto_rawDescOnce sync.Once + file_bd2_state_v1_state_proto_rawDescData []byte +) + +func file_bd2_state_v1_state_proto_rawDescGZIP() []byte { + file_bd2_state_v1_state_proto_rawDescOnce.Do(func() { + file_bd2_state_v1_state_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_bd2_state_v1_state_proto_rawDesc), len(file_bd2_state_v1_state_proto_rawDesc))) + }) + return file_bd2_state_v1_state_proto_rawDescData +} + +var file_bd2_state_v1_state_proto_msgTypes = make([]protoimpl.MessageInfo, 36) +var file_bd2_state_v1_state_proto_goTypes = []any{ + (*Snapshot)(nil), // 0: bd2.state.v1.Snapshot + (*QuestKey)(nil), // 1: bd2.state.v1.QuestKey + (*Vector3)(nil), // 2: bd2.state.v1.Vector3 + (*Position)(nil), // 3: bd2.state.v1.Position + (*QuestProgress)(nil), // 4: bd2.state.v1.QuestProgress + (*Progress)(nil), // 5: bd2.state.v1.Progress + (*DeckSlot)(nil), // 6: bd2.state.v1.DeckSlot + (*Pair)(nil), // 7: bd2.state.v1.Pair + (*Deck)(nil), // 8: bd2.state.v1.Deck + (*Pictorial)(nil), // 9: bd2.state.v1.Pictorial + (*Item)(nil), // 10: bd2.state.v1.Item + (*IndexedGrant)(nil), // 11: bd2.state.v1.IndexedGrant + (*Inventory)(nil), // 12: bd2.state.v1.Inventory + (*EquipmentOption)(nil), // 13: bd2.state.v1.EquipmentOption + (*Equipment)(nil), // 14: bd2.state.v1.Equipment + (*NamedIndex)(nil), // 15: bd2.state.v1.NamedIndex + (*EquipmentInventory)(nil), // 16: bd2.state.v1.EquipmentInventory + (*Character)(nil), // 17: bd2.state.v1.Character + (*Costume)(nil), // 18: bd2.state.v1.Costume + (*CharacterState)(nil), // 19: bd2.state.v1.CharacterState + (*NamedCount)(nil), // 20: bd2.state.v1.NamedCount + (*GachaSelection)(nil), // 21: bd2.state.v1.GachaSelection + (*NamedSelections)(nil), // 22: bd2.state.v1.NamedSelections + (*GachaUser)(nil), // 23: bd2.state.v1.GachaUser + (*NamedGachaUser)(nil), // 24: bd2.state.v1.NamedGachaUser + (*GachaFixed)(nil), // 25: bd2.state.v1.GachaFixed + (*NamedGachaFixed)(nil), // 26: bd2.state.v1.NamedGachaFixed + (*GachaPointExchange)(nil), // 27: bd2.state.v1.GachaPointExchange + (*NamedGachaPointExchange)(nil), // 28: bd2.state.v1.NamedGachaPointExchange + (*CostumeUpgrade)(nil), // 29: bd2.state.v1.CostumeUpgrade + (*CostumeExchange)(nil), // 30: bd2.state.v1.CostumeExchange + (*CollectionGrant)(nil), // 31: bd2.state.v1.CollectionGrant + (*Collection)(nil), // 32: bd2.state.v1.Collection + (*Wallet)(nil), // 33: bd2.state.v1.Wallet + (*MailState)(nil), // 34: bd2.state.v1.MailState + (*MissionState)(nil), // 35: bd2.state.v1.MissionState +} +var file_bd2_state_v1_state_proto_depIdxs = []int32{ + 5, // 0: bd2.state.v1.Snapshot.progress:type_name -> bd2.state.v1.Progress + 8, // 1: bd2.state.v1.Snapshot.deck:type_name -> bd2.state.v1.Deck + 12, // 2: bd2.state.v1.Snapshot.inventory:type_name -> bd2.state.v1.Inventory + 16, // 3: bd2.state.v1.Snapshot.equipment:type_name -> bd2.state.v1.EquipmentInventory + 19, // 4: bd2.state.v1.Snapshot.characters:type_name -> bd2.state.v1.CharacterState + 32, // 5: bd2.state.v1.Snapshot.collection:type_name -> bd2.state.v1.Collection + 33, // 6: bd2.state.v1.Snapshot.wallet:type_name -> bd2.state.v1.Wallet + 34, // 7: bd2.state.v1.Snapshot.mail:type_name -> bd2.state.v1.MailState + 35, // 8: bd2.state.v1.Snapshot.missions:type_name -> bd2.state.v1.MissionState + 2, // 9: bd2.state.v1.Position.player:type_name -> bd2.state.v1.Vector3 + 1, // 10: bd2.state.v1.QuestProgress.key:type_name -> bd2.state.v1.QuestKey + 3, // 11: bd2.state.v1.Progress.position:type_name -> bd2.state.v1.Position + 4, // 12: bd2.state.v1.Progress.quests:type_name -> bd2.state.v1.QuestProgress + 1, // 13: bd2.state.v1.Progress.cleared_quests:type_name -> bd2.state.v1.QuestKey + 6, // 14: bd2.state.v1.Deck.battle:type_name -> bd2.state.v1.DeckSlot + 6, // 15: bd2.state.v1.Deck.field:type_name -> bd2.state.v1.DeckSlot + 7, // 16: bd2.state.v1.Deck.waypoints:type_name -> bd2.state.v1.Pair + 7, // 17: bd2.state.v1.Deck.selected_costumes:type_name -> bd2.state.v1.Pair + 7, // 18: bd2.state.v1.Deck.bought_packs:type_name -> bd2.state.v1.Pair + 9, // 19: bd2.state.v1.Item.pictorial:type_name -> bd2.state.v1.Pictorial + 10, // 20: bd2.state.v1.Inventory.items:type_name -> bd2.state.v1.Item + 11, // 21: bd2.state.v1.Inventory.grant_items:type_name -> bd2.state.v1.IndexedGrant + 13, // 22: bd2.state.v1.Equipment.main_options:type_name -> bd2.state.v1.EquipmentOption + 13, // 23: bd2.state.v1.Equipment.sub_options:type_name -> bd2.state.v1.EquipmentOption + 13, // 24: bd2.state.v1.Equipment.private_option:type_name -> bd2.state.v1.EquipmentOption + 14, // 25: bd2.state.v1.EquipmentInventory.equipment:type_name -> bd2.state.v1.Equipment + 15, // 26: bd2.state.v1.EquipmentInventory.grants:type_name -> bd2.state.v1.NamedIndex + 9, // 27: bd2.state.v1.Character.pictorial:type_name -> bd2.state.v1.Pictorial + 9, // 28: bd2.state.v1.Costume.pictorial:type_name -> bd2.state.v1.Pictorial + 17, // 29: bd2.state.v1.CharacterState.characters:type_name -> bd2.state.v1.Character + 21, // 30: bd2.state.v1.NamedSelections.selections:type_name -> bd2.state.v1.GachaSelection + 23, // 31: bd2.state.v1.NamedGachaUser.user:type_name -> bd2.state.v1.GachaUser + 25, // 32: bd2.state.v1.NamedGachaFixed.fixed:type_name -> bd2.state.v1.GachaFixed + 27, // 33: bd2.state.v1.NamedGachaPointExchange.exchange:type_name -> bd2.state.v1.GachaPointExchange + 29, // 34: bd2.state.v1.CollectionGrant.upgrades:type_name -> bd2.state.v1.CostumeUpgrade + 30, // 35: bd2.state.v1.CollectionGrant.exchanges:type_name -> bd2.state.v1.CostumeExchange + 25, // 36: bd2.state.v1.CollectionGrant.gacha_fixed:type_name -> bd2.state.v1.GachaFixed + 17, // 37: bd2.state.v1.Collection.characters:type_name -> bd2.state.v1.Character + 18, // 38: bd2.state.v1.Collection.costumes:type_name -> bd2.state.v1.Costume + 20, // 39: bd2.state.v1.Collection.base_costume_levels:type_name -> bd2.state.v1.NamedCount + 22, // 40: bd2.state.v1.Collection.gacha_selections:type_name -> bd2.state.v1.NamedSelections + 20, // 41: bd2.state.v1.Collection.step_up_progress:type_name -> bd2.state.v1.NamedCount + 24, // 42: bd2.state.v1.Collection.gacha_users:type_name -> bd2.state.v1.NamedGachaUser + 26, // 43: bd2.state.v1.Collection.gacha_fixed:type_name -> bd2.state.v1.NamedGachaFixed + 28, // 44: bd2.state.v1.Collection.gacha_point_exchanges:type_name -> bd2.state.v1.NamedGachaPointExchange + 31, // 45: bd2.state.v1.Collection.grants:type_name -> bd2.state.v1.CollectionGrant + 20, // 46: bd2.state.v1.MissionState.progress:type_name -> bd2.state.v1.NamedCount + 47, // [47:47] is the sub-list for method output_type + 47, // [47:47] is the sub-list for method input_type + 47, // [47:47] is the sub-list for extension type_name + 47, // [47:47] is the sub-list for extension extendee + 0, // [0:47] is the sub-list for field type_name +} + +func init() { file_bd2_state_v1_state_proto_init() } +func file_bd2_state_v1_state_proto_init() { + if File_bd2_state_v1_state_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_bd2_state_v1_state_proto_rawDesc), len(file_bd2_state_v1_state_proto_rawDesc)), + NumEnums: 0, + NumMessages: 36, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_bd2_state_v1_state_proto_goTypes, + DependencyIndexes: file_bd2_state_v1_state_proto_depIdxs, + MessageInfos: file_bd2_state_v1_state_proto_msgTypes, + }.Build() + File_bd2_state_v1_state_proto = out.File + file_bd2_state_v1_state_proto_goTypes = nil + file_bd2_state_v1_state_proto_depIdxs = nil +} diff --git a/go/gen/state/v2/state.pb.go b/go/gen/state/v2/state.pb.go new file mode 100644 index 0000000..65c8ace --- /dev/null +++ b/go/gen/state/v2/state.pb.go @@ -0,0 +1,605 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v7.36.0 +// source: bd2/state/v2/state.proto + +package statev2 + +import ( + v1 "bd2server/gen/state/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CharacterOrigin int32 + +const ( + CharacterOrigin_CHARACTER_ORIGIN_UNSPECIFIED CharacterOrigin = 0 + // Character supplied by story/bootstrap state (V1 CharacterState). + CharacterOrigin_CHARACTER_ORIGIN_BASE CharacterOrigin = 1 + // Durable account-owned character (V1 Collection.characters). + CharacterOrigin_CHARACTER_ORIGIN_ACQUIRED CharacterOrigin = 2 +) + +// Enum value maps for CharacterOrigin. +var ( + CharacterOrigin_name = map[int32]string{ + 0: "CHARACTER_ORIGIN_UNSPECIFIED", + 1: "CHARACTER_ORIGIN_BASE", + 2: "CHARACTER_ORIGIN_ACQUIRED", + } + CharacterOrigin_value = map[string]int32{ + "CHARACTER_ORIGIN_UNSPECIFIED": 0, + "CHARACTER_ORIGIN_BASE": 1, + "CHARACTER_ORIGIN_ACQUIRED": 2, + } +) + +func (x CharacterOrigin) Enum() *CharacterOrigin { + p := new(CharacterOrigin) + *p = x + return p +} + +func (x CharacterOrigin) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CharacterOrigin) Descriptor() protoreflect.EnumDescriptor { + return file_bd2_state_v2_state_proto_enumTypes[0].Descriptor() +} + +func (CharacterOrigin) Type() protoreflect.EnumType { + return &file_bd2_state_v2_state_proto_enumTypes[0] +} + +func (x CharacterOrigin) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CharacterOrigin.Descriptor instead. +func (CharacterOrigin) EnumDescriptor() ([]byte, []int) { + return file_bd2_state_v2_state_proto_rawDescGZIP(), []int{0} +} + +// Version 2 is an incremental semantic schema. Unchanged frozen V1 value +// messages are reused deliberately; the ownership layout below is V2-owned. +type Snapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + FormatVersion uint32 `protobuf:"varint,1,opt,name=format_version,json=formatVersion,proto3" json:"format_version,omitempty"` + ClientVersion string `protobuf:"bytes,2,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + GameDataVersion string `protobuf:"bytes,3,opt,name=game_data_version,json=gameDataVersion,proto3" json:"game_data_version,omitempty"` + Progress *v1.Progress `protobuf:"bytes,10,opt,name=progress,proto3" json:"progress,omitempty"` + Deck *v1.Deck `protobuf:"bytes,11,opt,name=deck,proto3" json:"deck,omitempty"` + Inventory *v1.Inventory `protobuf:"bytes,12,opt,name=inventory,proto3" json:"inventory,omitempty"` + Equipment *v1.EquipmentInventory `protobuf:"bytes,13,opt,name=equipment,proto3" json:"equipment,omitempty"` + Roster *CharacterRoster `protobuf:"bytes,14,opt,name=roster,proto3" json:"roster,omitempty"` + Collection *CollectionLedger `protobuf:"bytes,15,opt,name=collection,proto3" json:"collection,omitempty"` + Wallet *v1.Wallet `protobuf:"bytes,16,opt,name=wallet,proto3" json:"wallet,omitempty"` + Mail *v1.MailState `protobuf:"bytes,17,opt,name=mail,proto3" json:"mail,omitempty"` + Missions *v1.MissionState `protobuf:"bytes,18,opt,name=missions,proto3" json:"missions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Snapshot) Reset() { + *x = Snapshot{} + mi := &file_bd2_state_v2_state_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Snapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Snapshot) ProtoMessage() {} + +func (x *Snapshot) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v2_state_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Snapshot.ProtoReflect.Descriptor instead. +func (*Snapshot) Descriptor() ([]byte, []int) { + return file_bd2_state_v2_state_proto_rawDescGZIP(), []int{0} +} + +func (x *Snapshot) GetFormatVersion() uint32 { + if x != nil { + return x.FormatVersion + } + return 0 +} + +func (x *Snapshot) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +func (x *Snapshot) GetGameDataVersion() string { + if x != nil { + return x.GameDataVersion + } + return "" +} + +func (x *Snapshot) GetProgress() *v1.Progress { + if x != nil { + return x.Progress + } + return nil +} + +func (x *Snapshot) GetDeck() *v1.Deck { + if x != nil { + return x.Deck + } + return nil +} + +func (x *Snapshot) GetInventory() *v1.Inventory { + if x != nil { + return x.Inventory + } + return nil +} + +func (x *Snapshot) GetEquipment() *v1.EquipmentInventory { + if x != nil { + return x.Equipment + } + return nil +} + +func (x *Snapshot) GetRoster() *CharacterRoster { + if x != nil { + return x.Roster + } + return nil +} + +func (x *Snapshot) GetCollection() *CollectionLedger { + if x != nil { + return x.Collection + } + return nil +} + +func (x *Snapshot) GetWallet() *v1.Wallet { + if x != nil { + return x.Wallet + } + return nil +} + +func (x *Snapshot) GetMail() *v1.MailState { + if x != nil { + return x.Mail + } + return nil +} + +func (x *Snapshot) GetMissions() *v1.MissionState { + if x != nil { + return x.Missions + } + return nil +} + +type CharacterRecord struct { + state protoimpl.MessageState `protogen:"open.v1"` + Character *v1.Character `protobuf:"bytes,1,opt,name=character,proto3" json:"character,omitempty"` + Origin CharacterOrigin `protobuf:"varint,2,opt,name=origin,proto3,enum=bd2.state.v2.CharacterOrigin" json:"origin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CharacterRecord) Reset() { + *x = CharacterRecord{} + mi := &file_bd2_state_v2_state_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CharacterRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CharacterRecord) ProtoMessage() {} + +func (x *CharacterRecord) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v2_state_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CharacterRecord.ProtoReflect.Descriptor instead. +func (*CharacterRecord) Descriptor() ([]byte, []int) { + return file_bd2_state_v2_state_proto_rawDescGZIP(), []int{1} +} + +func (x *CharacterRecord) GetCharacter() *v1.Character { + if x != nil { + return x.Character + } + return nil +} + +func (x *CharacterRecord) GetOrigin() CharacterOrigin { + if x != nil { + return x.Origin + } + return CharacterOrigin_CHARACTER_ORIGIN_UNSPECIFIED +} + +type CharacterRoster struct { + state protoimpl.MessageState `protogen:"open.v1"` + // These allocators apply only to acquired characters and costumes. Base + // character identities are allocated by the bootstrap source. + NextAcquiredCharacterIndex uint64 `protobuf:"varint,1,opt,name=next_acquired_character_index,json=nextAcquiredCharacterIndex,proto3" json:"next_acquired_character_index,omitempty"` + NextCostumeIndex uint64 `protobuf:"varint,2,opt,name=next_costume_index,json=nextCostumeIndex,proto3" json:"next_costume_index,omitempty"` + Characters []*CharacterRecord `protobuf:"bytes,3,rep,name=characters,proto3" json:"characters,omitempty"` + Costumes []*v1.Costume `protobuf:"bytes,4,rep,name=costumes,proto3" json:"costumes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CharacterRoster) Reset() { + *x = CharacterRoster{} + mi := &file_bd2_state_v2_state_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CharacterRoster) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CharacterRoster) ProtoMessage() {} + +func (x *CharacterRoster) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v2_state_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CharacterRoster.ProtoReflect.Descriptor instead. +func (*CharacterRoster) Descriptor() ([]byte, []int) { + return file_bd2_state_v2_state_proto_rawDescGZIP(), []int{2} +} + +func (x *CharacterRoster) GetNextAcquiredCharacterIndex() uint64 { + if x != nil { + return x.NextAcquiredCharacterIndex + } + return 0 +} + +func (x *CharacterRoster) GetNextCostumeIndex() uint64 { + if x != nil { + return x.NextCostumeIndex + } + return 0 +} + +func (x *CharacterRoster) GetCharacters() []*CharacterRecord { + if x != nil { + return x.Characters + } + return nil +} + +func (x *CharacterRoster) GetCostumes() []*v1.Costume { + if x != nil { + return x.Costumes + } + return nil +} + +// Collection no longer owns character entities. It contains only preview, +// gacha accounting and idempotent grant history. +type CollectionLedger struct { + state protoimpl.MessageState `protogen:"open.v1"` + LatestPreview []uint64 `protobuf:"varint,1,rep,packed,name=latest_preview,json=latestPreview,proto3" json:"latest_preview,omitempty"` + PreviewEventIndex uint64 `protobuf:"varint,2,opt,name=preview_event_index,json=previewEventIndex,proto3" json:"preview_event_index,omitempty"` + PreviewLocked bool `protobuf:"varint,3,opt,name=preview_locked,json=previewLocked,proto3" json:"preview_locked,omitempty"` + BaseCostumeLevels []*v1.NamedCount `protobuf:"bytes,4,rep,name=base_costume_levels,json=baseCostumeLevels,proto3" json:"base_costume_levels,omitempty"` + GachaSelections []*v1.NamedSelections `protobuf:"bytes,5,rep,name=gacha_selections,json=gachaSelections,proto3" json:"gacha_selections,omitempty"` + StepUpProgress []*v1.NamedCount `protobuf:"bytes,6,rep,name=step_up_progress,json=stepUpProgress,proto3" json:"step_up_progress,omitempty"` + GachaUsers []*v1.NamedGachaUser `protobuf:"bytes,7,rep,name=gacha_users,json=gachaUsers,proto3" json:"gacha_users,omitempty"` + GachaFixed []*v1.NamedGachaFixed `protobuf:"bytes,8,rep,name=gacha_fixed,json=gachaFixed,proto3" json:"gacha_fixed,omitempty"` + GachaApplied []string `protobuf:"bytes,9,rep,name=gacha_applied,json=gachaApplied,proto3" json:"gacha_applied,omitempty"` + GachaPointExchanges []*v1.NamedGachaPointExchange `protobuf:"bytes,10,rep,name=gacha_point_exchanges,json=gachaPointExchanges,proto3" json:"gacha_point_exchanges,omitempty"` + GachaCountCorrected bool `protobuf:"varint,11,opt,name=gacha_count_corrected,json=gachaCountCorrected,proto3" json:"gacha_count_corrected,omitempty"` + Grants []*v1.CollectionGrant `protobuf:"bytes,12,rep,name=grants,proto3" json:"grants,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CollectionLedger) Reset() { + *x = CollectionLedger{} + mi := &file_bd2_state_v2_state_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CollectionLedger) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CollectionLedger) ProtoMessage() {} + +func (x *CollectionLedger) ProtoReflect() protoreflect.Message { + mi := &file_bd2_state_v2_state_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CollectionLedger.ProtoReflect.Descriptor instead. +func (*CollectionLedger) Descriptor() ([]byte, []int) { + return file_bd2_state_v2_state_proto_rawDescGZIP(), []int{3} +} + +func (x *CollectionLedger) GetLatestPreview() []uint64 { + if x != nil { + return x.LatestPreview + } + return nil +} + +func (x *CollectionLedger) GetPreviewEventIndex() uint64 { + if x != nil { + return x.PreviewEventIndex + } + return 0 +} + +func (x *CollectionLedger) GetPreviewLocked() bool { + if x != nil { + return x.PreviewLocked + } + return false +} + +func (x *CollectionLedger) GetBaseCostumeLevels() []*v1.NamedCount { + if x != nil { + return x.BaseCostumeLevels + } + return nil +} + +func (x *CollectionLedger) GetGachaSelections() []*v1.NamedSelections { + if x != nil { + return x.GachaSelections + } + return nil +} + +func (x *CollectionLedger) GetStepUpProgress() []*v1.NamedCount { + if x != nil { + return x.StepUpProgress + } + return nil +} + +func (x *CollectionLedger) GetGachaUsers() []*v1.NamedGachaUser { + if x != nil { + return x.GachaUsers + } + return nil +} + +func (x *CollectionLedger) GetGachaFixed() []*v1.NamedGachaFixed { + if x != nil { + return x.GachaFixed + } + return nil +} + +func (x *CollectionLedger) GetGachaApplied() []string { + if x != nil { + return x.GachaApplied + } + return nil +} + +func (x *CollectionLedger) GetGachaPointExchanges() []*v1.NamedGachaPointExchange { + if x != nil { + return x.GachaPointExchanges + } + return nil +} + +func (x *CollectionLedger) GetGachaCountCorrected() bool { + if x != nil { + return x.GachaCountCorrected + } + return false +} + +func (x *CollectionLedger) GetGrants() []*v1.CollectionGrant { + if x != nil { + return x.Grants + } + return nil +} + +var File_bd2_state_v2_state_proto protoreflect.FileDescriptor + +const file_bd2_state_v2_state_proto_rawDesc = "" + + "\n" + + "\x18bd2/state/v2/state.proto\x12\fbd2.state.v2\x1a\x18bd2/state/v1/state.proto\"\xe1\x04\n" + + "\bSnapshot\x12%\n" + + "\x0eformat_version\x18\x01 \x01(\rR\rformatVersion\x12%\n" + + "\x0eclient_version\x18\x02 \x01(\tR\rclientVersion\x12*\n" + + "\x11game_data_version\x18\x03 \x01(\tR\x0fgameDataVersion\x122\n" + + "\bprogress\x18\n" + + " \x01(\v2\x16.bd2.state.v1.ProgressR\bprogress\x12&\n" + + "\x04deck\x18\v \x01(\v2\x12.bd2.state.v1.DeckR\x04deck\x125\n" + + "\tinventory\x18\f \x01(\v2\x17.bd2.state.v1.InventoryR\tinventory\x12>\n" + + "\tequipment\x18\r \x01(\v2 .bd2.state.v1.EquipmentInventoryR\tequipment\x125\n" + + "\x06roster\x18\x0e \x01(\v2\x1d.bd2.state.v2.CharacterRosterR\x06roster\x12>\n" + + "\n" + + "collection\x18\x0f \x01(\v2\x1e.bd2.state.v2.CollectionLedgerR\n" + + "collection\x12,\n" + + "\x06wallet\x18\x10 \x01(\v2\x14.bd2.state.v1.WalletR\x06wallet\x12+\n" + + "\x04mail\x18\x11 \x01(\v2\x17.bd2.state.v1.MailStateR\x04mail\x126\n" + + "\bmissions\x18\x12 \x01(\v2\x1a.bd2.state.v1.MissionStateR\bmissions\"\x7f\n" + + "\x0fCharacterRecord\x125\n" + + "\tcharacter\x18\x01 \x01(\v2\x17.bd2.state.v1.CharacterR\tcharacter\x125\n" + + "\x06origin\x18\x02 \x01(\x0e2\x1d.bd2.state.v2.CharacterOriginR\x06origin\"\xf4\x01\n" + + "\x0fCharacterRoster\x12A\n" + + "\x1dnext_acquired_character_index\x18\x01 \x01(\x04R\x1anextAcquiredCharacterIndex\x12,\n" + + "\x12next_costume_index\x18\x02 \x01(\x04R\x10nextCostumeIndex\x12=\n" + + "\n" + + "characters\x18\x03 \x03(\v2\x1d.bd2.state.v2.CharacterRecordR\n" + + "characters\x121\n" + + "\bcostumes\x18\x04 \x03(\v2\x15.bd2.state.v1.CostumeR\bcostumes\"\xd2\x05\n" + + "\x10CollectionLedger\x12%\n" + + "\x0elatest_preview\x18\x01 \x03(\x04R\rlatestPreview\x12.\n" + + "\x13preview_event_index\x18\x02 \x01(\x04R\x11previewEventIndex\x12%\n" + + "\x0epreview_locked\x18\x03 \x01(\bR\rpreviewLocked\x12H\n" + + "\x13base_costume_levels\x18\x04 \x03(\v2\x18.bd2.state.v1.NamedCountR\x11baseCostumeLevels\x12H\n" + + "\x10gacha_selections\x18\x05 \x03(\v2\x1d.bd2.state.v1.NamedSelectionsR\x0fgachaSelections\x12B\n" + + "\x10step_up_progress\x18\x06 \x03(\v2\x18.bd2.state.v1.NamedCountR\x0estepUpProgress\x12=\n" + + "\vgacha_users\x18\a \x03(\v2\x1c.bd2.state.v1.NamedGachaUserR\n" + + "gachaUsers\x12>\n" + + "\vgacha_fixed\x18\b \x03(\v2\x1d.bd2.state.v1.NamedGachaFixedR\n" + + "gachaFixed\x12#\n" + + "\rgacha_applied\x18\t \x03(\tR\fgachaApplied\x12Y\n" + + "\x15gacha_point_exchanges\x18\n" + + " \x03(\v2%.bd2.state.v1.NamedGachaPointExchangeR\x13gachaPointExchanges\x122\n" + + "\x15gacha_count_corrected\x18\v \x01(\bR\x13gachaCountCorrected\x125\n" + + "\x06grants\x18\f \x03(\v2\x1d.bd2.state.v1.CollectionGrantR\x06grants*m\n" + + "\x0fCharacterOrigin\x12 \n" + + "\x1cCHARACTER_ORIGIN_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15CHARACTER_ORIGIN_BASE\x10\x01\x12\x1d\n" + + "\x19CHARACTER_ORIGIN_ACQUIRED\x10\x02B Z\x1ebd2server/gen/state/v2;statev2b\x06proto3" + +var ( + file_bd2_state_v2_state_proto_rawDescOnce sync.Once + file_bd2_state_v2_state_proto_rawDescData []byte +) + +func file_bd2_state_v2_state_proto_rawDescGZIP() []byte { + file_bd2_state_v2_state_proto_rawDescOnce.Do(func() { + file_bd2_state_v2_state_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_bd2_state_v2_state_proto_rawDesc), len(file_bd2_state_v2_state_proto_rawDesc))) + }) + return file_bd2_state_v2_state_proto_rawDescData +} + +var file_bd2_state_v2_state_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_bd2_state_v2_state_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_bd2_state_v2_state_proto_goTypes = []any{ + (CharacterOrigin)(0), // 0: bd2.state.v2.CharacterOrigin + (*Snapshot)(nil), // 1: bd2.state.v2.Snapshot + (*CharacterRecord)(nil), // 2: bd2.state.v2.CharacterRecord + (*CharacterRoster)(nil), // 3: bd2.state.v2.CharacterRoster + (*CollectionLedger)(nil), // 4: bd2.state.v2.CollectionLedger + (*v1.Progress)(nil), // 5: bd2.state.v1.Progress + (*v1.Deck)(nil), // 6: bd2.state.v1.Deck + (*v1.Inventory)(nil), // 7: bd2.state.v1.Inventory + (*v1.EquipmentInventory)(nil), // 8: bd2.state.v1.EquipmentInventory + (*v1.Wallet)(nil), // 9: bd2.state.v1.Wallet + (*v1.MailState)(nil), // 10: bd2.state.v1.MailState + (*v1.MissionState)(nil), // 11: bd2.state.v1.MissionState + (*v1.Character)(nil), // 12: bd2.state.v1.Character + (*v1.Costume)(nil), // 13: bd2.state.v1.Costume + (*v1.NamedCount)(nil), // 14: bd2.state.v1.NamedCount + (*v1.NamedSelections)(nil), // 15: bd2.state.v1.NamedSelections + (*v1.NamedGachaUser)(nil), // 16: bd2.state.v1.NamedGachaUser + (*v1.NamedGachaFixed)(nil), // 17: bd2.state.v1.NamedGachaFixed + (*v1.NamedGachaPointExchange)(nil), // 18: bd2.state.v1.NamedGachaPointExchange + (*v1.CollectionGrant)(nil), // 19: bd2.state.v1.CollectionGrant +} +var file_bd2_state_v2_state_proto_depIdxs = []int32{ + 5, // 0: bd2.state.v2.Snapshot.progress:type_name -> bd2.state.v1.Progress + 6, // 1: bd2.state.v2.Snapshot.deck:type_name -> bd2.state.v1.Deck + 7, // 2: bd2.state.v2.Snapshot.inventory:type_name -> bd2.state.v1.Inventory + 8, // 3: bd2.state.v2.Snapshot.equipment:type_name -> bd2.state.v1.EquipmentInventory + 3, // 4: bd2.state.v2.Snapshot.roster:type_name -> bd2.state.v2.CharacterRoster + 4, // 5: bd2.state.v2.Snapshot.collection:type_name -> bd2.state.v2.CollectionLedger + 9, // 6: bd2.state.v2.Snapshot.wallet:type_name -> bd2.state.v1.Wallet + 10, // 7: bd2.state.v2.Snapshot.mail:type_name -> bd2.state.v1.MailState + 11, // 8: bd2.state.v2.Snapshot.missions:type_name -> bd2.state.v1.MissionState + 12, // 9: bd2.state.v2.CharacterRecord.character:type_name -> bd2.state.v1.Character + 0, // 10: bd2.state.v2.CharacterRecord.origin:type_name -> bd2.state.v2.CharacterOrigin + 2, // 11: bd2.state.v2.CharacterRoster.characters:type_name -> bd2.state.v2.CharacterRecord + 13, // 12: bd2.state.v2.CharacterRoster.costumes:type_name -> bd2.state.v1.Costume + 14, // 13: bd2.state.v2.CollectionLedger.base_costume_levels:type_name -> bd2.state.v1.NamedCount + 15, // 14: bd2.state.v2.CollectionLedger.gacha_selections:type_name -> bd2.state.v1.NamedSelections + 14, // 15: bd2.state.v2.CollectionLedger.step_up_progress:type_name -> bd2.state.v1.NamedCount + 16, // 16: bd2.state.v2.CollectionLedger.gacha_users:type_name -> bd2.state.v1.NamedGachaUser + 17, // 17: bd2.state.v2.CollectionLedger.gacha_fixed:type_name -> bd2.state.v1.NamedGachaFixed + 18, // 18: bd2.state.v2.CollectionLedger.gacha_point_exchanges:type_name -> bd2.state.v1.NamedGachaPointExchange + 19, // 19: bd2.state.v2.CollectionLedger.grants:type_name -> bd2.state.v1.CollectionGrant + 20, // [20:20] is the sub-list for method output_type + 20, // [20:20] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name +} + +func init() { file_bd2_state_v2_state_proto_init() } +func file_bd2_state_v2_state_proto_init() { + if File_bd2_state_v2_state_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_bd2_state_v2_state_proto_rawDesc), len(file_bd2_state_v2_state_proto_rawDesc)), + NumEnums: 1, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_bd2_state_v2_state_proto_goTypes, + DependencyIndexes: file_bd2_state_v2_state_proto_depIdxs, + EnumInfos: file_bd2_state_v2_state_proto_enumTypes, + MessageInfos: file_bd2_state_v2_state_proto_msgTypes, + }.Build() + File_bd2_state_v2_state_proto = out.File + file_bd2_state_v2_state_proto_goTypes = nil + file_bd2_state_v2_state_proto_depIdxs = nil +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..a4be9e4 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,17 @@ +module bd2server + +go 1.25.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.59.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..b707641 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,22 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= diff --git a/go/internal/account/login.go b/go/internal/account/login.go new file mode 100644 index 0000000..6692efe --- /dev/null +++ b/go/internal/account/login.go @@ -0,0 +1,255 @@ +// Package account owns the local-account representation used by LoginUser. +// It deliberately stores decoded protobuf data, never a captured HTTP reply +// or a captured session key. +package account + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "bd2server/internal/cryptox" + "bd2server/internal/wire" +) + +const Version23413 = "2.34.13" + +var ( + ErrInvalidSeed = errors.New("account: invalid LoginUser seed") +) + +// LoginSeed is the versioned, decoded representation of a LoginUser response. +// UserInfo is UserDBInfo protobuf field 1 with its field 3 (user_key) +// 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 + firstGacha *bool +} + +func (s *LoginSeed) SetFirstGacha(value bool) { s.firstGacha = &value } + +// CurrencyProvider supplies the authoritative mutable UserDBInfo wallet. +type CurrencyProvider interface { + Currencies() (gold, freeJewelry, jewelry, mileage uint64) +} + +type HopePowderProvider interface { + HopePowderBalance() uint64 +} + +func (s *LoginSeed) AttachCurrencies(provider CurrencyProvider) error { + if provider == nil { + return errors.New("account: nil currency provider") + } + s.currencies = provider + return nil +} + +// SeedCurrencies returns the immutable starting balances embedded in the +// versioned UserDBInfo template. Mutable balances live in player.Wallet. +func (s *LoginSeed) SeedCurrencies() (gold, freeJewelry, jewelry, mileage uint64, err error) { + if err = s.Validate(); err != nil { + return 0, 0, 0, 0, err + } + read := func(field int) (uint64, error) { + value, found, readErr := wire.Varint(s.UserInfo, field) + if readErr != nil { + return 0, readErr + } + if !found { + return 0, nil + } + return value, nil + } + if gold, err = read(7); err != nil { + return 0, 0, 0, 0, err + } + if freeJewelry, err = read(8); err != nil { + return 0, 0, 0, 0, err + } + if jewelry, err = read(9); err != nil { + return 0, 0, 0, 0, err + } + if mileage, err = read(23); err != nil { + return 0, 0, 0, 0, err + } + return gold, freeJewelry, jewelry, mileage, nil +} + +func (s *LoginSeed) SeedHopePowder() (uint64, error) { + if err := s.Validate(); err != nil { + return 0, err + } + value, found, err := wire.Varint(s.UserInfo, 24) + if err != nil || !found { + return value, err + } + return value, nil +} + +type diskSeed struct { + Version string `json:"version"` + PacketCode int `json:"packet_code"` + UserInfoBase64 string `json:"user_info_base64"` + ResponseFieldsBase64 string `json:"response_fields_base64,omitempty"` +} + +// Load reads one versioned seed file. Relative paths are intentionally left to +// the caller; production code can therefore choose an explicit local asset. +func Load(path string) (*LoginSeed, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("account: read seed: %w", err) + } + var disk diskSeed + if err := json.Unmarshal(data, &disk); err != nil { + return nil, fmt.Errorf("account: decode seed JSON: %w", err) + } + user, err := base64.StdEncoding.DecodeString(disk.UserInfoBase64) + if err != nil { + return nil, fmt.Errorf("account: decode user_info_base64: %w", err) + } + other, err := base64.StdEncoding.DecodeString(disk.ResponseFieldsBase64) + if err != nil { + return nil, fmt.Errorf("account: decode response_fields_base64: %w", err) + } + seed := &LoginSeed{Version: disk.Version, PacketCode: disk.PacketCode, UserInfo: user, ResponseFields: other} + if err := seed.Validate(); err != nil { + return nil, err + } + return seed, nil +} + +// Write stores a seed in the portable JSON form used by the one-shot importer. +func (s *LoginSeed) Write(path string) error { + if err := s.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(diskSeed{ + Version: s.Version, PacketCode: s.PacketCode, + UserInfoBase64: base64.StdEncoding.EncodeToString(s.UserInfo), + ResponseFieldsBase64: base64.StdEncoding.EncodeToString(s.ResponseFields), + }, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if err := os.WriteFile(filepath.Clean(path), data, 0o644); err != nil { + return fmt.Errorf("account: write seed: %w", err) + } + return nil +} + +// Validate verifies the minimum protocol contract and makes sure a captured +// user_key cannot accidentally be committed to a local seed. +func (s *LoginSeed) Validate() error { + if s == nil || s.Version == "" || s.PacketCode <= 0 || len(s.UserInfo) == 0 { + return ErrInvalidSeed + } + if _, found, err := wire.Bytes(s.UserInfo, 3); err != nil { + return fmt.Errorf("%w: malformed UserInfo: %v", ErrInvalidSeed, err) + } else if found { + return fmt.Errorf("%w: UserInfo contains user_key", ErrInvalidSeed) + } + if err := wire.Walk(s.ResponseFields, func(field wire.Field) error { + if field.Number == 1 { + return fmt.Errorf("%w: response fields contain UserInfo", ErrInvalidSeed) + } + return nil + }); err != nil { + return err + } + return nil +} + +// Encode makes a fresh encrypted LoginUser HTTP envelope. sessionKey belongs +// to the new local session; no value from a capture is used at runtime. +func (s *LoginSeed) Encode(sessionKey string, now time.Time) ([]byte, error) { + if err := s.Validate(); err != nil { + return nil, err + } + proto, err := s.Login(nil, []byte(sessionKey)) + if err != nil { + return nil, err + } + data, err := cryptox.EncryptBase64Payload(proto, cryptox.Key()) + if err != nil { + return nil, err + } + envelope := struct { + ErrorType int `json:"errorType"` + PacketCode int `json:"packetCode"` + Length int `json:"length"` + Data string `json:"data"` + ServerNowTime int64 `json:"serverNowTime"` + }{ + ErrorType: 0, PacketCode: s.PacketCode, + Length: base64.StdEncoding.EncodedLen(len(proto)), Data: data, + ServerNowTime: now.UnixMilli(), + } + return json.Marshal(envelope) +} + +// Login constructs the decoded LoginUser protobuf for one fresh local +// session. request is the already-decrypted LoginUser protobuf. The reply +// is deliberately protobuf-only so the session/transport layer owns its +// packet-code and HTTP envelope policy. +func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { + if err := s.Validate(); err != nil { + return nil, err + } + if len(request) != 0 { + if _, found, err := wire.Varint(request, 1); err != nil || !found { + return nil, fmt.Errorf("account: LoginUser request has no sequence: %w", err) + } + } + if len(sessionKey) != cryptox.AESKeySize { + return nil, fmt.Errorf("account: session key: %w", cryptox.ErrInvalidKey) + } + if _, err := cryptox.SessionKey(string(sessionKey)); err != nil { + return nil, fmt.Errorf("account: session key: %w", err) + } + user := append([]byte(nil), s.UserInfo...) + if s.currencies != nil { + gold, freeJewelry, jewelry, mileage := s.currencies.Currencies() + var err error + if user, _, err = wire.ReplaceVarint(user, 7, gold); err != nil { + return nil, fmt.Errorf("account: replace gold: %w", err) + } + if user, _, err = wire.ReplaceVarint(user, 8, freeJewelry); err != nil { + return nil, fmt.Errorf("account: replace free jewelry: %w", err) + } + if user, _, err = wire.ReplaceVarint(user, 9, jewelry); err != nil { + return nil, fmt.Errorf("account: replace jewelry: %w", err) + } + if user, _, err = wire.ReplaceVarint(user, 23, mileage); err != nil { + return nil, fmt.Errorf("account: replace mileage: %w", err) + } + if provider, ok := s.currencies.(HopePowderProvider); ok { + if user, _, err = wire.ReplaceVarint(user, 24, provider.HopePowderBalance()); err != nil { + return nil, fmt.Errorf("account: replace hope powder: %w", err) + } + } + } + if s.firstGacha != nil { + value := uint64(0) + if *s.firstGacha { + value = 1 + } + var err error + if user, _, err = wire.ReplaceVarint(user, 27, value); err != nil { + return nil, fmt.Errorf("account: replace first gacha: %w", err) + } + } + user = wire.AppendBytes(user, 3, sessionKey) + return append(wire.AppendBytes(nil, 1, user), s.ResponseFields...), nil +} diff --git a/go/internal/account/login_test.go b/go/internal/account/login_test.go new file mode 100644 index 0000000..9f25b9d --- /dev/null +++ b/go/internal/account/login_test.go @@ -0,0 +1,89 @@ +package account + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "bd2server/internal/cryptox" + "bd2server/internal/wire" +) + +func TestEncodeUsesFreshLocalKey(t *testing.T) { + user := wire.AppendVarint(nil, 1, 42) + user = wire.AppendString(user, 2, "Guest_42") + user = wire.AppendVarint(user, 5, 100) + seed := &LoginSeed{Version: Version23413, PacketCode: 11, UserInfo: user} + const local = "0123456789abcdef0123456789abcdef" + body, err := seed.Encode(local, time.UnixMilli(1234)) + if err != nil { + t.Fatal(err) + } + var envelope struct { + PacketCode int `json:"packetCode"` + Length int `json:"length"` + Data string `json:"data"` + ServerNowTime int64 `json:"serverNowTime"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatal(err) + } + if envelope.PacketCode != 11 || envelope.ServerNowTime != 1234 { + t.Fatalf("envelope=%+v", envelope) + } + proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key()) + if err != nil { + t.Fatal(err) + } + responseUser, found, err := wire.Bytes(proto, 1) + if err != nil || !found { + t.Fatalf("response UserInfo: found=%v err=%v", found, err) + } + gotKey, found, err := wire.Bytes(responseUser, 3) + if err != nil || !found || string(gotKey) != local { + t.Fatalf("local key=%q found=%v err=%v", gotKey, found, err) + } +} + +func TestLoginValidatesEncryptedRequest(t *testing.T) { + seed := &LoginSeed{Version: Version23413, PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)} + if _, err := seed.Login([]byte("not protobuf"), []byte("0123456789abcdef0123456789abcdef")); err == nil { + t.Fatal("Login accepted invalid protobuf request") + } +} + +func TestLoadRejectsSeedWithUserKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + seed := &LoginSeed{Version: Version23413, PacketCode: 11, UserInfo: wire.AppendString(nil, 3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")} + if err := seed.Write(path); err == nil { + t.Fatal("Write accepted user_key") + } + if err := os.WriteFile(path, []byte(`{"version":"2.34.13","packet_code":11,"user_info_base64":"GgF4"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("Load accepted user_key") + } +} + +func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) { + seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "login_user.json")) + if err != nil { + t.Fatal(err) + } + proto, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatal(err) + } + user, found, err := wire.Bytes(proto, 1) + if err != nil || !found { + t.Fatalf("missing UserInfo: found=%v err=%v", found, err) + } + key, found, err := wire.Bytes(user, 3) + if err != nil || !found || string(key) != "0123456789abcdef0123456789abcdef" { + t.Fatalf("unexpected generated key: %q found=%v err=%v", key, found, err) + } +} diff --git a/go/internal/battle/battle.go b/go/internal/battle/battle.go new file mode 100644 index 0000000..448276b --- /dev/null +++ b/go/internal/battle/battle.go @@ -0,0 +1,208 @@ +// Package battle implements a deterministic local battle session. The client +// remains authoritative for turn simulation; the server validates sequencing +// and echoes the submitted combat state without capture replay. +package battle + +import ( + "errors" + "fmt" + "log/slog" + "sync" + + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +type Service struct { + mu sync.Mutex + entered bool + index uint64 + round uint64 + monster uint64 + deck uint64 + initialBlue [][]byte + gameDataRoot string + gameDataVersion string + inventory *player.Inventory + buffs func() ([]gamedata.PictorialBuffStat, error) + onTutorialWin func() error +} + +func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback } + +func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory) *Service { + return &Service{gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, inventory: inventory} +} + +func (s *Service) AttachPictorialBuffs(buffs func() ([]gamedata.PictorialBuffStat, error)) { + s.buffs = buffs +} + +func checkSeq(request []byte) error { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return errors.New("battle: invalid request sequence") + } + return nil +} + +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/BattleEnter" && path != "/BattleStart" && path != "/BattleRetry" && path != "/BattleVerifyState" && path != "/BattleEnd" && path != "/BattleExit" { + return 0, nil, false, nil + } + if err := checkSeq(request); err != nil { + return 0, nil, true, err + } + s.mu.Lock() + defer s.mu.Unlock() + switch path { + case "/BattleVerifyState": + if !s.entered { + return 0, nil, true, errors.New("battle: verify before enter") + } + // Packet code 142 follows BattleVerify(141). State 3 is the protocol's + // explicit SUCCESS value; PVE does not require authoritative team lists. + return 142, wire.AppendVarint(nil, 1, 3), true, nil + case "/BattleEnter": + deck, deckFound, err := wire.Varint(request, 4) + if err != nil || !deckFound || deck == 0 { + return 0, nil, true, errors.New("battle: missing battle deck") + } + mode, modeFound, err := wire.Varint(request, 5) + if err != nil || !modeFound || mode == 0 { + return 0, nil, true, errors.New("battle: missing battle mode") + } + s.entered, s.index, s.round, s.initialBlue = true, 0, 0, nil + s.monster, _, _ = wire.Varint(request, 3) + s.deck = deck + slog.Info("team trace: battle entered", "monster", s.monster, "enemyDeck", deck, "mode", mode) + response := wire.AppendVarint(nil, 2, deck) + if s.buffs != nil { + buffs, err := s.buffs() + if err != nil { + return 0, nil, true, fmt.Errorf("battle: account pictorial buffs: %w", err) + } + for _, buff := range buffs { + entry := wire.AppendVarint(nil, 1, buff.StatType) + entry = wire.AppendDouble(entry, 2, buff.Value) + if buff.Category != 0 { + entry = wire.AppendVarint(entry, 3, buff.Category) + } + response = wire.AppendBytes(response, 4, entry) + } + } + // The local engine is the normal deterministic engine. + response = wire.AppendVarint(response, 6, 1) + return 52, response, true, nil + case "/BattleRetry": + if !s.entered { + return 0, nil, true, errors.New("battle: retry before enter") + } + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("battle: retry missing battle index") + } + if len(s.initialBlue) == 0 { + return 0, nil, true, errors.New("battle: retry before initial battle state") + } + var response []byte + for _, character := range s.initialBlue { + response = wire.AppendBytes(response, 2, character) + } + response = wire.AppendVarint(response, 3, index) + s.index, s.round = index, 0 + return 58, response, true, nil + case "/BattleStart": + if !s.entered { + return 0, nil, true, errors.New("battle: start before enter") + } + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("battle: invalid battle index") + } + if s.index != 0 && index != s.index { + return 0, nil, true, fmt.Errorf("battle: index changed from %d to %d", s.index, index) + } + s.index, s.round = index, s.round+1 + if s.round == 1 { + s.initialBlue = nil + } + var response []byte + err = wire.Walk(request, func(field wire.Field) error { + if field.Type != 2 { + return nil + } + if field.Number == 4 { + response = wire.AppendBytes(response, 1, field.Value) + } else if field.Number == 5 { + response = wire.AppendBytes(response, 2, field.Value) + if s.round == 1 { + s.initialBlue = append(s.initialBlue, append([]byte(nil), field.Value...)) + } + } + return nil + }) + if err != nil { + return 0, nil, true, err + } + // Stable per-battle/round seed; reproducible across retries. + seed := index*7919 + s.round*104729 + response = wire.AppendVarint(response, 3, seed) + return 14, response, true, nil + case "/BattleEnd": + if !s.entered { + return 0, nil, true, errors.New("battle: end before enter") + } + result, found, err := wire.Varint(request, 2) + if err != nil || !found || result == 0 { + return 0, nil, true, errors.New("battle: invalid result") + } + response := wire.AppendVarint(nil, 1, result) + err = wire.Walk(request, func(field wire.Field) error { + if field.Number == 3 && field.Type == 2 { + response = wire.AppendBytes(response, 3, field.Value) + } + return nil + }) + if err != nil { + return 0, nil, true, err + } + rewardBundle := false + if result == 1 && s.inventory != nil && s.monster != 0 && s.gameDataRoot != "" { + rewards, rewardErr := gamedata.BattleDeckRewards(s.gameDataRoot, s.gameDataVersion, 21, s.deck) + if rewardErr != nil { + return 0, nil, true, fmt.Errorf("battle: monster %d deck %d rewards: %w", s.monster, s.deck, rewardErr) + } + items, grantErr := s.inventory.GrantOnce(fmt.Sprintf("pack21:monster%d:deck%d", s.monster, s.deck), rewards) + if grantErr != nil { + return 0, nil, true, grantErr + } + var bundle []byte + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + } + if len(bundle) != 0 { + response = wire.AppendBytes(response, 5, bundle) + rewardBundle = true + } + } + if result == 1 && s.onTutorialWin != nil { + if err := s.onTutorialWin(); err != nil { + return 0, nil, true, fmt.Errorf("battle: update tutorial kill mission: %w", err) + } + } + for _, field := range []int{5, 6, 7, 8, 10, 11, 15, 16, 25} { + if field == 5 && rewardBundle { + continue + } + response = wire.AppendBytes(response, field, nil) + } + s.entered, s.deck, s.initialBlue = false, 0, nil + return 15, response, true, nil + case "/BattleExit": + s.entered, s.index, s.round, s.deck, s.initialBlue = false, 0, 0, 0, nil + return 388, nil, true, nil + } + panic("unreachable") +} diff --git a/go/internal/battle/battle_test.go b/go/internal/battle/battle_test.go new file mode 100644 index 0000000..1318fa4 --- /dev/null +++ b/go/internal/battle/battle_test.go @@ -0,0 +1,122 @@ +package battle + +import ( + "testing" + + "bd2server/internal/gamedata" + "bd2server/internal/wire" +) + +func request(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) } + +func TestLocalBattleLifecycle(t *testing.T) { + s := &Service{} + enter := request(1) + enter = wire.AppendVarint(enter, 4, 1) + enter = wire.AppendVarint(enter, 5, 1) + code, response, ok, err := s.Handle("/BattleEnter", enter) + if err != nil || !ok || code != 52 { + t.Fatalf("enter: %d %v %v", code, ok, err) + } + engine, found, _ := wire.Varint(response, 6) + if !found || engine != 1 { + t.Fatalf("engine=%d/%v", engine, found) + } + red := wire.AppendVarint(nil, 1, 1) + blue := wire.AppendVarint(nil, 1, 6) + start := request(2) + start = wire.AppendVarint(start, 2, 1) + start = wire.AppendBytes(start, 4, red) + start = wire.AppendBytes(start, 5, blue) + code, response, _, err = s.Handle("/BattleStart", start) + if err != nil || code != 14 { + t.Fatalf("start: %d %v", code, err) + } + if _, found, _ := wire.Bytes(response, 1); !found { + t.Fatal("red state not echoed") + } + if _, found, _ := wire.Bytes(response, 2); !found { + t.Fatal("blue state not echoed") + } + code, response, _, err = s.Handle("/BattleVerifyState", request(3)) + state, found, _ := wire.Varint(response, 1) + if err != nil || code != 142 || !found || state != 3 { + t.Fatalf("verify: code=%d state=%d/%v err=%v", code, state, found, err) + } + end := request(4) + end = wire.AppendVarint(end, 2, 2) + end = wire.AppendBytes(end, 3, blue) + code, response, _, err = s.Handle("/BattleEnd", end) + result, found, _ := wire.Varint(response, 1) + if err != nil || code != 15 || !found || result != 2 { + t.Fatalf("end: code=%d result=%d/%v err=%v", code, result, found, err) + } +} + +func TestBattleOrdering(t *testing.T) { + s := &Service{} + start := request(1) + start = wire.AppendVarint(start, 2, 1) + if _, _, _, err := s.Handle("/BattleStart", start); err == nil { + t.Fatal("start without enter accepted") + } +} + +func TestBattleRetryRestoresFirstSubmittedBlueTeam(t *testing.T) { + s := &Service{} + enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 4), 5, 1) + if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil { + t.Fatal(err) + } + first := wire.AppendVarint(wire.AppendVarint(nil, 2, 101), 4, 513) + second := wire.AppendVarint(wire.AppendVarint(nil, 2, 102), 4, 200) + start := wire.AppendVarint(request(2), 2, 77) + start = wire.AppendBytes(start, 5, first) + start = wire.AppendBytes(start, 5, second) + if _, _, _, err := s.Handle("/BattleStart", start); err != nil { + t.Fatal(err) + } + retry := wire.AppendVarint(request(3), 2, 77) + code, response, handled, err := s.Handle("/BattleRetry", retry) + if err != nil || !handled || code != 58 { + t.Fatalf("retry code=%d handled=%v err=%v", code, handled, err) + } + index, found, err := wire.Varint(response, 3) + if err != nil || !found || index != 77 { + t.Fatalf("retry battle index=%d found=%v err=%v", index, found, err) + } + var restored [][]byte + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 2 { + restored = append(restored, field.Value) + } + return nil + }); err != nil || len(restored) != 2 || string(restored[0]) != string(first) || string(restored[1]) != string(second) { + t.Fatalf("retry team=%x err=%v", restored, err) + } +} + +func TestBattleEnterUsesSamePictorialSnapshotAsAllCharRefresh(t *testing.T) { + s := &Service{} + s.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) { + return []gamedata.PictorialBuffStat{{StatType: 2, Value: .0175}, {StatType: 4, Value: .01}}, nil + }) + enter := wire.AppendVarint(wire.AppendVarint(request(9), 4, 1), 5, 1) + code, response, handled, err := s.Handle("/BattleEnter", enter) + if err != nil || !handled || code != 52 { + t.Fatalf("battle entry code=%d handled=%v err=%v", code, handled, err) + } + var count int + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 4 { + count++ + stat, found, err := wire.Varint(field.Value, 1) + if err != nil || !found || (stat != 2 && stat != 4) { + t.Fatalf("invalid battle buff stat=%d found=%v err=%v", stat, found, err) + } + } + return nil + }); err != nil || count != 2 { + t.Fatalf("battle buffs count=%d err=%v", count, err) + } +} diff --git a/go/internal/bootstrap/bootstrap.go b/go/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..a56e5fa --- /dev/null +++ b/go/internal/bootstrap/bootstrap.go @@ -0,0 +1,84 @@ +// Package bootstrap implements only the server discovery phase of BD2 2.34.13. +// Fields are taken from the current Proto/Net definitions, not the older checklist. +package bootstrap + +import ( + "fmt" + "net/url" + "strings" + "time" + + "bd2server/internal/wire" +) + +const ( + ClientVersion = "2.34.13" + BundleVersion = "20260904222602" +) + +type Config struct { + BaseURL string // Must include the trailing slash, e.g. http://127.0.0.1:8080/game/ + CDNURL string // Base for /StandaloneWindows64/HD//... + GameDataURL string // Leave empty until a healthy GameData archive is available. + GameDataVer string + Version string + BundleVer string +} + +func (c Config) Validate() error { + for _, pair := range [][2]string{{"game server", c.BaseURL}, {"CDN", c.CDNURL}} { + u, err := url.Parse(pair[1]) + if err != nil || u.Scheme != "http" || u.Host == "" { + return fmt.Errorf("%s needs a valid local HTTP URL: %q", pair[0], pair[1]) + } + } + if !strings.HasSuffix(c.BaseURL, "/") { + return fmt.Errorf("game server URL needs a trailing slash: %q", c.BaseURL) + } + if (c.GameDataURL == "") != (c.GameDataVer == "") { + return fmt.Errorf("GameData URL and version must be provided together") + } + if c.Version == "" || c.BundleVer == "" { + return fmt.Errorf("client and bundle versions must not be empty") + } + return nil +} + +// MaintenanceInfoResponse: market_info=1, connect_type=3, user_type=4. +// MaintenanceInfo: market_type=1, version=2, bundle_version=3. +func Maintenance(version, bundle string, request []byte) ([]byte, error) { + _, _, err := wire.Varint(request, 2) // validate MaintenanceInfoRequest.market_type + if err != nil { + return nil, err + } + // The current official 2.34.13 trace requests market_type=8 but returns + // MaintenanceInfo.market_type=4. These are different enums; do not mirror. + market := wire.AppendVarint(nil, 1, 4) + market = wire.AppendString(market, 2, version) + market = wire.AppendString(market, 3, bundle) + answer := wire.AppendBytes(nil, 1, market) + answer = wire.AppendVarint(answer, 3, 1) // normal connection + answer = wire.AppendVarint(answer, 4, 1) // nonzero, avoid platform-update branch + return answer, nil +} + +// ServerInfoResponse.info_list=1; nested ServerInfo fields 2/3/5/6/7/8/9. +func ServerInfo(c Config) []byte { + info := wire.AppendString(nil, 2, c.BaseURL) + info = wire.AppendString(info, 3, c.CDNURL) + info = wire.AppendString(info, 5, strings.TrimSuffix(c.BaseURL, "game/")+"logs") + // Blank chat-server address avoids connecting to an unrelated official host. + if c.GameDataURL != "" { + info = wire.AppendString(info, 8, c.GameDataURL) + info = wire.AppendString(info, 9, c.GameDataVer) + } + return wire.AppendBytes(nil, 1, info) +} + +// ServerNowTimeResponse.server_time=1 (Unix milliseconds). +func ServerNowTime(now time.Time) []byte { + return wire.AppendVarint(nil, 1, uint64(now.UnixMilli())) +} + +// NoticeInfoResponse has a repeated notice_info=1; no notices is valid. +func NoticeInfo() []byte { return nil } diff --git a/go/internal/bootstrap/bootstrap_test.go b/go/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..81a7368 --- /dev/null +++ b/go/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,71 @@ +package bootstrap + +import ( + "testing" + "time" + + "bd2server/internal/wire" +) + +func TestMaintenance(t *testing.T) { + req := wire.AppendVarint(nil, 1, 2) + req = wire.AppendVarint(req, 2, 8) + response, err := Maintenance(ClientVersion, BundleVersion, req) + if err != nil { + t.Fatal(err) + } + market, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("market info missing: %v", err) + } + typ, _, _ := wire.Varint(market, 1) + if typ != 4 { + t.Fatalf("market type does not match 2.34.13 response: %d", typ) + } + connect, _, _ := wire.Varint(response, 3) + user, _, _ := wire.Varint(response, 4) + if connect != 1 || user == 0 { + t.Fatalf("client would enter update branch: connect=%d user=%d", connect, user) + } +} + +func TestServerInfoNoOfficialEndpoints(t *testing.T) { + c := Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: ClientVersion, BundleVer: BundleVersion} + if err := c.Validate(); err != nil { + t.Fatal(err) + } + response := ServerInfo(c) + info, _, _ := wire.Bytes(response, 1) + address, _, _ := wire.Bytes(info, 2) + if string(address) != c.BaseURL { + t.Fatalf("game URL: %q", address) + } + if _, exists, _ := wire.Bytes(info, 8); exists { + t.Fatal("unexpected GameData URL") + } + if v, _, _ := wire.Varint(ServerNowTime(time.UnixMilli(1234567)), 1); v != 1234567 { + t.Fatalf("time: %d", v) + } +} + +func TestServerInfoIncludesLocalGameData(t *testing.T) { + c := Config{ + BaseURL: "http://127.0.0.1:8080/game/", + CDNURL: "http://127.0.0.1:8080/assets/ServerData", + Version: ClientVersion, BundleVer: BundleVersion, + GameDataURL: "http://127.0.0.1:8080/assets/GameData", + GameDataVer: "20260910162539", + } + if err := c.Validate(); err != nil { + t.Fatal(err) + } + info, found, err := wire.Bytes(ServerInfo(c), 1) + if err != nil || !found { + t.Fatalf("server info missing: %v", err) + } + url, _, _ := wire.Bytes(info, 8) + version, _, _ := wire.Bytes(info, 9) + if string(url) != c.GameDataURL || string(version) != c.GameDataVer { + t.Fatalf("GameData mismatch: url=%q version=%q", url, version) + } +} diff --git a/go/internal/cryptox/cryptox.go b/go/internal/cryptox/cryptox.go new file mode 100644 index 0000000..406601d --- /dev/null +++ b/go/internal/cryptox/cryptox.go @@ -0,0 +1,136 @@ +// Package cryptox implements the small AES envelope used by the BD2 game API. +// +// The protocol key is deliberately represented as its ASCII bytes: a session +// key such as "7eeb...0b15" is not hex-decoded before it is supplied to AES. +package cryptox + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "errors" + "fmt" +) + +const ( + // FixedLoginKey encrypts LoginUser and JoinUser before a user_key exists. + FixedLoginKey = "abcdefghijkrstuv024680wxyzlmnopq" + // AESKeySize is the wire protocol key size (AES-256). + AESKeySize = 32 +) + +var zeroIV [aes.BlockSize]byte + +var ( + ErrInvalidKey = errors.New("bd2 crypto: key must be exactly 32 ASCII bytes") + ErrInvalidCiphertext = errors.New("bd2 crypto: ciphertext must be a non-empty AES block sequence") + ErrInvalidPadding = errors.New("bd2 crypto: invalid PKCS#7 padding") +) + +// Key returns the fixed pre-login AES key. +func Key() []byte { return []byte(FixedLoginKey) } + +// SessionKey validates a login user_key. The protocol passes its 32 ASCII +// characters directly to AES; it does not decode the hexadecimal spelling. +func SessionKey(userKey string) ([]byte, error) { + if len(userKey) != AESKeySize { + return nil, fmt.Errorf("%w: got %d bytes", ErrInvalidKey, len(userKey)) + } + for _, c := range []byte(userKey) { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { + return nil, fmt.Errorf("%w: session key is not hexadecimal ASCII", ErrInvalidKey) + } + } + return []byte(userKey), nil +} + +// Encrypt applies PKCS#7 and AES-256-CBC using the all-zero protocol IV. +func Encrypt(plain, key []byte) ([]byte, error) { + if len(key) != AESKeySize { + return nil, ErrInvalidKey + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + padded := pad(plain, block.BlockSize()) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, zeroIV[:]).CryptBlocks(out, padded) + return out, nil +} + +// Decrypt removes AES-256-CBC and requires valid PKCS#7 padding. +func Decrypt(ciphertext, key []byte) ([]byte, error) { + if len(key) != AESKeySize { + return nil, ErrInvalidKey + } + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return nil, ErrInvalidCiphertext + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + out := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, zeroIV[:]).CryptBlocks(out, ciphertext) + return unpad(out, block.BlockSize()) +} + +// EncryptBase64 returns the API's AES transport value. +func EncryptBase64(plain, key []byte) (string, error) { + ciphertext, err := Encrypt(plain, key) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// DecryptBase64 accepts the API's base64(AES-CBC(...)) transport value. +func DecryptBase64(encoded string, key []byte) ([]byte, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("bd2 crypto: invalid base64 ciphertext: %w", err) + } + return Decrypt(ciphertext, key) +} + +// EncryptBase64Payload is the game-message envelope transform. A protobuf +// payload is first base64 encoded, then AES-CBC encrypted, then base64 encoded +// for JSON transport. +func EncryptBase64Payload(payload, key []byte) (string, error) { + return EncryptBase64([]byte(base64.StdEncoding.EncodeToString(payload)), key) +} + +// DecryptBase64Payload reverses EncryptBase64Payload and returns protobuf +// bytes directly. +func DecryptBase64Payload(encoded string, key []byte) ([]byte, error) { + inner, err := DecryptBase64(encoded, key) + if err != nil { + return nil, err + } + payload, err := base64.StdEncoding.DecodeString(string(inner)) + if err != nil { + return nil, fmt.Errorf("bd2 crypto: invalid inner base64 payload: %w", err) + } + return payload, nil +} + +func pad(src []byte, blockSize int) []byte { + n := blockSize - len(src)%blockSize + out := make([]byte, len(src)+n) + copy(out, src) + copy(out[len(src):], bytes.Repeat([]byte{byte(n)}, n)) + return out +} + +func unpad(src []byte, blockSize int) ([]byte, error) { + if len(src) == 0 || len(src)%blockSize != 0 { + return nil, ErrInvalidPadding + } + n := int(src[len(src)-1]) + if n == 0 || n > blockSize || n > len(src) || !bytes.Equal(src[len(src)-n:], bytes.Repeat([]byte{byte(n)}, n)) { + return nil, ErrInvalidPadding + } + return src[:len(src)-n], nil +} diff --git a/go/internal/cryptox/cryptox_test.go b/go/internal/cryptox/cryptox_test.go new file mode 100644 index 0000000..2d55f76 --- /dev/null +++ b/go/internal/cryptox/cryptox_test.go @@ -0,0 +1,37 @@ +package cryptox + +import ( + "bytes" + "testing" +) + +func TestAESRoundTripAndFixedKey(t *testing.T) { + plain := []byte("BD2 AES fixture: blocks and PKCS7") + encoded, err := EncryptBase64(plain, Key()) + if err != nil { + t.Fatal(err) + } + got, err := DecryptBase64(encoded, Key()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plain) { + t.Fatalf("round trip = %q, want %q", got, plain) + } +} + +func TestSessionKeyValidation(t *testing.T) { + key, err := SessionKey("7eeb5c2480c298f6fc4c00d22b1b0b15") + if err != nil || string(key) != "7eeb5c2480c298f6fc4c00d22b1b0b15" { + t.Fatalf("SessionKey = %q, %v", key, err) + } + if _, err := SessionKey("not-a-key"); err == nil { + t.Fatal("SessionKey accepted invalid key") + } +} + +func TestRejectBadPadding(t *testing.T) { + if _, err := Decrypt(make([]byte, 16), Key()); err == nil { + t.Fatal("Decrypt accepted bad padding") + } +} diff --git a/go/internal/dbcrypt/pages.go b/go/internal/dbcrypt/pages.go new file mode 100644 index 0000000..58ea1fa --- /dev/null +++ b/go/internal/dbcrypt/pages.go @@ -0,0 +1,65 @@ +// Package dbcrypt implements the page cipher shared by Intro and GameData +// SQLite files in Brown Dust II 2.34.13. +package dbcrypt + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha1" + "fmt" +) + +const PageSize = 4096 + +var Header = []byte("SQLite format 3\x00") + +func DecryptPages(in []byte) ([]byte, error) { return cryptPages(in, false) } +func EncryptPages(in []byte) ([]byte, error) { return cryptPages(in, true) } + +func cryptPages(in []byte, encrypt bool) ([]byte, error) { + if len(in) == 0 || len(in)%PageSize != 0 { + return nil, fmt.Errorf("dbcrypt: database length %d is not a non-zero multiple of %d", len(in), PageSize) + } + block, err := aes.NewCipher(deriveKey()) + if err != nil { + return nil, err + } + out := make([]byte, len(in)) + for start := 0; start < len(in); start += PageSize { + var mode cipher.BlockMode = cipher.NewCBCEncrypter(block, Header) + if !encrypt { + mode = cipher.NewCBCDecrypter(block, Header) + } + mode.CryptBlocks(out[start:start+PageSize], in[start:start+PageSize]) + } + return out, nil +} + +func deriveKey() []byte { + password := []byte(fmt.Sprintf("%X", sha1.Sum([]byte("spdhdnlwmrpavmtm")))) + return pbkdf2SHA1(password, Header, 2010, 32) +} + +func pbkdf2SHA1(password, salt []byte, iterations, length int) []byte { + var result []byte + for block := uint32(1); len(result) < length; block++ { + message := append(append([]byte{}, salt...), byte(block>>24), byte(block>>16), byte(block>>8), byte(block)) + u := hmacSHA1(password, message) + t := append([]byte{}, u...) + for i := 1; i < iterations; i++ { + u = hmacSHA1(password, u) + for j := range t { + t[j] ^= u[j] + } + } + result = append(result, t...) + } + return result[:length] +} + +func hmacSHA1(key, message []byte) []byte { + h := hmac.New(sha1.New, key) + _, _ = h.Write(message) + return h.Sum(nil) +} diff --git a/go/internal/dbcrypt/pages_test.go b/go/internal/dbcrypt/pages_test.go new file mode 100644 index 0000000..c49bb19 --- /dev/null +++ b/go/internal/dbcrypt/pages_test.go @@ -0,0 +1,26 @@ +package dbcrypt + +import ( + "bytes" + "testing" +) + +func TestRoundTrip(t *testing.T) { + plain := make([]byte, PageSize*2) + copy(plain, Header) + copy(plain[PageSize:], []byte("second independent page")) + encrypted, err := EncryptPages(plain) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(encrypted, plain) { + t.Fatal("encryption did not change data") + } + decrypted, err := DecryptPages(encrypted) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decrypted, plain) { + t.Fatal("round trip changed pages") + } +} diff --git a/go/internal/deck/deck.go b/go/internal/deck/deck.go new file mode 100644 index 0000000..d35fea1 --- /dev/null +++ b/go/internal/deck/deck.go @@ -0,0 +1,523 @@ +// Package deck owns local deck, field-party, waypoint, and selected-costume +// state. It stores typed JSON, never captured protobuf/base64 envelopes. +package deck + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "sync" + + "bd2server/internal/wire" +) + +const version = "2.34.13" + +type DeckEntry struct { + CharacterInvenIndex uint64 `json:"character_inven_index"` + CostumeInvenIndex uint64 `json:"costume_inven_index"` + Slot uint64 `json:"slot"` +} +type FieldEntry struct { + Slot uint64 `json:"slot"` + CharacterInvenIndex uint64 `json:"character_inven_index"` + CostumeInvenIndex uint64 `json:"costume_inven_index"` +} +type Seed struct { + Version string `json:"version"` + FieldDeck []FieldEntry `json:"field_deck"` + FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` + AutoReviveCatalyst uint64 `json:"auto_revive_catalyst,omitempty"` +} +type state struct { + Version string `json:"version"` + Deck []DeckEntry `json:"deck"` + FieldDeck []FieldEntry `json:"field_deck"` + FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` + Waypoints map[uint64]uint64 `json:"waypoints"` + Costumes map[uint64]uint64 `json:"costumes"` + Packs map[uint64]uint64 `json:"packs"` + HighestTotalBattlePower uint64 `json:"highest_total_battle_power"` + PortraitCostumeID uint64 `json:"portrait_costume_id"` + AutoReviveCatalyst uint64 `json:"auto_revive_catalyst,omitempty"` +} +type Store struct { + mu sync.RWMutex + path string + state state +} + +func (s *Store) CurrentDeck() []DeckEntry { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]DeckEntry(nil), s.state.Deck...) +} + +func LoadSeed(path string) (Seed, error) { + b, e := os.ReadFile(path) + if e != nil { + return Seed{}, fmt.Errorf("deck: read seed: %w", e) + } + var s Seed + if e = json.Unmarshal(b, &s); e != nil { + return Seed{}, fmt.Errorf("deck: decode seed: %w", e) + } + if e = s.validate(); e != nil { + return Seed{}, e + } + return s, nil +} +func (s Seed) validate() error { + if s.Version != version { + return errors.New("deck: wrong seed version") + } + return validField(s.FieldDeck) +} +func validField(entries []FieldEntry) error { + seen := map[uint64]bool{} + for _, e := range entries { + if e.Slot == 0 || e.CharacterInvenIndex == 0 || e.CostumeInvenIndex == 0 || seen[e.Slot] { + return errors.New("deck: invalid field deck") + } + seen[e.Slot] = true + } + return nil +} +func NewStore(seed Seed) (*Store, error) { + if e := seed.validate(); e != nil { + return nil, e + } + return &Store{state: state{Version: version, FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}}, nil +} +func OpenStore(path string, seed Seed) (*Store, error) { + s, e := NewStore(seed) + if e != nil { + return nil, e + } + if path == "" { + return nil, errors.New("deck: empty save path") + } + s.path = filepath.Clean(path) + b, e := os.ReadFile(s.path) + if errors.Is(e, os.ErrNotExist) { + return s, nil + } + if e != nil { + return nil, fmt.Errorf("deck: read state: %w", e) + } + if e = json.Unmarshal(b, &s.state); e != nil { + return nil, fmt.Errorf("deck: malformed state: %w", e) + } + if s.state.Version != version || validField(s.state.FieldDeck) != nil { + return nil, errors.New("deck: invalid saved state") + } + if s.state.Waypoints == nil { + s.state.Waypoints = map[uint64]uint64{} + } + if s.state.Costumes == nil { + s.state.Costumes = map[uint64]uint64{} + } + if s.state.Packs == nil { + s.state.Packs = map[uint64]uint64{} + } + // Saves from before the auto-revive protocol had no catalyst field. The + // versioned initial account seed provides its observed initial balance. + if s.state.AutoReviveCatalyst == 0 { + s.state.AutoReviveCatalyst = seed.AutoReviveCatalyst + } + return s, nil +} +func (s *Store) commit(next state) error { + if s.path != "" { + b, e := json.MarshalIndent(next, "", " ") + if e != nil { + return e + } + dir := filepath.Dir(s.path) + if e = os.MkdirAll(dir, 0700); e != nil { + return e + } + f, e := os.CreateTemp(dir, ".deck-*.tmp") + if e != nil { + return e + } + name := f.Name() + defer os.Remove(name) + if _, e = f.Write(append(b, '\n')); e == nil { + e = f.Sync() + } + if closeErr := f.Close(); e == nil { + e = closeErr + } + if e != nil { + return e + } + if e = os.Rename(name, s.path); e != nil { + return e + } + } + s.state = next + return nil +} +func clone(x state) state { + y := x + y.Deck = append([]DeckEntry(nil), x.Deck...) + y.FieldDeck = append([]FieldEntry(nil), x.FieldDeck...) + y.Waypoints = map[uint64]uint64{} + for k, v := range x.Waypoints { + y.Waypoints[k] = v + } + y.Costumes = map[uint64]uint64{} + for k, v := range x.Costumes { + y.Costumes[k] = v + } + y.Packs = map[uint64]uint64{} + for k, v := range x.Packs { + y.Packs[k] = v + } + return y +} +func checkSeq(req []byte) error { + v, ok, e := wire.Varint(req, 1) + if e != nil || !ok || v == 0 { + return errors.New("deck: invalid request sequence") + } + return nil +} +func triples(req []byte) ([]DeckEntry, error) { + var out []DeckEntry + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return errors.New("deck: deck field") + } + a, aok, e := wire.Varint(f.Value, 1) + if e != nil || !aok || a == 0 { + return errors.New("deck: deck character") + } + b, _, e := wire.Varint(f.Value, 2) + if e != nil { + return errors.New("deck: deck costume") + } + c, cok, e := wire.Varint(f.Value, 3) + if e != nil || !cok || c == 0 { + return errors.New("deck: deck slot") + } + out = append(out, DeckEntry{a, b, c}) + return nil + }) + if e != nil { + return nil, e + } + if len(out) == 0 { + return nil, errors.New("deck: empty deck") + } + seen := map[uint64]bool{} + for _, x := range out { + if seen[x.Slot] { + return nil, errors.New("deck: duplicate deck slot") + } + seen[x.Slot] = true + } + return out, nil +} +func fieldEntries(req []byte) ([]FieldEntry, error) { + var out []FieldEntry + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return errors.New("deck: field deck entry is not a message") + } + slot, slotOK, err := wire.Varint(f.Value, 1) + if err != nil || !slotOK || slot == 0 { + return errors.New("deck: invalid field deck slot") + } + character, characterOK, err := wire.Varint(f.Value, 2) + if err != nil || !characterOK || character == 0 { + return errors.New("deck: invalid field deck character") + } + costume, costumeOK, err := wire.Varint(f.Value, 3) + if err != nil || !costumeOK || costume == 0 { + return errors.New("deck: invalid field deck costume") + } + out = append(out, FieldEntry{Slot: slot, CharacterInvenIndex: character, CostumeInvenIndex: costume}) + return nil + }) + if e != nil { + return nil, e + } + if len(out) == 0 { + return nil, errors.New("deck: empty field deck") + } + return out, validField(out) +} + +// Handle implements session.Handler. Every mutation validates its complete +// typed request before committing a replacement JSON state. +func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) { + switch path { + case "/DeckInfo": + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.RLock() + defer s.mu.RUnlock() + slog.Info("team trace: deliver saved battle deck", "deck", s.state.Deck) + return 8, encodeDeck(s.state.Deck), true, nil + case "/FieldDeckInfo": + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.RLock() + defer s.mu.RUnlock() + return 273, encodeField(s.state.FieldDeck), true, nil + case "/DeckCharAutoRevive": + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + caster, _, e := wire.Varint(req, 2) + if e != nil { + return 0, nil, true, fmt.Errorf("deck: invalid auto-revive caster: %w", e) + } + if caster != 0 { + // The observed story flow has no caster and only refreshes the + // formation. A nonzero caster would require authoritative revive + // targets, talent experience, and catalyst consumption. + return 0, nil, true, fmt.Errorf("deck: auto-revive caster %d is not verified", caster) + } + s.mu.RLock() + defer s.mu.RUnlock() + if len(s.state.Deck) == 0 { + return 0, nil, true, errors.New("deck: auto-revive has no current battle deck") + } + var response []byte + for _, entry := range s.state.Deck { + deck := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex) + if entry.CostumeInvenIndex != 0 { + deck = wire.AppendVarint(deck, 2, entry.CostumeInvenIndex) + } + deck = wire.AppendVarint(deck, 3, entry.Slot) + response = wire.AppendBytes(response, 1, deck) + } + for _, entry := range s.state.FieldDeck { + field := wire.AppendVarint(nil, 1, entry.Slot) + field = wire.AppendVarint(field, 2, entry.CharacterInvenIndex) + if entry.CostumeInvenIndex != 0 { + field = wire.AppendVarint(field, 3, entry.CostumeInvenIndex) + } + response = wire.AppendBytes(response, 2, field) + } + response = wire.AppendVarint(response, 4, 2) // Define_AutoReviveCharType.CHANGE. + if s.state.AutoReviveCatalyst != 0 { + response = wire.AppendVarint(response, 7, s.state.AutoReviveCatalyst) + } + return 373, response, true, nil + case "/WaypointInfo": + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.RLock() + defer s.mu.RUnlock() + return 31, encodeWaypoints(s.state.Waypoints), true, nil + case "/DeckSave": + x, e := triples(req) + if e != nil { + return 0, nil, true, e + } + s.mu.Lock() + defer s.mu.Unlock() + seq, _, _ := wire.Varint(req, 1) + slog.Info("team trace: client requested battle deck replacement", "seq", seq, "before", s.state.Deck, "after", x) + n := clone(s.state) + n.Deck = x + e = s.commit(n) + if e != nil { + slog.Error("team trace: deck replacement failed", "seq", seq, "error", e) + } + return 10, nil, true, e + case "/FieldDeckSave": + x, e := fieldEntries(req) + if e != nil { + return 0, nil, true, e + } + s.mu.Lock() + defer s.mu.Unlock() + n := clone(s.state) + n.FieldDeck = x + e = s.commit(n) + return 274, nil, true, e + case "/SaveFieldCharControlDeckType": + // Define_FieldCharControllDeckType is a proto3 enum whose valid values + // are BATTLE=0, FIELD=1 and STORY=2. BATTLE is the protobuf default, so + // the generated client deliberately omits field 2 when it switches out + // of story mode after the final quest. An absent field is therefore a + // real value 0, not a malformed request. + v, _, e := wire.Varint(req, 2) + if e != nil || v > 2 { + return 0, nil, true, errors.New("deck: invalid field control type") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.Lock() + defer s.mu.Unlock() + n := clone(s.state) + n.FieldCharControlDeckType = v + e = s.commit(n) + return 288, nil, true, e + case "/WaypointSave": + pack, ok, e := wire.Varint(req, 2) + if e != nil || !ok || pack == 0 { + return 0, nil, true, errors.New("deck: invalid waypoint pack") + } + way, ok, e := wire.Varint(req, 3) + if e != nil || !ok || way == 0 { + return 0, nil, true, errors.New("deck: invalid waypoint") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.Lock() + defer s.mu.Unlock() + n := clone(s.state) + n.Waypoints[pack] = way + e = s.commit(n) + return 32, nil, true, e + case "/CostumeUse": + raw, ok, e := wire.Bytes(req, 2) + if e != nil || !ok { + return 0, nil, true, errors.New("deck: invalid costume use") + } + cost, ok, e := wire.Varint(raw, 1) + if e != nil || !ok || cost == 0 { + return 0, nil, true, errors.New("deck: invalid costume") + } + char, ok, e := wire.Varint(raw, 2) + if e != nil || !ok || char == 0 { + return 0, nil, true, errors.New("deck: invalid costume character") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.Lock() + defer s.mu.Unlock() + n := clone(s.state) + n.Costumes[char] = cost + e = s.commit(n) + return 41, nil, true, e + case "/PackBuy": + pack, ok, e := wire.Varint(req, 2) + if e != nil || !ok || pack == 0 { + return 0, nil, true, errors.New("deck: invalid pack") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.Lock() + defer s.mu.Unlock() + n := clone(s.state) + // Purchasing the story pack is idempotent. Login/bootstrap may repeat + // the request after a client restart before all local state is restored. + n.Packs[pack] = 1 + e = s.commit(n) + if e != nil { + return 0, nil, true, e + } + packInfo := wire.AppendVarint(nil, 1, pack) + packInfo = wire.AppendVarint(packInfo, 8, n.Packs[pack]) + response := wire.AppendBytes(nil, 1, packInfo) + // Starter pack purchase grants the local story-pack entitlement and + // starter currency. These are semantic RewardDBInfoBundle fields, not + // recorded response bytes. + entitlement := wire.AppendVarint(nil, 2, 1) + entitlement = wire.AppendVarint(entitlement, 3, 19) + entitlement = wire.AppendVarint(entitlement, 4, 1) + entitlement = wire.AppendVarint(entitlement, 8, ^uint64(0)-32400000+1) + currency := wire.AppendVarint(nil, 3, 12) + currency = wire.AppendVarint(currency, 4, 200) + tracking := wire.AppendVarint(nil, 2, 1) + tracking = wire.AppendVarint(tracking, 3, 19) + tracking = wire.AppendVarint(tracking, 4, 1) + bundle := wire.AppendBytes(nil, 1, entitlement) + bundle = wire.AppendBytes(bundle, 1, currency) + bundle = wire.AppendBytes(bundle, 6, tracking) + return 6, wire.AppendBytes(response, 2, bundle), true, nil + case "/SaveTotalBattlePower": + power, ok, e := wire.Varint(req, 2) + if e != nil || !ok || power == 0 { + return 0, nil, true, errors.New("deck: invalid total battle power") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.Lock() + defer s.mu.Unlock() + n := clone(s.state) + if power > n.HighestTotalBattlePower { + n.HighestTotalBattlePower = power + } + if e = s.commit(n); e != nil { + return 0, nil, true, e + } + return 258, wire.AppendVarint(nil, 1, n.HighestTotalBattlePower), true, nil + case "/UserPortraitChange": + costumeID, ok, e := wire.Varint(req, 2) + if e != nil || !ok || costumeID == 0 { + return 0, nil, true, errors.New("deck: invalid portrait costume") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + s.mu.Lock() + defer s.mu.Unlock() + n := clone(s.state) + n.PortraitCostumeID = costumeID + if e = s.commit(n); e != nil { + return 0, nil, true, e + } + return 75, wire.AppendVarint(nil, 1, costumeID), true, nil + } + return 0, nil, false, nil +} +func encodeDeck(xs []DeckEntry) []byte { + var b []byte + for _, x := range xs { + v := wire.AppendVarint(nil, 1, x.CharacterInvenIndex) + v = wire.AppendVarint(v, 2, x.CostumeInvenIndex) + v = wire.AppendVarint(v, 3, x.Slot) + b = wire.AppendBytes(b, 1, v) + } + return b +} +func encodeField(xs []FieldEntry) []byte { + var b []byte + for _, x := range xs { + v := wire.AppendVarint(nil, 1, x.Slot) + v = wire.AppendVarint(v, 2, x.CharacterInvenIndex) + v = wire.AppendVarint(v, 3, x.CostumeInvenIndex) + b = wire.AppendBytes(b, 1, v) + } + return b +} +func encodeWaypoints(xs map[uint64]uint64) []byte { + keys := make([]uint64, 0, len(xs)) + for k := range xs { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + var b []byte + for _, k := range keys { + v := wire.AppendVarint(nil, 1, k) + v = wire.AppendVarint(v, 2, xs[k]) + b = wire.AppendBytes(b, 1, v) + } + return b +} diff --git a/go/internal/deck/deck_test.go b/go/internal/deck/deck_test.go new file mode 100644 index 0000000..8f5eb24 --- /dev/null +++ b/go/internal/deck/deck_test.go @@ -0,0 +1,237 @@ +package deck + +import ( + "bd2server/internal/wire" + "path/filepath" + "testing" +) + +func seeded(t *testing.T) *Store { + t.Helper() + x, e := LoadSeed(filepath.Join("..", "..", "seed", "v2_34_13", "decks.json")) + if e != nil { + t.Fatal(e) + } + s, e := NewStore(x) + if e != nil { + t.Fatal(e) + } + return s +} +func req(seq uint64, fields ...[]byte) []byte { + b := wire.AppendVarint(nil, 1, seq) + for _, f := range fields { + b = append(b, f...) + } + return b +} +func triple(a, b, c uint64) []byte { + v := wire.AppendVarint(nil, 1, a) + v = wire.AppendVarint(v, 2, b) + v = wire.AppendVarint(v, 3, c) + return wire.AppendBytes(nil, 2, v) +} +func TestFieldDeckSeedAndSave(t *testing.T) { + s := seeded(t) + code, b, ok, e := s.Handle("/FieldDeckInfo", req(1)) + if e != nil || !ok || code != 273 { + t.Fatalf("info %d %t %v", code, ok, e) + } + n := 0 + if e = wire.Walk(b, func(f wire.Field) error { + if f.Number == 1 { + n++ + } + return nil + }); e != nil || n != 5 { + t.Fatalf("seed field deck: %d %v", n, e) + } + // FieldDeckDBInfo is sequence#1, character#2, costume#3. + field := triple(1, 99, 199) + code, _, ok, e = s.Handle("/FieldDeckSave", req(2, field)) + if e != nil || !ok || code != 274 { + t.Fatalf("save: %d %t %v", code, ok, e) + } + _, b, _, _ = s.Handle("/FieldDeckInfo", req(3)) + var got uint64 + _ = wire.Walk(b, func(f wire.Field) error { + if f.Number == 1 { + got, _, _ = wire.Varint(f.Value, 2) + } + return nil + }) + if got != 99 { + t.Fatalf("saved char=%d", got) + } +} +func TestDeckPersistenceAndCommands(t *testing.T) { + seed, e := LoadSeed(filepath.Join("..", "..", "seed", "v2_34_13", "decks.json")) + if e != nil { + t.Fatal(e) + } + path := filepath.Join(t.TempDir(), "deck.json") + s, e := OpenStore(path, seed) + if e != nil { + t.Fatal(e) + } + code, _, _, e := s.Handle("/DeckSave", req(1, triple(100, 200, 1))) + if e != nil || code != 10 { + t.Fatalf("deck save: %d %v", code, e) + } + way := wire.AppendVarint(nil, 2, 21) + way = wire.AppendVarint(way, 3, 1) + if code, _, _, e = s.Handle("/WaypointSave", req(2, way)); e != nil || code != 32 { + t.Fatalf("way: %d %v", code, e) + } + use := wire.AppendVarint(nil, 1, 200) + use = wire.AppendVarint(use, 2, 100) + if code, _, _, e = s.Handle("/CostumeUse", req(3, wire.AppendBytes(nil, 2, use))); e != nil || code != 41 { + t.Fatalf("use: %d %v", code, e) + } + pack := wire.AppendVarint(nil, 2, 21) + code, b, _, e := s.Handle("/PackBuy", req(4, pack)) + if e != nil || code != 6 { + t.Fatalf("buy: %d %v", code, e) + } + p, _, _ := wire.Bytes(b, 1) + id, _, _ := wire.Varint(p, 1) + if id != 21 { + t.Fatalf("pack response=%d", id) + } + reopened, e := OpenStore(path, seed) + if e != nil { + t.Fatal(e) + } + _, b, _, e = reopened.Handle("/DeckInfo", req(5)) + if e != nil { + t.Fatal(e) + } + entry, _, _ := wire.Bytes(b, 1) + id, _, _ = wire.Varint(entry, 1) + if id != 100 { + t.Fatalf("persist deck=%d", id) + } +} +func TestRejectsInvalidMutations(t *testing.T) { + s := seeded(t) + if _, _, _, e := s.Handle("/DeckSave", req(1, triple(1, 2, 1), triple(3, 4, 1))); e == nil { + t.Fatal("duplicate slots accepted") + } + if _, _, _, e := s.Handle("/WaypointSave", req(1, wire.AppendVarint(nil, 2, 21))); e == nil { + t.Fatal("waypoint missing id accepted") + } +} + +func TestSaveFieldCharControlDeckTypeAcceptsProtoDefaultBattle(t *testing.T) { + s := seeded(t) + // FCCD_BATTLE=0 is omitted by proto3 serialization. This is the exact + // shape sent when the client leaves story control after the final quest. + code, _, handled, err := s.Handle("/SaveFieldCharControlDeckType", req(1)) + if err != nil || !handled || code != 288 || s.state.FieldCharControlDeckType != 0 { + t.Fatalf("battle save code=%d handled=%v type=%d err=%v", code, handled, s.state.FieldCharControlDeckType, err) + } + for seq, value := range []uint64{1, 2} { + request := req(uint64(seq+2), wire.AppendVarint(nil, 2, value)) + if code, _, handled, err = s.Handle("/SaveFieldCharControlDeckType", request); err != nil || !handled || code != 288 || s.state.FieldCharControlDeckType != value { + t.Fatalf("type %d save code=%d handled=%v stored=%d err=%v", value, code, handled, s.state.FieldCharControlDeckType, err) + } + } + if _, _, handled, err = s.Handle("/SaveFieldCharControlDeckType", req(9, wire.AppendVarint(nil, 2, 3))); !handled || err == nil { + t.Fatalf("unknown enum accepted handled=%v err=%v", handled, err) + } +} + +func TestTotalBattlePowerKeepsHighest(t *testing.T) { + s := seeded(t) + for seq, power := range []uint64{1300, 900} { + request := req(uint64(seq+1), wire.AppendVarint(nil, 2, power)) + code, response, ok, err := s.Handle("/SaveTotalBattlePower", request) + if err != nil || !ok || code != 258 { + t.Fatalf("save power: code=%d ok=%v err=%v", code, ok, err) + } + highest, found, err := wire.Varint(response, 1) + if err != nil || !found || highest != 1300 { + t.Fatalf("highest=%d found=%v err=%v", highest, found, err) + } + } +} + +func TestPortraitChangeEchoesAndStoresCostume(t *testing.T) { + s := seeded(t) + request := req(1, wire.AppendVarint(nil, 2, 3501)) + code, response, ok, err := s.Handle("/UserPortraitChange", request) + if err != nil || !ok || code != 75 { + t.Fatalf("portrait: code=%d ok=%v err=%v", code, ok, err) + } + id, found, err := wire.Varint(response, 1) + if err != nil || !found || id != 3501 || s.state.PortraitCostumeID != 3501 { + t.Fatalf("portrait response=%d found=%v stored=%d err=%v", id, found, s.state.PortraitCostumeID, err) + } +} + +func TestDeckCharAutoReviveUsesCurrentFormationWithoutInventingRevives(t *testing.T) { + s := seeded(t) + if code, _, handled, err := s.Handle("/DeckSave", req(1, triple(535607162, 10, 1), triple(535604120, 1, 2))); code != 10 || !handled || err != nil { + t.Fatalf("save code=%d handled=%v err=%v", code, handled, err) + } + code, response, handled, err := s.Handle("/DeckCharAutoRevive", req(2)) + if err != nil || !handled || code != 373 { + t.Fatalf("auto revive code=%d handled=%v err=%v", code, handled, err) + } + var battle, field, revive int + if err := wire.Walk(response, func(f wire.Field) error { + switch f.Number { + case 1: + battle++ + if battle == 1 { + index, _, _ := wire.Varint(f.Value, 1) + if index != 535607162 { + t.Errorf("first battle character %d", index) + } + } + case 2: + field++ + case 3, 5: + revive++ + } + return nil + }); err != nil || battle != 2 || field != 5 || revive != 0 { + t.Fatalf("formation battle=%d field=%d invented revive=%d err=%v", battle, field, revive, err) + } + mode, _, _ := wire.Varint(response, 4) + catalyst, _, _ := wire.Varint(response, 7) + if mode != 2 || catalyst != 200 { + t.Fatalf("official formation mode=%d catalyst=%d", mode, catalyst) + } + if _, _, handled, err := s.Handle("/DeckCharAutoRevive", req(3, wire.AppendVarint(nil, 2, 535604120))); !handled || err == nil { + t.Fatalf("unverified caster incorrectly accepted handled=%v err=%v", handled, err) + } + if _, _, handled, err := s.Handle("/DeckCharAutoRevive", nil); !handled || err == nil { + t.Fatalf("missing sequence incorrectly accepted handled=%v err=%v", handled, err) + } +} + +func TestDeckCharAutoReviveMigratesExistingSaveCatalyst(t *testing.T) { + seed, err := LoadSeed(filepath.Join("..", "..", "seed", "v2_34_13", "decks.json")) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "deck.json") + s, err := OpenStore(path, seed) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := s.Handle("/DeckSave", req(1, triple(88, 12, 1))); err != nil { + t.Fatal(err) + } + // Old snapshots stored no catalyst; opening must restore it from the + // account seed while leaving all deck and field entries untouched. + s.state.AutoReviveCatalyst = 0 + if err := s.commit(s.state); err != nil { + t.Fatal(err) + } + reopened, err := OpenStore(path, seed) + if err != nil || reopened.state.AutoReviveCatalyst != 200 || reopened.state.Deck[0].CharacterInvenIndex != 88 { + t.Fatalf("migrated state=%+v err=%v", reopened.state, err) + } +} diff --git a/go/internal/feature/bootstrap.go b/go/internal/feature/bootstrap.go new file mode 100644 index 0000000..2f05ce8 --- /dev/null +++ b/go/internal/feature/bootstrap.go @@ -0,0 +1,33 @@ +package feature + +import "bd2server/internal/wire" + +// initialResponses are locally constructed, typed defaults for the 2.34.13 +// new-player account. These do not reuse recorded response bytes. Stateful +// entities (inventory, characters, quests, decks) are deliberately excluded. +func initialResponse(path string) (int, []byte, bool) { + switch path { + case "/PackInfo": + // No purchased packs yet. SquareSceneId is the new-account square. + return 4, wire.AppendVarint(nil, 5, 3), true + case "/RecipeInfo": + // The starter crafting recipe is ID 101 (packed repeated int32). + return 46, wire.AppendBytes(nil, 2, []byte{101}), true + case "/CashMailInfo": + // MaxInvenIndex is explicitly present even with zero cash mails. + return 140, wire.AppendVarint(nil, 3, 0), true + case "/AvatarInfo": + return 467, wire.AppendBytes(nil, 1, nil), true + case "/GuildRaidSeasonReward": + return 310, wire.AppendBytes(nil, 1, nil), true + case "/DeckInfo": + // No active deck yet; talent slots are four zero-valued packed ints. + return 8, wire.AppendBytes(nil, 2, []byte{0, 0, 0, 0}), true + case "/RootSortIdInfo": + entry := wire.AppendVarint(nil, 1, 2) + entry = wire.AppendVarint(entry, 2, 140) + entry = wire.AppendVarint(entry, 3, 1) + return 367, wire.AppendBytes(nil, 1, entry), true + } + return 0, nil, false +} diff --git a/go/internal/feature/compatibility_test.go b/go/internal/feature/compatibility_test.go new file mode 100644 index 0000000..524e126 --- /dev/null +++ b/go/internal/feature/compatibility_test.go @@ -0,0 +1,50 @@ +package feature + +import ( + "bytes" + "path/filepath" + "testing" + + "bd2server/internal/fixture" + "bd2server/internal/wire" +) + +// TestCaptureCompatibility is an optional development-time protocol audit. +// Normal server execution never opens the capture. +func TestCaptureCompatibility(t *testing.T) { + root := filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") + set, err := fixture.Load(root) + if err != nil { + t.Skipf("optional capture unavailable: %v", err) + } + key, err := set.SessionKey() + if err != nil { + t.Fatal(err) + } + for batch := 1; batch <= 2; batch++ { + body, err := set.BatchResponse(batch) + if err != nil { + t.Fatal(err) + } + items, decoded, err := fixture.DecodeBatch(body, []byte(key)) + if err != nil { + t.Fatal(err) + } + for _, item := range items { + if _, _, handled, err := Handle(item.Path, wire.AppendVarint(nil, 1, 1)); err != nil || !handled { + t.Logf("batch=%d pending domain endpoint=%s", batch, item.Path) + } + if _, known := EmptyPacketCodes()[item.Path]; known { + if len(decoded[item.Path]) != 0 { + t.Errorf("%s captured protobuf was not empty", item.Path) + } + } + if _, _, known := initialResponse(item.Path); known { + code, generated, handled, err := Handle(item.Path, wire.AppendVarint(nil, 1, 1)) + if err != nil || !handled || code != item.Response.PacketCode || !bytes.Equal(generated, decoded[item.Path]) { + t.Errorf("%s native response differs from observed 2.34.13 new account: code=%d vs %d proto=%x vs %x err=%v", item.Path, code, item.Response.PacketCode, generated, decoded[item.Path], err) + } + } + } + } +} diff --git a/go/internal/feature/feature.go b/go/internal/feature/feature.go new file mode 100644 index 0000000..ddd104a --- /dev/null +++ b/go/internal/feature/feature.go @@ -0,0 +1,102 @@ +// Package feature contains deliberately small, stateless protocol handlers. +// +// These endpoints were observed to return an empty protobuf for a new local +// account. Keeping that fact here (rather than treating every unknown route +// as successful) lets the session dispatcher remain fail-closed. +package feature + +import ( + "errors" + "fmt" + + "bd2server/internal/wire" +) + +// ErrInvalidRequest means a known endpoint was sent a malformed request. +var ErrInvalidRequest = errors.New("feature: invalid protobuf request") + +// emptyResponses is the 2.34.13 capture audit's 35 endpoint new-player batch +// set whose successful response protobuf has zero bytes. Packet codes are +// protocol values, not ordinals derived from the request or response order. +var emptyResponses = map[string]int{ + "/AvatarShopWishListInfo": 470, + "/CafeteriaInfo": 351, + "/CashBonusInfo": 588, + "/CharAwakeInfo": 0, + "/CharPartnerInfo": 65, + "/CommunityRewardInfo": 289, + "/DailyStoryInfo": 538, + "/DatingInfo": 361, + "/DeckCostumeSettingInfo": 397, + "/DispatchInfo": 0, + "/EquipPresetInfo": 253, + "/EvilCastleDailyRewardState": 441, + "/EvilCastleTowerInfo": 200, + "/FieldObjectInfo": 28, + "/FieldTrapInfo": 171, + "/FireWorksInfo": 554, + "/FishingCollectionInfo": 465, + "/FishingTrapInfo": 457, + "/FriendInfoList": 204, + "/FriendshipInfo": 612, + "/FriendshipSpecialEpisodeInfo": 623, + "/GuildInitInfo": 332, + "/IdCardPresetInfo": 450, + "/LifeUserInfo": 594, + "/MasterTitleInfo": 590, + "/MiniEventHubInfo": 534, + "/MyLikeInfo": 218, + "/MyRoomItemInfo": 232, + "/PersonalInfo": 199, + "/PrestigeSkinInfo": 425, + "/PresetInfo": 178, + "/QuestMaxClearInfo": 137, + "/SpineInteractionAchievementInfo": 532, + "/TotalWarRewardState": 252, + "/TutorialInfo": 101, +} + +// Handle recognizes only audited, stateless empty-response endpoints. The +// request must be a structurally valid protobuf with its normal nonzero +// request sequence in field 1. Unknown paths return ok=false and no response. +// proto is nil on success: protobuf's canonical empty-message encoding is zero +// bytes. +func Handle(path string, request []byte) (packetCode int, proto []byte, ok bool, err error) { + packetCode, ok = emptyResponses[path] + if !ok { + packetCode, proto, ok = initialResponse(path) + } + if !ok { + packetCode, ok = standaloneDefaults[path] + } + if !ok { + packetCode, ok = commandSuccess[path] + if !ok { + return 0, nil, false, nil + } + } + sequence, present, walkErr := wire.Varint(request, 1) + if walkErr != nil || !present || sequence == 0 { + if walkErr != nil { + return 0, nil, true, fmt.Errorf("%w: %s: %v", ErrInvalidRequest, path, walkErr) + } + return 0, nil, true, fmt.Errorf("%w: %s has no request sequence", ErrInvalidRequest, path) + } + return packetCode, proto, true, nil +} + +// Service adapts this registry to session.Handler without importing session. +type Service struct{} + +func (Service) Handle(path string, request []byte) (int, []byte, bool, error) { + return Handle(path, request) +} + +// EmptyPacketCodes returns a detached copy for diagnostics and protocol tests. +func EmptyPacketCodes() map[string]int { + result := make(map[string]int, len(emptyResponses)) + for path, code := range emptyResponses { + result[path] = code + } + return result +} diff --git a/go/internal/feature/feature_test.go b/go/internal/feature/feature_test.go new file mode 100644 index 0000000..acc0fae --- /dev/null +++ b/go/internal/feature/feature_test.go @@ -0,0 +1,64 @@ +package feature + +import ( + "errors" + "testing" + + "bd2server/internal/wire" +) + +func TestHandleAuditedEmptyResponses(t *testing.T) { + if got := len(EmptyPacketCodes()); got != 35 { + t.Fatalf("audited empty-response registry has %d paths, want 35", got) + } + for path, wantCode := range EmptyPacketCodes() { + t.Run(path, func(t *testing.T) { + gotCode, gotProto, ok, err := Handle(path, wire.AppendVarint(nil, 1, 42)) + if err != nil || !ok || gotCode != wantCode || len(gotProto) != 0 { + t.Fatalf("Handle(%q): code=%d proto=%x ok=%t err=%v; want code=%d, empty, handled", path, gotCode, gotProto, ok, err, wantCode) + } + }) + } +} + +func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) { + if code, proto, ok, err := Handle("/not-a-real-endpoint", wire.AppendVarint(nil, 1, 1)); code != 0 || proto != nil || ok || err != nil { + t.Fatalf("unknown route was not fail-closed: code=%d proto=%x ok=%t err=%v", code, proto, ok, err) + } + for name, request := range map[string][]byte{ + "empty": nil, + "no-sequence": wire.AppendVarint(nil, 2, 1), + "zero-sequence": wire.AppendVarint(nil, 1, 0), + "malformed": {0x08, 0x80}, + } { + t.Run(name, func(t *testing.T) { + _, proto, ok, err := Handle("/QuestMaxClearInfo", request) + if !ok || proto != nil || !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("invalid request: proto=%x ok=%t err=%v", proto, ok, err) + } + }) + } +} + +func TestEmptyPacketCodesReturnsCopy(t *testing.T) { + codes := EmptyPacketCodes() + codes["/QuestMaxClearInfo"] = -1 + if code, _, ok, err := Handle("/QuestMaxClearInfo", wire.AppendVarint(nil, 1, 1)); err != nil || !ok || code != 137 { + t.Fatalf("registry escaped its copy: code=%d ok=%t err=%v", code, ok, err) + } +} + +func TestStandaloneNativeDefaults(t *testing.T) { + for path, want := range map[string]int{ + "/EventScheduleInfo": 163, + "/EquipInfo": 34, + "/TodayQuestInfo": 64, + "/UpdateAgeGate": 0, + "/ActiveMap": 0, + } { + code, proto, ok, err := Handle(path, wire.AppendVarint(nil, 1, 55)) + if err != nil || !ok || code != want || len(proto) != 0 { + t.Fatalf("%s: code=%d proto=%x ok=%v err=%v", path, code, proto, ok, err) + } + } +} diff --git a/go/internal/feature/standalone.go b/go/internal/feature/standalone.go new file mode 100644 index 0000000..523e297 --- /dev/null +++ b/go/internal/feature/standalone.go @@ -0,0 +1,47 @@ +package feature + +// standaloneDefaults are native local-server defaults for optional query +// 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, + "/AchievementInfo": 166, + "/HuntDispatchInfo": 189, + "/EventMissionInfo": 127, + "/MissionInfo": 118, + "/EventRewardHistory": 0, + "/PackEventStoryInfo": 220, + "/PackEventBattleInfo": 214, + "/FishingItemInfo": 459, + "/MonsterHuntDeckInfo": 263, + "/Attendance": 0, + "/AttendanceInfo": 0, + "/PackPreviewInfo": 104, + "/TodayQuestInfo": 64, + "/WaypointInfo": 31, + "/FieldDeckInfo": 273, + "/HuntingGroundInfoList": 387, + "/FriendRecommend": 211, + "/SupporterStatus": 438, + "/MailHistoryInfo": 138, + "/SupporterBattleInfo": 439, + "/InnOpen": 109, +} + +// commandSuccess contains idempotent local mutations whose response type is +// canonically empty. Domain persistence can be added without changing their +// protocol contract. Core progress commands are handled by session first. +var commandSuccess = map[string]int{ + "/UpdateAgeGate": 0, + "/SaveFieldCharControlDeckType": 288, + "/WaypointSave": 32, + "/ActiveMap": 0, + "/MasterTitleInfoUpdate": 592, + "/FieldDeckSave": 274, + "/CostumeUse": 41, + "/BattleExit": 388, +} diff --git a/go/internal/fixture/debug_response_test.go b/go/internal/fixture/debug_response_test.go new file mode 100644 index 0000000..f8fa0ad --- /dev/null +++ b/go/internal/fixture/debug_response_test.go @@ -0,0 +1,37 @@ +package fixture + +import ( + "fmt" + "path/filepath" + "testing" + + "bd2server/internal/wire" +) + +func TestDebugBattleStartResponse(t *testing.T) { + set, err := Load(filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) + if err != nil { + t.Skip(err) + } + for _, record := range set.RecordedResponses("/BattleStart") { + p, _, err := set.RecordedPayloadAt("/BattleStart", record.RequestSequence) + if err != nil { + t.Log(err) + continue + } + _ = wire.Walk(p.Proto, func(f wire.Field) error { + if f.Number == 1 || f.Number == 2 { + vals := []string{} + _ = wire.Walk(f.Value, func(x wire.Field) error { + if x.Type == 0 { + v, _, _ := wire.Varint(f.Value, x.Number) + vals = append(vals, fmt.Sprintf("%d=%d", x.Number, v)) + } + return nil + }) + t.Logf("client=%d capture=%d top=%d raw=%x fields=%v", record.ClientSequence, record.RequestSequence, f.Number, f.Value, vals) + } + return nil + }) + } +} diff --git a/go/internal/fixture/fixture.go b/go/internal/fixture/fixture.go new file mode 100644 index 0000000..7a02c07 --- /dev/null +++ b/go/internal/fixture/fixture.go @@ -0,0 +1,702 @@ +// Package fixture reads captured BD2 protocol messages without duplicating the +// large capture in the source tree. It is intended for local-server bootstrap +// fixtures and protocol tests, not as a persistence format. +package fixture + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "bd2server/internal/cryptox" +) + +const CaptureVersion = "2.34.13" + +// NamedFixture names the small, stable bootstrap response set. Files remain +// in capture///bodies and are loaded on demand. +type NamedFixture struct { + Name string + Path string + File string + Key KeyKind +} + +type KeyKind uint8 + +const ( + Plain KeyKind = iota + FixedLogin + Session +) + +var Bootstrap = []NamedFixture{ + {Name: "maintenance", Path: "/MaintenanceInfo", File: "00010_resp_MaintenanceInfo.bin", Key: Plain}, + {Name: "server_info", Path: "/ServerInfo", File: "00012_resp_ServerInfo.bin", Key: Plain}, + {Name: "login", Path: "/LoginUser", File: "00016_resp_LoginUser.bin", Key: FixedLogin}, + {Name: "notice", Path: "/NoticeInfo", File: "00020_resp_NoticeInfo.bin", Key: Plain}, +} + +var ( + ErrNotFound = errors.New("bd2 fixture: response not found") + ErrUnsafePath = errors.New("bd2 fixture: unsafe capture path") + ErrNoPayload = errors.New("bd2 fixture: response has no data payload") + ErrLengthMismatch = errors.New("bd2 fixture: response length does not match decoded payload") +) + +// Capture points at a single timestamped capture directory, i.e. +// capture/2.34.13/20260920-003254. +type Capture struct { + Root string +} + +// Set is the local-server integration view of one captured new-player run. +// It intentionally keeps responses on disk and only loads bodies on demand; +// this avoids copying the capture into binaries or maintaining a second large +// fixture tree. Load accepts the timestamped capture directory itself. +type Set struct { + *Capture + core map[string]NamedFixture + responses map[string][]RecordedResponse +} + +// RecordedResponse has an unambiguous selector: Path plus RequestSequence. +// Sequence is the one-based request event number in bd2_dump.jsonl, rather +// than an ordinal response count. This prevents a state-changing endpoint +// from accidentally receiving a response captured for another request. +type RecordedResponse struct { + Path string + Method string + File string + RequestSequence int + ClientSequence uint64 +} + +// RecordedResponses lists the unambiguous capture selectors for path. The +// returned slice is detached from the fixture index and safe for callers to +// retain as a scripted bootstrap plan. +func (s *Set) RecordedResponses(path string) []RecordedResponse { + return append([]RecordedResponse(nil), s.responses[path]...) +} + +// Load opens an on-disk capture and indexes its stable core endpoints. +func Load(captureDir string) (*Set, error) { + c, err := Open(captureDir) + if err != nil { + return nil, err + } + core := make(map[string]NamedFixture, len(Bootstrap)) + for _, spec := range Bootstrap { + core[spec.Path] = spec + } + responses, err := indexResponses(c.Root) + if err != nil { + return nil, err + } + return &Set{Capture: c, core: core, responses: responses}, nil +} + +type dumpRecord struct { + Event string `json:"ev"` + Game bool `json:"game"` + FlowID string `json:"flow_id"` + Method string `json:"method"` + Path string `json:"path"` + RawFile string `json:"raw_file"` + PB []string `json:"pb"` +} + +type indexedRequest struct { + CaptureSequence int + ClientSequence uint64 +} + +func indexResponses(root string) (map[string][]RecordedResponse, error) { + data, err := os.ReadFile(filepath.Join(root, "bd2_dump.jsonl")) + if err != nil { + return nil, fmt.Errorf("read capture index: %w", err) + } + requests := make(map[string]indexedRequest) + responses := make(map[string][]RecordedResponse) + sequence := 0 + for lineNo, line := range bytes.Split(data, []byte{'\n'}) { + if len(bytes.TrimSpace(line)) == 0 { + continue + } + var record dumpRecord + if err := json.Unmarshal(line, &record); err != nil { + return nil, fmt.Errorf("decode capture index line %d: %w", lineNo+1, err) + } + if !record.Game || record.FlowID == "" { + continue + } + if record.Event == "req" { + sequence++ + requests[record.FlowID] = indexedRequest{CaptureSequence: sequence, ClientSequence: protobufSequence(record.PB)} + continue + } + if record.Event != "resp" || record.RawFile == "" { + continue + } + request, found := requests[record.FlowID] + if !found { + continue // capture can begin after an already-open HTTP flow + } + if err := safeName(filepath.Base(record.RawFile)); err != nil { + return nil, fmt.Errorf("capture index line %d: %w", lineNo+1, err) + } + responses[record.Path] = append(responses[record.Path], RecordedResponse{ + Path: record.Path, Method: record.Method, File: filepath.Base(record.RawFile), RequestSequence: request.CaptureSequence, ClientSequence: request.ClientSequence, + }) + } + return responses, nil +} + +func protobufSequence(fields []string) uint64 { + const prefix = "#1 varint " + for _, field := range fields { + text := strings.TrimSpace(field) + if !strings.HasPrefix(text, prefix) { + continue + } + value, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(text, prefix)), 10, 64) + if err == nil { + return value + } + } + return 0 +} + +// RecordedResponseForClientSequence resolves the captured response using the +// endpoint and the request message's protobuf seq field. A missing/zero seq +// never falls back to a path-only match. +func (s *Set) RecordedResponseForClientSequence(path string, clientSequence uint64) (RecordedResponse, error) { + if clientSequence == 0 { + return RecordedResponse{}, fmt.Errorf("%w: response %s has no client sequence", ErrNotFound, path) + } + for _, response := range s.responses[path] { + if response.ClientSequence == clientSequence { + return response, nil + } + } + return RecordedResponse{}, fmt.Errorf("%w: response %s at client sequence %d", ErrNotFound, path, clientSequence) +} + +func Open(root string) (*Capture, error) { + info, err := os.Stat(root) + if err != nil { + return nil, fmt.Errorf("open capture: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("open capture: %s is not a directory", root) + } + if _, err := os.Stat(filepath.Join(root, "bodies")); err != nil { + return nil, fmt.Errorf("open capture bodies: %w", err) + } + return &Capture{Root: root}, nil +} + +// Response is a raw captured HTTP body along with its recorded endpoint. +type Response struct { + Path string + File string + Body []byte +} + +// BootstrapResponse loads one of the fixed bootstrap fixtures by symbolic name. +func (c *Capture) BootstrapResponse(name string) (Response, error) { + for _, spec := range Bootstrap { + if spec.Name == name { + return c.readResponse(spec.Path, spec.File) + } + } + return Response{}, fmt.Errorf("%w: bootstrap %q", ErrNotFound, name) +} + +func (c *Capture) Maintenance() (Response, error) { return c.BootstrapResponse("maintenance") } +func (c *Capture) ServerInfo() (Response, error) { return c.BootstrapResponse("server_info") } +func (c *Capture) Login() (Response, error) { return c.BootstrapResponse("login") } +func (c *Capture) Notice() (Response, error) { return c.BootstrapResponse("notice") } + +// CoreResponse returns the original JSON HTTP response for a bootstrap path. +// It is useful where a handler deliberately wants an exact captured response. +func (s *Set) CoreResponse(path string) ([]byte, error) { + spec, ok := s.core[path] + if !ok { + return nil, fmt.Errorf("%w: core path %q", ErrNotFound, path) + } + response, err := s.readResponse(spec.Path, spec.File) + if err != nil { + return nil, err + } + return append([]byte(nil), response.Body...), nil +} + +// RecordedResponseAt retrieves one captured HTTP response only when both its +// endpoint path and original request sequence match. Passing an arbitrary +// ordinal is deliberately not supported; callers should record the sequence +// that their route/session policy maps to a fixture. +func (s *Set) RecordedResponseAt(path string, requestSequence int) ([]byte, RecordedResponse, error) { + for _, response := range s.responses[path] { + if response.RequestSequence != requestSequence { + continue + } + body, err := os.ReadFile(filepath.Join(s.Root, "bodies", response.File)) + if err != nil { + return nil, RecordedResponse{}, fmt.Errorf("read recorded response %s: %w", response.File, err) + } + return body, response, nil + } + return nil, RecordedResponse{}, fmt.Errorf("%w: response %s at request sequence %d", ErrNotFound, path, requestSequence) +} + +// RecordedPayloadAt decodes a selected captured response with the captured +// session key. LoginUser is always decoded with the fixed pre-login key. +func (s *Set) RecordedPayloadAt(path string, requestSequence int) (Payload, RecordedResponse, error) { + body, response, err := s.RecordedResponseAt(path, requestSequence) + if err != nil { + return Payload{}, RecordedResponse{}, err + } + key := []byte(nil) + if path == "/LoginUser" || path == "/JoinUser" { + key = cryptox.Key() + } else { + capturedKey, err := s.SessionKey() + if err != nil { + return Payload{}, RecordedResponse{}, err + } + key = []byte(capturedKey) + } + env, proto, encrypted, err := decodeEnvelope(body, key) + if err != nil { + return Payload{}, RecordedResponse{}, err + } + return Payload{Path: path, PacketCode: env.PacketCode, Proto: proto, Envelope: env, Encrypted: encrypted}, response, nil +} + +// ReencryptRecordedAt is the safe replay helper for a selected response. It +// preserves unencrypted protocol responses, but re-encrypts every encrypted +// response payload with the supplied local-session key and fixes length. +func (s *Set) ReencryptRecordedAt(path string, requestSequence int, localSessionKey []byte) ([]byte, RecordedResponse, error) { + payload, response, err := s.RecordedPayloadAt(path, requestSequence) + if err != nil { + return nil, RecordedResponse{}, err + } + if !payload.Encrypted { + body, _, err := s.RecordedResponseAt(path, requestSequence) + return body, response, err + } + body, err := EncodePayload(payload, localSessionKey, true) + return body, response, err +} + +// Payload is the decoded portion of a captured endpoint response. PacketCode +// and Proto are sufficient for a server to modify state and re-encrypt it. +type Payload struct { + Path string + PacketCode int + Proto []byte + Envelope Envelope + Encrypted bool +} + +// CorePayload decodes a core fixture. LoginUser uses the fixed pre-login key; +// the remaining initial bootstrap bodies are direct base64 protobuf payloads. +func (s *Set) CorePayload(path string) (Payload, error) { + spec, ok := s.core[path] + if !ok { + return Payload{}, fmt.Errorf("%w: core path %q", ErrNotFound, path) + } + body, err := s.CoreResponse(path) + if err != nil { + return Payload{}, err + } + key := []byte(nil) + if spec.Key == FixedLogin { + key = cryptox.Key() + } + env, proto, encrypted, err := decodeEnvelope(body, key) + if err != nil { + return Payload{}, err + } + return Payload{Path: path, PacketCode: env.PacketCode, Proto: proto, Envelope: env, Encrypted: encrypted}, nil +} + +// SessionKey returns the captured LoginUser user_key. It is intended only for +// offline fixture decoding; callers should replace it with a local test key +// once their account/session layer exists. This package never logs it. +func (s *Set) SessionKey() (string, error) { + payload, err := s.CorePayload("/LoginUser") + if err != nil { + return "", err + } + match := sessionKeyPattern.Find(payload.Proto) + if match == nil { + return "", errors.New("bd2 fixture: LoginUser payload does not contain user_key") + } + return string(match), nil +} + +var sessionKeyPattern = regexp.MustCompile(`[0-9a-f]{32}`) + +// ReencryptCore returns a core response whose payload has been encrypted with +// key. It is primarily for LoginUser replay after a handler substitutes a +// local user key in its protobuf. Callers who mutate protobuf should prefer +// EncodePayload below. +func (s *Set) ReencryptCore(path string, key []byte) ([]byte, error) { + payload, err := s.CorePayload(path) + if err != nil { + return nil, err + } + return EncodePayload(payload, key, true) +} + +// EncodePayload serializes a server-owned payload with an accurate length. +func EncodePayload(payload Payload, key []byte, encrypted bool) ([]byte, error) { + return EncodeEnvelope(payload.Envelope, payload.Proto, key, encrypted) +} + +// Batch loads one of the two recorded initial BatchRequest response bodies. +func (c *Capture) Batch(number int) (Response, error) { + switch number { + case 1: + return c.readResponse("/BatchRequest", "00041_resp_BatchRequest.bin") + case 2: + return c.readResponse("/BatchRequest", "00077_resp_BatchRequest.bin") + default: + return Response{}, fmt.Errorf("%w: batch %d", ErrNotFound, number) + } +} + +// BatchResponse returns the captured raw BatchRequest response. For live +// replay prefer BatchForPaths, because capture response order is arbitrary. +func (s *Set) BatchResponse(index int) ([]byte, error) { + response, err := s.Batch(index) + if err != nil { + return nil, err + } + return append([]byte(nil), response.Body...), nil +} + +// BatchForPaths returns the captured responses in exactly the request path +// order supplied by the client. It verifies both the captured request and +// response before selecting items, preventing accidental path drift. +func (s *Set) BatchForPaths(index int, paths []string) ([]BatchItem, error) { + key, err := s.SessionKey() + if err != nil { + return nil, err + } + return s.batchForPathsWithKey(index, paths, []byte(key)) +} + +// HandleBatch decodes an encrypted client BatchRequest body then produces a +// JSON BatchRequest response arranged to match its request order. The result +// is not AES-wrapped: captured BatchRequest responses are JSON arrays too. +func (s *Set) HandleBatch(index int, requestBody []byte, sessionKey []byte) ([]byte, error) { + requests, _, err := DecodeBatchRequest(requestBody, sessionKey) + if err != nil { + return nil, err + } + paths := make([]string, len(requests)) + for i, request := range requests { + paths[i] = request.Path + } + items, err := s.batchForPathsCaptured(index, paths) + if err != nil { + return nil, err + } + if err := s.reencryptBatchItems(items, sessionKey); err != nil { + return nil, err + } + return json.Marshal(items) +} + +func (s *Set) batchForPathsWithKey(index int, paths []string, key []byte) ([]BatchItem, error) { + items, err := s.batchForPathsCaptured(index, paths) + if err != nil { + return nil, err + } + if err := s.reencryptBatchItems(items, key); err != nil { + return nil, err + } + return items, nil +} + +// batchForPathsCaptured selects data using only the captured session key. The +// captured request itself is validated before its response map is trusted. +func (s *Set) batchForPathsCaptured(index int, paths []string) ([]BatchItem, error) { + capturedKey, err := s.SessionKey() + if err != nil { + return nil, err + } + key := []byte(capturedKey) + if err := s.validateBatchRequest(index, key); err != nil { + return nil, err + } + raw, err := s.BatchResponse(index) + if err != nil { + return nil, err + } + items, _, err := DecodeBatch(raw, key) + if err != nil { + return nil, err + } + byPath := make(map[string]BatchItem, len(items)) + for _, item := range items { + byPath[item.Path] = item + } + ordered := make([]BatchItem, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if _, duplicate := seen[path]; duplicate { + return nil, fmt.Errorf("bd2 fixture: duplicate requested batch path %q", path) + } + seen[path] = struct{}{} + item, ok := byPath[path] + if !ok { + return nil, fmt.Errorf("%w: batch %d path %q", ErrNotFound, index, path) + } + ordered = append(ordered, item) + } + return ordered, nil +} + +func (s *Set) reencryptBatchItems(items []BatchItem, localSessionKey []byte) error { + if len(localSessionKey) != cryptox.AESKeySize { + return cryptox.ErrInvalidKey + } + capturedKey, err := s.SessionKey() + if err != nil { + return err + } + for i := range items { + body, err := json.Marshal(items[i].Response) + if err != nil { + return err + } + env, proto, _, err := decodeEnvelope(body, []byte(capturedKey)) + if err != nil { + return fmt.Errorf("decode batch response %s: %w", items[i].Path, err) + } + encoded, err := EncodeEnvelope(env, proto, localSessionKey, true) + if err != nil { + return err + } + if err := json.Unmarshal(encoded, &items[i].Response); err != nil { + return err + } + } + return nil +} + +func (s *Set) validateBatchRequest(index int, key []byte) error { + var file string + switch index { + case 1: + file = "00040_req_BatchRequest.bin" + case 2: + file = "00076_req_BatchRequest.bin" + default: + return fmt.Errorf("%w: batch %d", ErrNotFound, index) + } + body, err := os.ReadFile(filepath.Join(s.Root, "bodies", file)) + if err != nil { + return err + } + _, _, err = DecodeBatchRequest(body, key) + return err +} + +func (c *Capture) readResponse(path, file string) (Response, error) { + if err := safeName(file); err != nil { + return Response{}, err + } + body, err := os.ReadFile(filepath.Join(c.Root, "bodies", file)) + if err != nil { + return Response{}, fmt.Errorf("read fixture %s: %w", file, err) + } + return Response{Path: path, File: file, Body: body}, nil +} + +func safeName(name string) error { + if filepath.Base(name) != name || strings.Contains(name, "..") || name == "" { + return fmt.Errorf("%w: %q", ErrUnsafePath, name) + } + return nil +} + +// Envelope is the common game response envelope. Unknown fields are retained +// in Extra, which keeps a replay fixture forward-compatible. +type Envelope struct { + ErrorType int `json:"errorType"` + PacketCode int `json:"packetCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + Length int `json:"length"` + Data string `json:"data"` + ServerNowTime int64 `json:"serverNowTime,omitempty"` + Notify string `json:"notify,omitempty"` + Extra json.RawMessage `json:"-"` +} + +// DecodeEnvelope accepts either base64(protobuf) or +// base64(AES-CBC(base64(protobuf))). The latter uses key. Captures legitimately +// contain both forms, including after login, so the fallback is intentional. +func DecodeEnvelope(body, key []byte) (Envelope, []byte, error) { + env, proto, _, err := decodeEnvelope(body, key) + return env, proto, err +} + +func decodeEnvelope(body, key []byte) (Envelope, []byte, bool, error) { + var env Envelope + if err := json.Unmarshal(body, &env); err != nil { + return Envelope{}, nil, false, fmt.Errorf("decode envelope JSON: %w", err) + } + if env.Data == "" { + if env.Length != 0 { + return Envelope{}, nil, false, fmt.Errorf("%w: advertised %d bytes", ErrNoPayload, env.Length) + } + return env, nil, false, nil + } + plain, encrypted, err := decodeData(env.Data, key) + if err != nil { + return Envelope{}, nil, false, err + } + // In an encrypted envelope length is the character count of the inner + // base64(protobuf), not the protobuf byte count. Direct-base64 bootstrap + // envelopes commonly leave it at zero (MaintenanceInfo is an example). + if encrypted && env.Length != base64.StdEncoding.EncodedLen(len(plain)) { + return Envelope{}, nil, false, fmt.Errorf("%w: advertised=%d wire=%d decoded=%d encrypted=%t", ErrLengthMismatch, env.Length, base64.StdEncoding.EncodedLen(len(plain)), len(plain), encrypted) + } + return env, plain, encrypted, nil +} + +func decodeData(data string, key []byte) ([]byte, bool, error) { + if len(key) == cryptox.AESKeySize { + if inner, err := cryptox.DecryptBase64(data, key); err == nil { + if proto, err := base64.StdEncoding.DecodeString(string(inner)); err == nil { + return proto, true, nil + } + } + } + proto, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, false, fmt.Errorf("decode fixture data: %w", err) + } + return proto, false, nil +} + +// EncodeEnvelope creates a client-shaped response. encrypted chooses the +// AES-wrapped variant; plain responses use a direct base64 protobuf payload. +func EncodeEnvelope(env Envelope, proto, key []byte, encrypted bool) ([]byte, error) { + copyEnv := env + if encrypted { + if len(key) != cryptox.AESKeySize { + return nil, cryptox.ErrInvalidKey + } + payload, err := cryptox.EncryptBase64Payload(proto, key) + if err != nil { + return nil, err + } + copyEnv.Data = payload + copyEnv.Length = base64.StdEncoding.EncodedLen(len(proto)) + } else { + copyEnv.Data = base64.StdEncoding.EncodeToString(proto) + } + return json.Marshal(copyEnv) +} + +// BatchItem is one JSON member in a BatchRequest response. +type BatchItem struct { + Path string `json:"path"` + Response Envelope `json:"responseData"` +} + +// DecodeBatch parses and validates a BatchRequest response. It rejects +// duplicate endpoint paths because a local dispatcher could not replay them +// deterministically by path. +func DecodeBatch(body, key []byte) ([]BatchItem, map[string][]byte, error) { + var items []BatchItem + if err := json.Unmarshal(body, &items); err != nil { + return nil, nil, fmt.Errorf("decode batch JSON: %w", err) + } + if len(items) == 0 { + return nil, nil, errors.New("bd2 fixture: empty batch response") + } + decoded := make(map[string][]byte, len(items)) + for i, item := range items { + if item.Path == "" || !strings.HasPrefix(item.Path, "/") { + return nil, nil, fmt.Errorf("bd2 fixture: batch item %d has invalid path %q", i, item.Path) + } + if _, exists := decoded[item.Path]; exists { + return nil, nil, fmt.Errorf("bd2 fixture: duplicate batch path %q", item.Path) + } + body, err := json.Marshal(item.Response) + if err != nil { + return nil, nil, err + } + _, proto, err := DecodeEnvelope(body, key) + if err != nil { + return nil, nil, fmt.Errorf("decode batch item %s: %w", item.Path, err) + } + decoded[item.Path] = proto + } + return items, decoded, nil +} + +// Request describes a decoded BatchRequest member. RequestData is a direct +// base64 protobuf payload; the JSON array itself is AES-wrapped on the wire. +type Request struct { + Path string `json:"path"` + RequestData string `json:"requestData"` +} + +func DecodeBatchRequest(body, key []byte) ([]Request, map[string][]byte, error) { + plain, err := cryptox.DecryptBase64(strings.TrimSpace(string(body)), key) + if err != nil { + return nil, nil, fmt.Errorf("decrypt batch request: %w", err) + } + var requests []Request + if err := json.Unmarshal(plain, &requests); err != nil { + return nil, nil, fmt.Errorf("decode batch request JSON: %w", err) + } + decoded := make(map[string][]byte, len(requests)) + for i, req := range requests { + if req.Path == "" || !strings.HasPrefix(req.Path, "/") || req.RequestData == "" { + return nil, nil, fmt.Errorf("bd2 fixture: batch request item %d is invalid", i) + } + if _, exists := decoded[req.Path]; exists { + return nil, nil, fmt.Errorf("bd2 fixture: duplicate batch request path %q", req.Path) + } + proto, err := base64.StdEncoding.DecodeString(req.RequestData) + if err != nil { + return nil, nil, fmt.Errorf("decode batch request %s: %w", req.Path, err) + } + decoded[req.Path] = proto + } + return requests, decoded, nil +} + +// Paths returns the fixture response paths in lexical order for diagnostics. +func Paths(items []BatchItem) []string { + paths := make([]string, 0, len(items)) + for _, item := range items { + paths = append(paths, item.Path) + } + sort.Strings(paths) + return paths +} + +// EqualProto deliberately treats nil and empty payloads equally; protocol +// endpoints with response length zero use both representations in practice. +func EqualProto(a, b []byte) bool { return bytes.Equal(a, b) } + +var _ fs.FS // document that fixtures deliberately use an on-disk capture. diff --git a/go/internal/fixture/fixture_test.go b/go/internal/fixture/fixture_test.go new file mode 100644 index 0000000..05c3a40 --- /dev/null +++ b/go/internal/fixture/fixture_test.go @@ -0,0 +1,360 @@ +package fixture + +import ( + "bytes" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "testing" + + "bd2server/internal/cryptox" + "bd2server/internal/wire" +) + +func captureRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) + if err != nil { + t.Fatal(err) + } + return root +} + +func TestCapturedQuestUpdateResponseShape(t *testing.T) { + set, err := Load(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + captureSequence int + questID uint64 + }{{60, 1}, {71, 4}, {120, 12}} { + payload, _, err := set.RecordedPayloadAt("/QuestUpdate", test.captureSequence) + if err != nil { + t.Fatal(err) + } + updated, found, err := wire.Varint(payload.Proto, 1) + if err != nil || !found || updated != test.questID { + t.Fatalf("sequence %d: updateQuestId=%d found=%v err=%v", test.captureSequence, updated, found, err) + } + rewardBundle, reward, err := wire.Bytes(payload.Proto, 2) + if err != nil || !reward || len(rewardBundle) != 0 { + t.Fatalf("sequence %d: reward bundle bytes=%d found=%v err=%v", test.captureSequence, len(rewardBundle), reward, err) + } + if payload.PacketCode != 19 { + t.Fatalf("sequence %d: packet code %d", test.captureSequence, payload.PacketCode) + } + } +} + +func TestLogCapturedQuestClearShape(t *testing.T) { + set, err := Load(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + for _, sequence := range []int{61, 63, 121} { + payload, _, err := set.RecordedPayloadAt("/QuestClear", sequence) + if err != nil { + t.Fatal(err) + } + var fields []string + if err := wire.Walk(payload.Proto, func(field wire.Field) error { + fields = append(fields, fmt.Sprintf("#%d/type%d/%dB", field.Number, field.Type, len(field.Value))) + return nil + }); err != nil { + t.Fatal(err) + } + t.Logf("sequence=%d packet=%d fields=%v", sequence, payload.PacketCode, fields) + } +} + +func TestLogNewPlayerQuestRewards(t *testing.T) { + set, err := Load(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + for _, record := range set.RecordedResponses("/QuestClear") { + payload, _, err := set.RecordedPayloadAt("/QuestClear", record.RequestSequence) + if err != nil { + t.Fatal(err) + } + quest, _, _ := wire.Varint(payload.Proto, 3) + nextRaw, _, _ := wire.Bytes(payload.Proto, 2) + next, _, _ := wire.Varint(nextRaw, 1) + reward, _, _ := wire.Bytes(payload.Proto, 1) + var items, chars, costumes []string + var responseChars, decks []string + _ = wire.Walk(reward, func(field wire.Field) error { + if field.Type != 2 { + return nil + } + switch field.Number { + case 1: + idx, _, _ := wire.Varint(field.Value, 1) + id, _, _ := wire.Varint(field.Value, 2) + typ, _, _ := wire.Varint(field.Value, 3) + count, _, _ := wire.Varint(field.Value, 4) + items = append(items, fmt.Sprintf("idx=%d id=%d type=%d count=%d", idx, id, typ, count)) + case 2: + idx, _, _ := wire.Varint(field.Value, 1) + id, _, _ := wire.Varint(field.Value, 2) + hp, _, _ := wire.Varint(field.Value, 3) + level, _, _ := wire.Varint(field.Value, 4) + costume, _, _ := wire.Varint(field.Value, 7) + chars = append(chars, fmt.Sprintf("idx=%d id=%d hp=%d level=%d costumeIdx=%d raw=%x", idx, id, hp, level, costume, field.Value)) + case 3: + idx, _, _ := wire.Varint(field.Value, 1) + id, _, _ := wire.Varint(field.Value, 2) + useChar, _, _ := wire.Varint(field.Value, 4) + costumes = append(costumes, fmt.Sprintf("idx=%d id=%d useChar=%d raw=%x", idx, id, useChar, field.Value)) + } + return nil + }) + _ = wire.Walk(payload.Proto, func(field wire.Field) error { + if field.Type != 2 { + return nil + } + if field.Number == 4 { + idx, _, _ := wire.Varint(field.Value, 1) + costume, _, _ := wire.Varint(field.Value, 2) + slot, _, _ := wire.Varint(field.Value, 3) + decks = append(decks, fmt.Sprintf("charIdx=%d costume=%d slot=%d", idx, costume, slot)) + } else if field.Number == 5 { + idx, _, _ := wire.Varint(field.Value, 1) + id, _, _ := wire.Varint(field.Value, 2) + hp, _, _ := wire.Varint(field.Value, 3) + level, _, _ := wire.Varint(field.Value, 4) + useCostume, _, _ := wire.Varint(field.Value, 7) + connect, _, _ := wire.Varint(field.Value, 13) + responseChars = append(responseChars, fmt.Sprintf("idx=%d id=%d hp=%d level=%d costumeIdx=%d connect=%d raw=%x", idx, id, hp, level, useCostume, connect, field.Value)) + } + return nil + }) + t.Logf("quest=%d next=%d requestSeq=%d items=%v rewardChars=%v costumes=%v decks=%v responseChars=%v", quest, next, record.RequestSequence, items, chars, costumes, decks, responseChars) + } +} + +func TestCapturedBootstrapResponsesDecode(t *testing.T) { + c, err := Open(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + for _, spec := range Bootstrap { + response, err := c.BootstrapResponse(spec.Name) + if err != nil { + t.Fatalf("%s: %v", spec.Name, err) + } + key := []byte(nil) + if spec.Key == FixedLogin { + key = cryptox.Key() + } + env, proto, err := DecodeEnvelope(response.Body, key) + if err != nil { + t.Fatalf("%s: %v", spec.Name, err) + } + if env.ErrorType != 0 || len(proto) == 0 { + t.Fatalf("%s: errorType=%d proto=%d length=%d", spec.Name, env.ErrorType, len(proto), env.Length) + } + } +} + +func TestEnvelopeReencryptsAndKeepsLength(t *testing.T) { + c, err := Open(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + login, err := c.Login() + if err != nil { + t.Fatal(err) + } + env, proto, err := DecodeEnvelope(login.Body, cryptox.Key()) + if err != nil { + t.Fatal(err) + } + reencoded, err := EncodeEnvelope(env, proto, cryptox.Key(), true) + if err != nil { + t.Fatal(err) + } + gotEnv, got, err := DecodeEnvelope(reencoded, cryptox.Key()) + if err != nil { + t.Fatal(err) + } + if gotEnv.Length != base64.StdEncoding.EncodedLen(len(proto)) || !bytes.Equal(got, proto) { + t.Fatalf("reencrypted payload changed: got=%d want=%d", len(got), len(proto)) + } +} + +func TestBatchRequestsAndResponsesAreComplete(t *testing.T) { + c, err := Open(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + key, err := cryptox.SessionKey("7eeb5c2480c298f6fc4c00d22b1b0b15") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + number int + request string + want int + }{ + {1, "00040_req_BatchRequest.bin", 57}, + {2, "00076_req_BatchRequest.bin", 5}, + } { + reqBody, err := os.ReadFile(filepath.Join(c.Root, "bodies", tc.request)) + if err != nil { + t.Fatal(err) + } + reqs, reqPayloads, err := DecodeBatchRequest(reqBody, key) + if err != nil { + t.Fatalf("batch %d request: %v", tc.number, err) + } + response, err := c.Batch(tc.number) + if err != nil { + t.Fatal(err) + } + items, responsePayloads, err := DecodeBatch(response.Body, key) + if err != nil { + t.Fatalf("batch %d response: %v", tc.number, err) + } + if len(reqs) != tc.want || len(items) != tc.want || len(reqPayloads) != tc.want || len(responsePayloads) != tc.want { + t.Fatalf("batch %d: req=%d resp=%d (want %d)", tc.number, len(reqs), len(items), tc.want) + } + for path := range reqPayloads { + if _, ok := responsePayloads[path]; !ok { + t.Fatalf("batch %d lacks response path %s", tc.number, path) + } + } + } +} + +func TestSetProvidesIntegrationAPIAndOrdersBatchByRequest(t *testing.T) { + set, err := Load(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + if _, err := set.CoreResponse("/MaintenanceInfo"); err != nil { + t.Fatal(err) + } + key, err := set.SessionKey() + if err != nil { + t.Fatal(err) + } + if len(key) != 32 { + t.Fatalf("session key has length %d", len(key)) + } + req, err := os.ReadFile(filepath.Join(set.Root, "bodies", "00040_req_BatchRequest.bin")) + if err != nil { + t.Fatal(err) + } + requests, _, err := DecodeBatchRequest(req, []byte(key)) + if err != nil { + t.Fatal(err) + } + paths := make([]string, len(requests)) + for i := range requests { + paths[i] = requests[i].Path + } + ordered, err := set.BatchForPaths(1, paths) + if err != nil { + t.Fatal(err) + } + for i, item := range ordered { + if item.Path != paths[i] { + t.Fatalf("ordered[%d]=%s want %s", i, item.Path, paths[i]) + } + } +} + +func TestRecordedResponsesUseRequestSequenceAndCanReencrypt(t *testing.T) { + set, err := Load(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + // Sequence 10 is EventScheduleInfo in this capture. It is a session-key + // encrypted response; use a different local key to prove re-encryption. + payload, record, err := set.RecordedPayloadAt("/EventScheduleInfo", 10) + if err != nil { + t.Fatal(err) + } + if !payload.Encrypted || record.File != "00018_resp_EventScheduleInfo.bin" { + t.Fatalf("unexpected selected record: %#v encrypted=%t", record, payload.Encrypted) + } + newKey, err := cryptox.SessionKey("0123456789abcdef0123456789abcdef") + if err != nil { + t.Fatal(err) + } + reencoded, _, err := set.ReencryptRecordedAt("/EventScheduleInfo", 10, newKey) + if err != nil { + t.Fatal(err) + } + _, proto, err := DecodeEnvelope(reencoded, newKey) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(proto, payload.Proto) { + t.Fatal("re-encrypted selected payload changed") + } + if _, _, err := set.RecordedResponseAt("/EventScheduleInfo", 999); err == nil { + t.Fatal("unsafe/nonexistent sequence unexpectedly resolved") + } +} + +func TestRecordedResponseResolvesClientSequence(t *testing.T) { + set, err := Load(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + record, err := set.RecordedResponseForClientSequence("/TutorialClear", 103) + if err != nil { + t.Fatal(err) + } + if record.RequestSequence != 57 || record.File != "00109_resp_TutorialClear.bin" { + t.Fatalf("unexpected TutorialClear record: %+v", record) + } + if _, err := set.RecordedResponseForClientSequence("/TutorialClear", 999999); err == nil { + t.Fatal("unknown client sequence unexpectedly resolved") + } +} + +func TestHandleBatchReencryptsAllResponseDataForLocalSession(t *testing.T) { + set, err := Load(captureRoot(t)) + if err != nil { + t.Fatal(err) + } + capturedKey, err := set.SessionKey() + if err != nil { + t.Fatal(err) + } + original, err := os.ReadFile(filepath.Join(set.Root, "bodies", "00040_req_BatchRequest.bin")) + if err != nil { + t.Fatal(err) + } + plain, err := cryptox.DecryptBase64(string(original), []byte(capturedKey)) + if err != nil { + t.Fatal(err) + } + localKey, err := cryptox.SessionKey("0123456789abcdef0123456789abcdef") + if err != nil { + t.Fatal(err) + } + localRequest, err := cryptox.EncryptBase64(plain, localKey) + if err != nil { + t.Fatal(err) + } + response, err := set.HandleBatch(1, []byte(localRequest), localKey) + if err != nil { + t.Fatal(err) + } + items, payloads, err := DecodeBatch(response, localKey) + if err != nil { + t.Fatal(err) + } + if len(items) != 57 || len(payloads) != 57 || items[0].Path != "/MailInfo" { + t.Fatalf("local batch response did not preserve request order/count: first=%q count=%d", items[0].Path, len(items)) + } + if items[0].Response.ServerNowTime == 0 || items[0].Response.Notify == "" { + t.Fatal("batch response lost envelope metadata while re-encrypting") + } +} diff --git a/go/internal/gacha/gacha.go b/go/internal/gacha/gacha.go new file mode 100644 index 0000000..e93c2e7 --- /dev/null +++ b/go/internal/gacha/gacha.go @@ -0,0 +1,744 @@ +// Package gacha implements the local, non-payment version of the official +// 2.34.13 infinite reroll product. Static pools come from verified GameData; +// only the latest preview and confirmed ownership are persisted locally. +package gacha + +import ( + "errors" + "fmt" + "sync" + + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +const infiniteGrant = "cash-product:1100001:9100033" + +const ( + twelvePickGroupID = 10001 + paidTwelvePickGroupID = 30011 +) + +const stepUpGroupID = 29 + +var stepUpGachaIDs = [...]uint64{8100118, 8100119, 8100120, 8100121} + +func StepUpMigrationFact() (uint64, []uint64) { + return stepUpGroupID, append([]uint64(nil), stepUpGachaIDs[:]...) +} + +type Service struct { + design *gamedata.InfiniteGachaDesign + regular *gamedata.RegularGachaCatalog + collection *player.CollectionStore + wallet *player.Wallet + inventory *player.Inventory + equipmentCatalog *gamedata.EquipmentGachaCatalog + equipmentInventory *player.EquipmentInventory + onPreview func() error + sessionMu sync.RWMutex + sessionID string +} + +func (s *Service) AttachPreviewMission(callback func() error) { s.onPreview = callback } +func (s *Service) AttachInventory(inventory *player.Inventory) { s.inventory = inventory } +func (s *Service) AttachEquipmentGacha(catalog *gamedata.EquipmentGachaCatalog, inventory *player.EquipmentInventory) { + s.equipmentCatalog, s.equipmentInventory = catalog, inventory +} + +// BeginSession is called by the transport session after each successful +// login. Request sequence numbers restart with the client, so the login ID is +// part of the durable idempotency key: retries in one login remain idempotent, +// while a new login may legitimately reuse the same protobuf sequence. +func (s *Service) BeginSession(id string) { + s.sessionMu.Lock() + s.sessionID = id + s.sessionMu.Unlock() +} + +func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularGachaCatalog, collection *player.CollectionStore, wallet *player.Wallet) (*Service, error) { + if design == nil || regular == nil || collection == nil || wallet == nil { + return nil, errors.New("gacha: invalid service configuration") + } + return &Service{design: design, regular: regular, collection: collection, wallet: wallet}, nil +} + +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/GachaInfo" && path != "/GachaBuy" && path != "/GachaPointManualExchange" && path != "/GachaBuyPreview" && path != "/GachaBuyPreviewLock" && path != "/GachaSelectionSave" && path != "/CashShopPurchaseCountInfo" && path != "/CashShopBuy" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, fmt.Errorf("gacha: %s invalid sequence", path) + } + switch path { + case "/GachaInfo": + return 145, s.gachaInfo(), true, nil + case "/GachaBuyPreview": + return s.preview(request) + case "/GachaBuyPreviewLock": + return s.lockPreview(request) + case "/GachaBuy": + return s.buy(request, seq) + case "/GachaPointManualExchange": + return s.manualPointExchange(request, seq) + case "/GachaSelectionSave": + return s.saveSelection(request) + case "/CashShopPurchaseCountInfo": + if _, bought := s.collection.Grant(infiniteGrant); bought { + count := wire.AppendVarint(nil, 1, gamedata.InfiniteProductGroupID) + count = wire.AppendVarint(count, 2, gamedata.InfiniteProductID) + count = wire.AppendVarint(count, 4, 1) + return 432, wire.AppendBytes(nil, 1, count), true, nil + } + return 432, nil, true, nil + case "/CashShopBuy": + return s.confirm(request) + default: + return 0, nil, false, nil + } +} + +func (s *Service) saveSelection(request []byte) (int, []byte, bool, error) { + var selections []player.GachaSelection + if err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + if field.Type != 2 { + return errors.New("gacha: invalid selection entry") + } + groupID, groupFound, err := wire.Varint(field.Value, 1) + if err != nil || !groupFound { + return errors.New("gacha: selection group missing") + } + slot, _, err := wire.Varint(field.Value, 2) // slot zero is omitted by protobuf + if err != nil { + return err + } + itemID, itemFound, err := wire.Varint(field.Value, 3) + if err != nil || !itemFound { + return errors.New("gacha: selection item missing") + } + selections = append(selections, player.GachaSelection{GroupID: groupID, Slot: slot, ItemID: itemID}) + return nil + }); err != nil { + return 198, nil, true, err + } + if len(selections) == 0 { + return 198, nil, true, errors.New("gacha: empty selection") + } + groupID := selections[0].GroupID + expected := 0 + if group, ok := s.regular.Group(groupID); ok && group.SelectCount != 0 { + expected = int(group.SelectCount) + } else if groupID == paidTwelvePickGroupID { + expected = 12 + } + if expected == 0 || len(selections) != expected { + return 198, nil, true, fmt.Errorf("gacha: group %d requires %d selections, got %d", groupID, expected, len(selections)) + } + validItems := make(map[uint64]bool, len(s.design.FiveStarIDs)) + for _, id := range s.design.FiveStarIDs { + validItems[id] = true + } + seenSlots := make(map[uint64]bool, len(selections)) + seenItems := make(map[uint64]bool, len(selections)) + for _, selection := range selections { + if selection.GroupID != groupID || selection.Slot >= uint64(expected) || seenSlots[selection.Slot] || seenItems[selection.ItemID] || !validItems[selection.ItemID] { + return 198, nil, true, fmt.Errorf("gacha: invalid 12PICK selection group=%d slot=%d item=%d", selection.GroupID, selection.Slot, selection.ItemID) + } + seenSlots[selection.Slot] = true + seenItems[selection.ItemID] = true + } + if err := s.collection.SaveGachaSelections(groupID, selections); err != nil { + return 198, nil, true, err + } + return 198, nil, true, nil +} + +func (s *Service) lockPreview(request []byte) (int, []byte, bool, error) { + eventIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || eventIndex == 0 { + return 175, nil, true, errors.New("gacha: missing preview event index") + } + if err := s.collection.LockPreview(eventIndex); err != nil { + return 175, nil, true, err + } + // 2.34.13 has no separate PacketCodeTypeProto member for the lock RPC. + // It is a sub-operation of GachaBuyPreview and the client callback only + // parses the empty response, so it uses the parent packet code. + return 175, nil, true, nil +} + +func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) { + id, found, err := wire.Varint(request, 2) + if err != nil || !found || id == 0 { + return 146, nil, true, errors.New("gacha: missing gacha id") + } + buyType, _, err := wire.Varint(request, 3) + if err != nil || (buyType != 1 && buyType != 2) { + return 146, nil, true, fmt.Errorf("gacha: unsupported buy type %d", buyType) + } + var tickets []player.Item + if err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 4 { + return nil + } + if field.Type != 2 { + return errors.New("gacha: invalid ticket info") + } + var item player.Item + var found bool + var parseErr error + if item.InvenIndex, found, parseErr = wire.Varint(field.Value, 1); parseErr != nil || !found { + return errors.New("gacha: ticket inventory index missing") + } + item.ID, _, parseErr = wire.Varint(field.Value, 2) + if parseErr != nil { + return parseErr + } + item.Type, _, parseErr = wire.Varint(field.Value, 3) + if parseErr != nil { + return parseErr + } + item.Count, _, parseErr = wire.Varint(field.Value, 4) + if parseErr != nil { + return parseErr + } + if item.Type != 8 || item.Count == 0 { + return errors.New("gacha: unsupported ticket") + } + tickets = append(tickets, item) + return nil + }); err != nil { + return 146, nil, true, err + } + design, ok := s.regular.Gacha(id) + if !ok && s.equipmentCatalog != nil { + if equipment, found := s.equipmentCatalog.Gacha(id); found { + return s.buyEquipment(seq, buyType, tickets, equipment) + } + } + if !ok || (design.PriceType != 2 && design.PriceType != 3) || (buyType == 2 && design.PriceType != 2) { + priceType := uint64(0) + if ok { + priceType = design.PriceType + } + return 146, nil, true, fmt.Errorf("gacha: unsupported regular gacha id=%d buyType=%d priceType=%d", id, buyType, priceType) + } + for _, ticket := range tickets { + allowed := false + for _, ticketID := range design.TicketIDs { + if ticket.ID == ticketID { + allowed = true + break + } + } + if !allowed { + return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for gacha %d", ticket.ID, id) + } + } + group, knownGroup := s.regular.GroupForGacha(id) + if !knownGroup { + group = gamedata.GachaGroupDesign{ID: id, PointCount: 1} + } + identity := s.requestIdentity(id, seq) + grant, already := s.collection.Grant(identity) + step, isStepUp := stepUpStep(id) + if isStepUp && !already { + completed := s.collection.StepUpProgress(stepUpGroupID) + if completed+1 != step { + return 146, nil, true, fmt.Errorf("gacha: step-up group %d completed=%d cannot buy step=%d", stepUpGroupID, completed, step) + } + } + if !already { + var ticketCount uint64 + for _, ticket := range tickets { + ticketCount += ticket.Count + } + if ticketCount > uint64(design.Count) || ((buyType == 2 || design.PriceType != 3) && ticketCount != 0) { + return 146, nil, true, errors.New("gacha: invalid ticket count") + } + remaining := uint64(design.Count) - ticketCount + unitPrice := design.Price / uint64(design.Count) + if len(tickets) != 0 { + if s.inventory == nil { + return 146, nil, true, errors.New("gacha: inventory not attached") + } + if err := s.inventory.Consume(tickets); err != nil { + return 146, nil, true, err + } + } + if remaining != 0 { + price := unitPrice * remaining + if buyType == 2 || design.PriceType == 2 { + if _, err := s.wallet.SpendJewelryOnce(identity, price); err != nil { + return 146, nil, true, err + } + } else { + if _, err := s.wallet.SpendFreeJewelryOnce(identity, price); err != nil { + return 146, nil, true, err + } + } + } + var roll []uint64 + var fixedStates []player.GachaFixedState + var selectionApplySortIDs []uint64 + if fixed, found := s.regular.Fixed(group.FixedID); found && design.FixedCostumeID == 0 { + var fixedResult gamedata.GachaFixedRoll + var selected, normalSelected, pitySelected []uint64 + if group.GachaSubType == 1 { + for _, choice := range s.collection.GachaSelections(group.ID) { + selected = append(selected, choice.ItemID) + } + } + if !group.UseSelectionOnlyFixedApply { + normalSelected = selected + } + if group.IsSelectedFromPity { + pitySelected = selected + } + roll, fixedResult, err = design.RollWithCostumeFixedSelection(s.collection.GachaFixedCount(group.FixedID, 0), s.collection.GachaFixedCount(group.FixedID, 1), fixed, normalSelected, pitySelected) + if err == nil { + fixedStates = []player.GachaFixedState{ + {FixedID: group.FixedID, Type: 0, Count: fixedResult.CostumeGrade4Count, ApplySort: fixedResult.CostumeGrade4Sort}, + {FixedID: group.FixedID, Type: 1, Count: fixedResult.CostumeGrade5Count, ApplySort: fixedResult.CostumeGrade5Sort}, + } + for _, sortID := range fixedResult.SelectionSorts { + selectionApplySortIDs = append(selectionApplySortIDs, uint64(sortID)) + } + } + } else { + roll, err = design.Roll() + } + if err != nil { + return 146, nil, true, err + } + grant, err = s.collection.GrantRegularPurchase(identity, roll, s.regular, player.GachaPurchase{Group: group, BuyType: buyType, Fixed: fixedStates, SelectionApplySortIDs: selectionApplySortIDs}) + if err != nil { + return 146, nil, true, err + } + } + if isStepUp { + if err := s.collection.CompleteStepUp(stepUpGroupID, step); err != nil { + return 146, nil, true, err + } + } + if err := s.creditOverflow(identity, grant); err != nil { + return 146, nil, true, err + } + response := wire.AppendBytes(nil, 1, s.rewardBundle(grant)) + response = wire.AppendVarint(response, 2, grant.GachaPoint) + for _, fixed := range grant.GachaFixed { + response = wire.AppendBytes(response, 3, gachaFixedWire(fixed)) + } + for _, sortID := range grant.SelectionApplySortIDs { + response = wire.AppendVarint(response, 4, sortID) + } + if s.onPreview != nil { + if err := s.onPreview(); err != nil { + return 146, nil, true, fmt.Errorf("gacha: update buy mission: %w", err) + } + } + return 146, response, true, nil +} + +// buyEquipment is intentionally limited to the current three GameData-backed +// pickup groups. Equipment lives in its own inventory, but the official +// GachaUser/GachaFixed records are shared protocol state and are persisted in +// CollectionStore under the same request identity. +func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha) (int, []byte, bool, error) { + if s.equipmentInventory == nil { + return 146, nil, true, errors.New("gacha: equipment inventory not attached") + } + if buyType != 1 || design.PriceType != 3 { + return 146, nil, true, fmt.Errorf("gacha: unsupported equipment buy type %d", buyType) + } + for _, ticket := range tickets { + if len(design.TicketIDs) != 1 || ticket.ID != design.TicketIDs[0] { + return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for equipment gacha %d", ticket.ID, design.ID) + } + } + group, ok := s.equipmentCatalog.GroupForGacha(design.ID) + if !ok { + return 146, nil, true, fmt.Errorf("gacha: equipment gacha %d has no group", design.ID) + } + identity := s.requestIdentity(design.ID, seq) + grant, already := s.collection.Grant(identity) + var entries []player.Equipment + if already { + for sort := 0; sort < design.Count; sort++ { + entry, found := s.equipmentInventory.Granted(fmt.Sprintf("%s:equip:%d", identity, sort)) + if !found { + return 146, nil, true, fmt.Errorf("gacha: equipment retry %s missing slot %d", identity, sort) + } + entries = append(entries, entry) + } + } else { + var ticketCount uint64 + for _, ticket := range tickets { + ticketCount += ticket.Count + } + if ticketCount > uint64(design.Count) { + return 146, nil, true, errors.New("gacha: invalid equipment ticket count") + } + if ticketCount != 0 { + if s.inventory == nil { + return 146, nil, true, errors.New("gacha: inventory not attached") + } + if err := s.inventory.Consume(tickets); err != nil { + return 146, nil, true, err + } + } + if remain := uint64(design.Count) - ticketCount; remain != 0 { + if _, err := s.wallet.SpendFreeJewelryOnce(identity, design.Price/uint64(design.Count)*remain); err != nil { + return 146, nil, true, err + } + } + fixed := s.equipmentCatalog.Fixed() + roll, state, err := design.Roll(s.collection.GachaFixedCount(fixed.ID, 2), s.collection.GachaFixedCount(fixed.ID, 3), fixed) + if err != nil { + return 146, nil, true, err + } + fixedStates := []player.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}} + for sort, equipmentID := range roll { + main, sub, private, err := s.equipmentCatalog.RollOptions(equipmentID) + if err != nil { + return 146, nil, true, err + } + entry := player.Equipment{ID: equipmentID, SortID: uint64(sort)} + 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} + } + saved, err := s.equipmentInventory.GrantGeneratedOnce(fmt.Sprintf("%s:equip:%d", identity, sort), entry) + if err != nil { + return 146, nil, true, err + } + entries = append(entries, saved) + } + grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates}) + if err != nil { + return 146, nil, true, err + } + } + bundle := []byte{} + for _, entry := range entries { + bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry)) + } + response := wire.AppendBytes(nil, 1, bundle) + response = wire.AppendVarint(response, 2, grant.GachaPoint) + for _, fixed := range grant.GachaFixed { + response = wire.AppendBytes(response, 3, gachaFixedWire(fixed)) + } + return 146, response, true, nil +} + +func gachaFixedWire(fixed player.GachaFixedState) []byte { + info := wire.AppendVarint(nil, 1, fixed.FixedID) + info = wire.AppendVarint(info, 2, fixed.Type) + info = wire.AppendVarint(info, 3, fixed.Count) + // ApplySortId is signed int32: -1 must be encoded explicitly, otherwise + // proto3 defaults to slot 0 and falsely labels the first draw guaranteed. + return wire.AppendVarint(info, 4, uint64(int64(fixed.ApplySort))) +} + +func (s *Service) manualPointExchange(request []byte, seq uint64) (int, []byte, bool, error) { + groupID, found, err := wire.Varint(request, 2) + if err != nil || !found || groupID == 0 { + return 188, nil, true, errors.New("gacha: missing point exchange group") + } + count, found, err := wire.Varint(request, 3) + if err != nil || !found || count == 0 { + return 188, nil, true, errors.New("gacha: invalid point exchange count") + } + group, ok := s.regular.Group(groupID) + if !ok || group.PointCount == 0 { + return 188, nil, true, fmt.Errorf("gacha: group %d does not support point exchange", groupID) + } + identity := fmt.Sprintf("gacha-point-manual:%s:%d:seq:%d", s.loginIdentity(), groupID, seq) + exchange, err := s.collection.ExchangeGachaPoint(identity, groupID, count) + if err != nil { + return 188, nil, true, err + } + if _, err := s.wallet.GrantHopePowderOnce(identity, exchange.Count); err != nil { + return 188, nil, true, err + } + // Manual conversion grants account currency type 22, not a stackable + // ItemDBInfo. The client's common reward handler applies RepaidCurrency. + reward := wire.AppendVarint(nil, 2, 22) + reward = wire.AppendVarint(reward, 3, exchange.Count) + bundle := wire.AppendBytes(nil, 10, reward) + return 188, wire.AppendBytes(nil, 1, bundle), true, nil +} + +func (s *Service) loginIdentity() string { + s.sessionMu.RLock() + defer s.sessionMu.RUnlock() + if s.sessionID == "" { + return "local" + } + return s.sessionID +} + +func (s *Service) requestIdentity(gachaID, seq uint64) string { + s.sessionMu.RLock() + sessionID := s.sessionID + s.sessionMu.RUnlock() + if sessionID == "" { + // Unit tests and embedders without a session coordinator retain the + // original stable key. Production always supplies BeginSession. + return fmt.Sprintf("regular-gacha:%d:seq:%d", gachaID, seq) + } + return fmt.Sprintf("regular-gacha:%d:session:%s:seq:%d", gachaID, sessionID, seq) +} + +func stepUpStep(gachaID uint64) (uint64, bool) { + for i, id := range stepUpGachaIDs { + if id == gachaID { + return uint64(i + 1), true + } + } + return 0, false +} + +func (s *Service) preview(request []byte) (int, []byte, bool, error) { + if _, bought := s.collection.Grant(infiniteGrant); bought { + return 175, nil, true, errors.New("gacha: infinite product already purchased") + } + id, found, err := wire.Varint(request, 2) + if err != nil || !found || id != gamedata.InfiniteGachaID { + return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id) + } + group, found, err := wire.Varint(request, 3) + if err != nil || !found || group != gamedata.InfiniteProductGroupID { + return 0, nil, true, fmt.Errorf("gacha: unsupported preview product group %d", group) + } + product, found, err := wire.Varint(request, 4) + if err != nil || !found || product != gamedata.InfiniteProductID { + return 0, nil, true, fmt.Errorf("gacha: unsupported preview product %d", product) + } + roll, err := s.design.Roll() + if err != nil { + return 0, nil, true, err + } + if err := s.collection.SetPreview(roll); err != nil { + return 0, nil, true, err + } + if s.onPreview != nil { + if err := s.onPreview(); err != nil { + return 0, nil, true, fmt.Errorf("gacha: update preview mission: %w", err) + } + } + var bundle []byte + for i, costumeID := range roll { + costume := wire.AppendVarint(nil, 2, costumeID) + if i != 0 { + costume = wire.AppendVarint(costume, 6, uint64(i)) + } + bundle = wire.AppendBytes(bundle, 3, costume) + } + return 175, wire.AppendBytes(nil, 1, bundle), true, nil +} + +func (s *Service) confirm(request []byte) (int, []byte, bool, error) { + group, found, err := wire.Varint(request, 3) + if err != nil || !found || group != gamedata.InfiniteProductGroupID { + return 0, nil, true, fmt.Errorf("gacha: unsupported cash product group %d", group) + } + var productID, saleGroup, buyCount uint64 + err = wire.Walk(request, func(field wire.Field) error { + if field.Number != 4 { + return nil + } + if field.Type != 2 || productID != 0 { + return errors.New("gacha: invalid product list") + } + var parseErr error + productID, found, parseErr = wire.Varint(field.Value, 1) + if parseErr != nil || !found { + return errors.New("gacha: product id missing") + } + saleGroup, _, parseErr = wire.Varint(field.Value, 2) + if parseErr != nil { + return parseErr + } + buyCount, found, parseErr = wire.Varint(field.Value, 3) + if parseErr != nil || !found { + return errors.New("gacha: buy count missing") + } + return nil + }) + if err != nil { + return 0, nil, true, err + } + if productID != gamedata.InfiniteProductID || saleGroup != 0 || buyCount != 1 { + return 0, nil, true, fmt.Errorf("gacha: unsupported product %d sale %d count %d", productID, saleGroup, buyCount) + } + grant, err := s.collection.ConfirmInfinite(infiniteGrant, s.design) + if err != nil { + return 0, nil, true, err + } + if err := s.creditOverflow(infiniteGrant, grant); err != nil { + return 61, nil, true, err + } + return 61, wire.AppendBytes(nil, 1, s.rewardBundle(grant)), true, nil +} + +func (s *Service) creditOverflow(identity string, grant player.CollectionGrant) error { + var mileage uint64 + for _, exchange := range grant.Exchanges { + if exchange.ExchangeItemType != 20 { + return fmt.Errorf("gacha: unsupported costume overflow type %d", exchange.ExchangeItemType) + } + if ^uint64(0)-mileage < exchange.ExchangeCount { + return errors.New("gacha: costume overflow mileage overflow") + } + mileage += exchange.ExchangeCount + } + if mileage == 0 { + return nil + } + _, err := s.wallet.GrantMileageOnce(identity, mileage) + return err +} + +func (s *Service) rewardBundle(grant player.CollectionGrant) []byte { + var bundle []byte + newCharacters := make(map[uint64]player.Character, len(grant.CharacterIndices)) + for _, index := range grant.CharacterIndices { + if character, found := s.collection.CharacterByIndex(index); found { + newCharacters[character.ConnectPotentialCostume] = character + bundle = wire.AppendBytes(bundle, 2, player.CharacterWire(character)) + } + } + for _, index := range grant.CostumeIndices { + if costume, found := s.collection.CostumeByIndex(index); found { + bundle = wire.AppendBytes(bundle, 3, player.CostumeWire(costume)) + } + } + for sortID, costumeID := range grant.ViewCostumeIDs { + view := wire.AppendVarint(nil, 2, costumeID) + view = wire.AppendVarint(view, 3, 11) + view = wire.AppendVarint(view, 4, 1) + bundle = wire.AppendBytes(bundle, 6, view) + if character, found := newCharacters[costumeID]; found { + charView := wire.AppendVarint(nil, 2, character.ID) + charView = wire.AppendVarint(charView, 3, 6) + charView = wire.AppendVarint(charView, 4, 1) + if sortID != 0 { + charView = wire.AppendVarint(charView, 6, uint64(sortID)) + } + bundle = wire.AppendBytes(bundle, 6, charView) + } + } + for _, upgrade := range grant.Upgrades { + info := wire.AppendVarint(nil, 1, upgrade.InvenIndex) + info = wire.AppendVarint(info, 2, 11) + info = wire.AppendVarint(info, 3, upgrade.CostumeID) + if upgrade.Before != 0 { + info = wire.AppendVarint(info, 4, upgrade.Before) + } + info = wire.AppendVarint(info, 5, upgrade.After) + if upgrade.SortID != 0 { + info = wire.AppendVarint(info, 6, upgrade.SortID) + } + bundle = wire.AppendBytes(bundle, 9, info) + } + var repaidMileage uint64 + for _, exchange := range grant.Exchanges { + info := wire.AppendVarint(nil, 1, exchange.OriginalItemType) + info = wire.AppendVarint(info, 2, exchange.OriginalItemID) + info = wire.AppendVarint(info, 3, exchange.OriginalCount) + info = wire.AppendVarint(info, 4, exchange.ExchangeItemType) + if exchange.ExchangeItemID != 0 { + info = wire.AppendVarint(info, 5, exchange.ExchangeItemID) + } + info = wire.AppendVarint(info, 6, exchange.ExchangeCount) + if exchange.SortID != 0 { + info = wire.AppendVarint(info, 7, exchange.SortID) + } + bundle = wire.AppendBytes(bundle, 8, info) + if exchange.ExchangeItemType == 20 { + repaidMileage += exchange.ExchangeCount + } + } + if repaidMileage != 0 { + repaid := wire.AppendVarint(nil, 2, 20) + repaid = wire.AppendVarint(repaid, 3, repaidMileage) + bundle = wire.AppendBytes(bundle, 10, repaid) + } + return bundle +} + +// The dynamic schedule is taken from the verified 2026-09-20 official +// GachaInfo response. Static banner definitions remain in GameData. The paid +// infinite product is confirmed through CashShopBuy and is not one of these +// ordinary schedule groups. +func (s *Service) gachaInfo() []byte { + entries := []struct { + group uint64 + start, end uint64 + }{ + {135, 1787788800000, 1790121599000}, + {205, 1788998400000, 1791417599000}, + {121, 1788998400000, 1790121599000}, + } + if s.equipmentCatalog != nil { + // Permanent equipment draw: direct client evidence is GachaBuy id=201, + // which GameData maps to group 10002. It has no SelectCount fields. + entries = append([]struct{ group, start, end uint64 }{{10002, 0, 4102444800000}}, entries...) + } + if _, bought := s.collection.Grant(infiniteGrant); !bought { + entries = append(entries, struct{ group, start, end uint64 }{30010, 1788998400000, 1791417599000}) + } + var out []byte + for _, entry := range entries { + schedule := wire.AppendVarint(nil, 1, entry.group) + schedule = wire.AppendVarint(schedule, 2, entry.start) + schedule = wire.AppendVarint(schedule, 3, entry.end) + out = wire.AppendBytes(out, 1, schedule) + } + step := wire.AppendVarint(nil, 1, 29) + step = wire.AppendVarint(step, 2, 1788998400000) + step = wire.AppendVarint(step, 3, 1791417599000) + out = wire.AppendBytes(out, 7, step) + if completed := s.collection.StepUpProgress(stepUpGroupID); completed != 0 { + user := wire.AppendVarint(nil, 1, stepUpGroupID) + user = wire.AppendVarint(user, 2, completed) + out = wire.AppendBytes(out, 8, user) + } + for _, groupID := range []uint64{twelvePickGroupID, paidTwelvePickGroupID} { + for _, selection := range s.collection.GachaSelections(groupID) { + info := wire.AppendVarint(nil, 1, selection.GroupID) + if selection.Slot != 0 { + info = wire.AppendVarint(info, 2, selection.Slot) + } + info = wire.AppendVarint(info, 3, selection.ItemID) + out = wire.AppendBytes(out, 5, info) + } + } + for _, user := range s.collection.GachaUsers() { + info := wire.AppendVarint(nil, 1, user.GroupID) + info = wire.AppendVarint(info, 2, user.Point) + info = wire.AppendVarint(info, 3, user.TotalBuyCount) + info = wire.AppendVarint(info, 4, user.OneFreePickCount) + info = wire.AppendVarint(info, 5, user.OneCashPickCount) + info = wire.AppendVarint(info, 6, user.TenFreePickCount) + info = wire.AppendVarint(info, 7, user.TenCashPickCount) + info = wire.AppendVarint(info, 8, user.ExchangeItemCount) + info = wire.AppendVarint(info, 9, user.ExchangeMileageCount) + out = wire.AppendBytes(out, 2, info) + } + for _, fixed := range s.collection.GachaFixedStates() { + out = wire.AppendBytes(out, 4, gachaFixedWire(fixed)) + } + return out +} diff --git a/go/internal/gacha/gacha_test.go b/go/internal/gacha/gacha_test.go new file mode 100644 index 0000000..df63470 --- /dev/null +++ b/go/internal/gacha/gacha_test.go @@ -0,0 +1,710 @@ +package gacha + +import ( + "path/filepath" + "testing" + + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +func fixtureCharacter(id, hp uint64) gamedata.CharacterDesign { + return gamedata.CharacterDesign{ + ID: id, HP: hp, CostumeMaxLevel: 5, + OverflowItemType: 20, OverflowItemCount: 2, + } +} + +func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) { + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{ + 60901: fixtureCharacter(6090, 253), + }) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "collection.json") + collection, err := player.OpenCollectionStore(path, nil) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(t.TempDir(), "wallet.json"), player.Currency{}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + preview := wire.AppendVarint(nil, 1, 1) + preview = wire.AppendVarint(preview, 2, gamedata.InfiniteGachaID) + preview = wire.AppendVarint(preview, 3, gamedata.InfiniteProductGroupID) + preview = wire.AppendVarint(preview, 4, gamedata.InfiniteProductID) + code, response, ok, err := service.Handle("/GachaBuyPreview", preview) + if err != nil || !ok || code != 175 { + t.Fatalf("preview code=%d ok=%v err=%v", code, ok, err) + } + bundle, found, err := wire.Bytes(response, 1) + if err != nil || !found || countFields(bundle, 3) != 10 { + t.Fatalf("preview bundle fields=%d found=%v err=%v", countFields(bundle, 3), found, err) + } + if len(collection.Characters()) != 0 { + t.Fatal("preview changed ownership") + } + lock := wire.AppendVarint(nil, 1, 2) + lock = wire.AppendVarint(lock, 2, 30010) + code, response, ok, err = service.Handle("/GachaBuyPreviewLock", lock) + if err != nil || !ok || code != 175 || len(response) != 0 { + t.Fatalf("preview lock code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err) + } + if eventIndex, locked := collection.PreviewLock(); !locked || eventIndex != 30010 { + t.Fatalf("preview lock event=%d locked=%v", eventIndex, locked) + } + + buy := wire.AppendVarint(nil, 1, 3) + buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID) + product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID) + product = wire.AppendVarint(product, 3, 1) + buy = wire.AppendBytes(buy, 4, product) + code, response, ok, err = service.Handle("/CashShopBuy", buy) + if err != nil || !ok || code != 61 { + t.Fatalf("confirm code=%d ok=%v err=%v", code, ok, err) + } + reward, found, err := wire.Bytes(response, 1) + if err != nil || !found || countFields(reward, 2) != 1 || countFields(reward, 3) != 1 || countFields(reward, 9) != 9 || countFields(reward, 8) != 4 { + t.Fatalf("reward chars=%d costumes=%d upgrades=%d exchanges=%d found=%v err=%v", countFields(reward, 2), countFields(reward, 3), countFields(reward, 9), countFields(reward, 8), found, err) + } + restored, err := player.OpenCollectionStore(path, nil) + if err != nil { + t.Fatal(err) + } + if got := restored.Characters(); len(got) != 1 || got[0].ID != 6090 { + t.Fatalf("restored characters=%+v", got) + } + if got := restored.Costumes(); len(got) != 1 || got[0].ID != 60901 || got[0].Level != 5 { + t.Fatalf("restored costumes=%+v", got) + } + if got := wallet.Snapshot().Mileage; got != 8 { + t.Fatalf("mileage=%d want=8", got) + } + if eventIndex, locked := restored.PreviewLock(); !locked || eventIndex != 30010 { + t.Fatalf("restored preview lock event=%d locked=%v", eventIndex, locked) + } + code, response, ok, err = service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 4)) + if err != nil || !ok || code != 145 || countFields(response, 1) != 3 { + t.Fatalf("post-purchase info code=%d schedules=%d ok=%v err=%v", code, countFields(response, 1), ok, err) + } + code, response, ok, err = service.Handle("/CashShopPurchaseCountInfo", wire.AppendVarint(nil, 1, 5)) + countInfo, found, parseErr := wire.Bytes(response, 1) + count, countFound, countErr := wire.Varint(countInfo, 4) + if err != nil || parseErr != nil || countErr != nil || !ok || code != 432 || !found || !countFound || count != 1 { + t.Fatalf("purchase count code=%d count=%d found=%v/%v ok=%v err=%v/%v/%v", code, count, found, countFound, ok, err, parseErr, countErr) + } + if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err == nil { + t.Fatal("purchased infinite gacha accepted a new preview") + } +} + +func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) { + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + preview := wire.AppendVarint(nil, 1, 1) + preview = wire.AppendVarint(preview, 2, gamedata.InfiniteGachaID) + preview = wire.AppendVarint(preview, 3, gamedata.InfiniteProductGroupID) + preview = wire.AppendVarint(preview, 4, gamedata.InfiniteProductID) + if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil { + t.Fatal(err) + } + buy := wire.AppendVarint(nil, 1, 2) + buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID) + product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID) + product = wire.AppendVarint(product, 3, 1) + buy = wire.AppendBytes(buy, 4, product) + if _, _, _, err := service.Handle("/CashShopBuy", buy); err == nil { + t.Fatal("unlocked preview was confirmed") + } + lock := wire.AppendVarint(nil, 1, 3) + lock = wire.AppendVarint(lock, 2, 30010) + if _, _, _, err := service.Handle("/GachaBuyPreviewLock", lock); err != nil { + t.Fatal(err) + } + if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil { + t.Fatal(err) + } + if eventIndex, locked := collection.PreviewLock(); locked || eventIndex != 0 { + t.Fatalf("reroll kept stale lock event=%d locked=%v", eventIndex, locked) + } +} + +func TestRegularGachaBuySpendsWalletAndPersistsCostume(t *testing.T) { + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{ + 10100084: {ID: 10100084, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, + }, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{FreeJewelry: 500}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 77) + request = wire.AppendVarint(request, 2, 10100084) + request = wire.AppendVarint(request, 3, 1) + code, response, ok, err := service.Handle("/GachaBuy", request) + if err != nil || !ok || code != 146 { + t.Fatalf("code=%d ok=%v err=%v", code, ok, err) + } + if wallet.Snapshot().FreeJewelry != 300 { + t.Fatalf("wallet=%+v", wallet.Snapshot()) + } + bundle, found, _ := wire.Bytes(response, 1) + if !found || countFields(bundle, 2) != 1 || countFields(bundle, 3) != 1 { + t.Fatalf("bundle=%x", bundle) + } + if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { + t.Fatal(err) + } + if wallet.Snapshot().FreeJewelry != 300 || len(collection.Characters()) != 1 { + t.Fatalf("retry wallet=%+v chars=%d", wallet.Snapshot(), len(collection.Characters())) + } +} + +func TestRegularGachaSequenceReuseAcrossLoginIsANewPurchase(t *testing.T) { + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{ + 10100084: {ID: 10100084, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, + }, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{FreeJewelry: 400}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, 10100084) + request = wire.AppendVarint(request, 3, 1) + service.BeginSession("login-a") + if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { + t.Fatal(err) + } + // Same login + sequence is an HTTP retry. + if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { + t.Fatal(err) + } + if got := wallet.Snapshot().FreeJewelry; got != 200 { + t.Fatalf("retry balance=%d want=200", got) + } + service.BeginSession("login-b") + if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { + t.Fatal(err) + } + if got := wallet.Snapshot().FreeJewelry; got != 0 { + t.Fatalf("new-login balance=%d want=0", got) + } + if got := collection.Costumes(); len(got) != 1 || got[0].Level != 1 { + t.Fatalf("costumes=%+v", got) + } +} + +func TestPaidRegularGachaBuySpendsPaidJewelry(t *testing.T) { + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{ + 8100118: {ID: 8100118, Count: 10, PriceType: 2, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, + }, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{Jewelry: 5000}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 88) + request = wire.AppendVarint(request, 2, 8100118) + // GB_NORMAL uses GachaTable.PriceType to distinguish paid (2) from free + // jewelry (3); it is not itself the paid/free currency selector. + request = wire.AppendVarint(request, 3, 1) + code, response, ok, err := service.Handle("/GachaBuy", request) + if err != nil || !ok || code != 146 { + t.Fatalf("code=%d ok=%v err=%v", code, ok, err) + } + if wallet.Snapshot().Jewelry != 3000 || wallet.Snapshot().FreeJewelry != 0 { + t.Fatalf("wallet=%+v", wallet.Snapshot()) + } + bundle, found, _ := wire.Bytes(response, 1) + if !found || countFields(bundle, 3) != 1 || countFields(bundle, 9) != 9 || countFields(bundle, 8) != 4 { + t.Fatalf("bundle=%x", bundle) + } +} + +func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) { + character := fixtureCharacter(6090, 253) + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character}) + if err != nil { + t.Fatal(err) + } + gachas := make(map[uint64]gamedata.RegularGacha, len(stepUpGachaIDs)) + for _, id := range stepUpGachaIDs { + gachas[id] = gamedata.RegularGacha{ID: id, Count: 1, PriceType: 2, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}} + } + regular, err := gamedata.NewRegularGachaCatalog(gachas, map[uint64]gamedata.CharacterDesign{60901: character}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collectionPath := filepath.Join(dir, "collection.json") + collection, err := player.OpenCollectionStore(collectionPath, nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{Jewelry: 1000}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + service.BeginSession("first-login") + for i, id := range stepUpGachaIDs { + request := wire.AppendVarint(nil, 1, uint64(i+1)) + request = wire.AppendVarint(request, 2, id) + request = wire.AppendVarint(request, 3, 1) + if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { + t.Fatalf("step %d: %v", i+1, err) + } + } + if got := collection.StepUpProgress(stepUpGroupID); got != 4 { + t.Fatalf("progress=%d want=4", got) + } + restored, err := player.OpenCollectionStore(collectionPath, nil) + if err != nil { + t.Fatal(err) + } + restarted, err := NewService(design, regular, restored, wallet) + if err != nil { + t.Fatal(err) + } + restarted.BeginSession("second-login") + _, info, _, err := restarted.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil { + t.Fatal(err) + } + if countFields(info, 7) != 1 || countFields(info, 8) != 1 { + t.Fatalf("step-up schedule/user count=%d/%d info=%x", countFields(info, 7), countFields(info, 8), info) + } + user, found, err := wire.Bytes(info, 8) + if err != nil || !found { + t.Fatalf("step user found=%v err=%v", found, err) + } + group, _, _ := wire.Varint(user, 1) + completed, _, _ := wire.Varint(user, 2) + if group != stepUpGroupID || completed != 4 { + t.Fatalf("step user group=%d completed=%d", group, completed) + } + balance := wallet.Snapshot().Jewelry + rebuy := wire.AppendVarint(nil, 1, 2) + rebuy = wire.AppendVarint(rebuy, 2, stepUpGachaIDs[0]) + rebuy = wire.AppendVarint(rebuy, 3, 1) + if _, _, _, err := restarted.Handle("/GachaBuy", rebuy); err == nil { + t.Fatal("completed step-up accepted another purchase") + } + if got := wallet.Snapshot().Jewelry; got != balance { + t.Fatalf("rejected purchase changed wallet: %d -> %d", balance, got) + } +} + +func TestRegularTenPullContainsTenClientDisplayEntriesAtMaxLevel(t *testing.T) { + character := fixtureCharacter(6090, 253) + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character}) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{ + 11000084: {ID: 11000084, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, + }, map[uint64]gamedata.CharacterDesign{60901: character}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{FreeJewelry: 2000}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, 11000084) + request = wire.AppendVarint(request, 3, 1) + _, response, _, err := service.Handle("/GachaBuy", request) + if err != nil { + t.Fatal(err) + } + bundle, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("reward bundle found=%v err=%v", found, err) + } + // Gacha result builds a slot from CostumeInfo (field 3) or + // ItemAutoUpgradeInfo (field 9). With one new costume followed by nine + // duplicates, all ten pulls must therefore be represented by 1 + 9. + if got := countFields(bundle, 3) + countFields(bundle, 9); got != 10 { + t.Fatalf("client display entries=%d costumes=%d upgrades=%d bundle=%x", got, countFields(bundle, 3), countFields(bundle, 9), bundle) + } + if countFields(bundle, 8) != 4 { + t.Fatalf("overflow exchanges=%d want=4", countFields(bundle, 8)) + } + if got := collection.Costumes()[0].Level; got != 5 { + t.Fatalf("costume level=%d want=5", got) + } + grant, ok := collection.Grant("regular-gacha:11000084:seq:1") + if !ok || len(grant.Upgrades) != 9 || len(grant.Exchanges) != 4 { + t.Fatalf("grant=%+v", grant) + } + for _, exchange := range grant.Exchanges { + matched := false + for _, upgrade := range grant.Upgrades { + if upgrade.CostumeID == exchange.OriginalItemID && upgrade.SortID == exchange.SortID && upgrade.Before == 5 && upgrade.After == 5 { + matched = true + break + } + } + if !matched { + t.Fatalf("exchange has no display upgrade: %+v grant=%+v", exchange, grant) + } + } +} + +func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing.T) { + character := fixtureCharacter(6090, 253) + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character}) + if err != nil { + t.Fatal(err) + } + pool := []gamedata.WeightedCostume{ + {Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, + {Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, + {Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, + {Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{ + 11000145: {ID: 11000145, Count: 10, PriceType: 3, Price: 2000, Pool: pool}, + }, map[uint64]gamedata.CharacterDesign{60901: character}) + if err != nil { + t.Fatal(err) + } + if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: 205, FixedID: 1, PointCount: 1, PickUpExchangeCost: 200, PickUpCostumeID: 60901, TenTimeGachaID: 11000145}, gamedata.GachaFixedDesign{ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collectionPath := filepath.Join(dir, "collection.json") + walletPath := filepath.Join(dir, "wallet.json") + collection, err := player.OpenCollectionStore(collectionPath, nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(walletPath, player.Currency{FreeJewelry: 2000}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + service.BeginSession("point-test-login") + buy := wire.AppendVarint(nil, 1, 41) + buy = wire.AppendVarint(buy, 2, 11000145) + buy = wire.AppendVarint(buy, 3, 1) + _, buyResponse, _, err := service.Handle("/GachaBuy", buy) + if err != nil { + t.Fatal(err) + } + if points, _, _ := wire.Varint(buyResponse, 2); points != 10 { + t.Fatalf("buy points=%d want=10", points) + } + if user := collection.GachaUser(205); user.Point != 10 || user.TotalBuyCount != 10 { + t.Fatalf("gacha user=%+v", user) + } + exchange := wire.AppendVarint(nil, 1, 42) + exchange = wire.AppendVarint(exchange, 2, 205) + exchange = wire.AppendVarint(exchange, 3, 5) + for i := 0; i < 2; i++ { + code, response, ok, err := service.Handle("/GachaPointManualExchange", exchange) + if err != nil || !ok || code != 188 { + t.Fatalf("exchange %d code=%d ok=%v err=%v", i, code, ok, err) + } + bundle, found, err := wire.Bytes(response, 1) + if err != nil || !found || countFields(bundle, 10) != 1 { + t.Fatalf("exchange reward bundle=%x found=%v err=%v", bundle, found, err) + } + view, _, _ := wire.Bytes(bundle, 10) + itemType, _, _ := wire.Varint(view, 2) + count, _, _ := wire.Varint(view, 3) + if itemType != 22 || count != 5 { + t.Fatalf("hope powder view type=%d count=%d", itemType, count) + } + } + if user := collection.GachaUser(205); user.Point != 5 || user.ExchangeMileageCount != 5 { + t.Fatalf("point exchange user=%+v", user) + } + if got := wallet.Snapshot().HopePowder; got != 5 { + t.Fatalf("hope powder=%d want=5", got) + } + restored, err := player.OpenCollectionStore(collectionPath, nil) + if err != nil { + t.Fatal(err) + } + if user := restored.GachaUser(205); user.Point != 5 || user.ExchangeMileageCount != 5 { + t.Fatalf("restored gacha user=%+v", user) + } + if fixed := restored.GachaFixedStates(); len(fixed) != 2 || fixed[0].ApplySort != -1 || fixed[1].ApplySort != -1 { + t.Fatalf("restored guaranteed counters=%+v", fixed) + } + restarted, err := NewService(design, regular, restored, wallet) + if err != nil { + t.Fatal(err) + } + restarted.BeginSession("new-login") + _, info, _, err := restarted.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 43)) + if err != nil || countFields(info, 2) != 1 || countFields(info, 4) != 2 { + t.Fatalf("gacha info users=%d fixed=%d err=%v", countFields(info, 2), countFields(info, 4), err) + } +} + +func TestCashShopPurchaseCountStartsEmpty(t *testing.T) { + design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 99) + code, response, ok, err := service.Handle("/CashShopPurchaseCountInfo", request) + if err != nil || !ok || code != 432 || len(response) != 0 { + t.Fatalf("code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err) + } +} + +func TestTwelvePickSelectionSavePersistsAndReturnsInGachaInfo(t *testing.T) { + fiveStars := make([]uint64, 12) + characters := make(map[uint64]gamedata.CharacterDesign, 14) + for i := range fiveStars { + fiveStars[i] = uint64(5001 + i) + characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100) + } + characters[4001] = fixtureCharacter(400, 100) + characters[3001] = fixtureCharacter(300, 100) + design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}}}, characters) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collectionPath := filepath.Join(dir, "collection.json") + collection, err := player.OpenCollectionStore(collectionPath, nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 100) + for slot, itemID := range fiveStars { + entry := wire.AppendVarint(nil, 1, paidTwelvePickGroupID) + if slot != 0 { + entry = wire.AppendVarint(entry, 2, uint64(slot)) + } + entry = wire.AppendVarint(entry, 3, itemID) + request = wire.AppendBytes(request, 2, entry) + } + code, response, ok, err := service.Handle("/GachaSelectionSave", request) + if err != nil || !ok || code != 198 || len(response) != 0 { + t.Fatalf("save code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err) + } + restored, err := player.OpenCollectionStore(collectionPath, nil) + if err != nil { + t.Fatal(err) + } + if got := restored.GachaSelections(paidTwelvePickGroupID); len(got) != 12 || got[0].ItemID != fiveStars[0] || got[11].Slot != 11 { + t.Fatalf("restored selections=%+v", got) + } + code, response, ok, err = service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 101)) + if err != nil || !ok || code != 145 || countFields(response, 5) != 12 { + t.Fatalf("info code=%d selections=%d ok=%v err=%v", code, countFields(response, 5), ok, err) + } +} + +func TestOrdinaryTwelvePickGacha101UsesSavedSelectionGroupAndPersists(t *testing.T) { + fiveStars := make([]uint64, 12) + characters := make(map[uint64]gamedata.CharacterDesign, 14) + for i := range fiveStars { + fiveStars[i] = uint64(5001 + i) + characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100) + } + characters[4001] = fixtureCharacter(400, 100) + characters[3001] = fixtureCharacter(300, 100) + design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters) + if err != nil { + t.Fatal(err) + } + pool := []gamedata.WeightedCostume{ + {Weight: 300, Children: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}}, + {Weight: 1400, Children: []gamedata.WeightedCostume{{ID: 4001, Weight: 1}}}, + {Weight: 8300, Children: []gamedata.WeightedCostume{{ID: 3001, Weight: 1}}}, + } + regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{ + 101: {ID: 101, Count: 10, PriceType: 3, Price: 2000, Pool: pool, TicketIDs: []uint64{1108, 1000}}, + }, characters) + if err != nil { + t.Fatal(err) + } + if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: twelvePickGroupID, FixedID: 8, PointCount: 1, TenTimeGachaID: 101, SelectCount: 12, SelectionChoiceRate: 100, GachaSubType: 1, IsSelectedFromPity: true}, gamedata.GachaFixedDesign{ID: 8, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{FreeJewelry: 2000}) + if err != nil { + t.Fatal(err) + } + service, err := NewService(design, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + service.BeginSession("ordinary-12pick") + save := wire.AppendVarint(nil, 1, 1) + for slot, itemID := range fiveStars { + choice := wire.AppendVarint(nil, 1, twelvePickGroupID) + if slot != 0 { + choice = wire.AppendVarint(choice, 2, uint64(slot)) + } + choice = wire.AppendVarint(choice, 3, itemID) + save = wire.AppendBytes(save, 2, choice) + } + if _, _, _, err := service.Handle("/GachaSelectionSave", save); err != nil { + t.Fatal(err) + } + buy := wire.AppendVarint(nil, 1, 2) + buy = wire.AppendVarint(buy, 2, 101) + buy = wire.AppendVarint(buy, 3, 1) + code, response, ok, err := service.Handle("/GachaBuy", buy) + if err != nil || !ok || code != 146 { + t.Fatalf("buy code=%d ok=%v err=%v", code, ok, err) + } + bundle, found, err := wire.Bytes(response, 1) + if err != nil || !found || countFields(bundle, 3)+countFields(bundle, 9) != 10 { + t.Fatalf("bundle=%x found=%v err=%v", bundle, found, err) + } + if user := collection.GachaUser(twelvePickGroupID); user.Point != 10 || user.TotalBuyCount != 10 { + t.Fatalf("user=%+v", user) + } + fixed := collection.GachaFixedStates() + if len(fixed) != 2 || fixed[0].FixedID != 8 || fixed[1].FixedID != 8 { + t.Fatalf("fixed=%+v", fixed) + } + if wallet.Snapshot().FreeJewelry != 0 { + t.Fatalf("wallet=%+v", wallet.Snapshot()) + } +} + +func countFields(data []byte, number int) int { + count := 0 + _ = wire.Walk(data, func(field wire.Field) error { + if field.Number == number { + count++ + } + return nil + }) + return count +} diff --git a/go/internal/gamedata/attributes.go b/go/internal/gamedata/attributes.go new file mode 100644 index 0000000..90e25f3 --- /dev/null +++ b/go/internal/gamedata/attributes.go @@ -0,0 +1,65 @@ +package gamedata + +import "math" + +// Stat is the small, protocol-independent subset of character statistics +// needed by the local server. The client uses the same two kinds of values +// for every stat: a flat contribution and a percentage contribution. +type Stat int + +const ( + StatHealth Stat = iota + 1 + StatAttack + StatMagic + StatDefencePercent + StatMagicResistancePercent +) + +// StatContribution is one already-resolved contribution from an equipment +// option, costume, collection, awakening, or another account system. Percent +// is expressed as a fraction (0.08 means 8%), matching the client formula. +type StatContribution struct { + Stat Stat + Flat float64 + Percent float64 +} + +// BaseStats contains the design value after the character level curve has +// been applied. It is deliberately separate from CharacterStats so callers +// cannot accidentally persist a derived value as design data. +type BaseStats struct { + Health float64 + Attack float64 + Magic float64 +} + +// AggregateStats applies the final client-side aggregation order: +// +// final = base + round(((base + flat) * (1 + percent)) - base) +// +// The game truncates the design value before applying account contributions; +// the final bonus is truncated toward zero as well. Keeping this operation +// in one place prevents revival, CharInfo, and battle code from developing +// subtly different HP calculations. +func AggregateStats(base BaseStats, contributions []StatContribution) BaseStats { + flat := map[Stat]float64{} + percent := map[Stat]float64{} + for _, contribution := range contributions { + if contribution.Stat == 0 { + continue + } + flat[contribution.Stat] += contribution.Flat + percent[contribution.Stat] += contribution.Percent + } + return BaseStats{ + Health: aggregateOne(base.Health, flat[StatHealth], percent[StatHealth]), + Attack: aggregateOne(base.Attack, flat[StatAttack], percent[StatAttack]), + Magic: aggregateOne(base.Magic, flat[StatMagic], percent[StatMagic]), + } +} + +func aggregateOne(base, flat, percent float64) float64 { + base = math.Trunc(base) + bonus := ((base + flat) * (1 + percent)) - base + return base + math.Trunc(bonus) +} diff --git a/go/internal/gamedata/attributes_test.go b/go/internal/gamedata/attributes_test.go new file mode 100644 index 0000000..4e46fc9 --- /dev/null +++ b/go/internal/gamedata/attributes_test.go @@ -0,0 +1,62 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestAggregateStatsAddsFlatHealth(t *testing.T) { + got := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Flat: 7}}) + if got.Health != 512 { + t.Fatalf("health=%v, want 512", got.Health) + } +} + +func TestRealTutorialStatsDoNotMisclassifyAttackAsHealth(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + base, err := CharacterBaseStats(root, "20260910162539", 350, 20) + if err != nil { + t.Fatal(err) + } + if base.Health != 505 { + t.Fatalf("base health=%v, want 505", base.Health) + } + option, err := EquipmentOptionContribution(root, "20260910162539", EquipmentOption{GroupID: 1010010, ID: 3}) + if err != nil { + t.Fatal(err) + } + if option.Stat != StatAttack || option.Flat != 7 { + t.Fatalf("option=%+v", option) + } + if got := AggregateStats(base, []StatContribution{option}); got.Health != 505 { + t.Fatalf("attack equipment changed health: %+v", got) + } +} + +func TestAggregateStatsAppliesPercentAfterFlat(t *testing.T) { + got := AggregateStats(BaseStats{Health: 100}, []StatContribution{{Stat: StatHealth, Flat: 7, Percent: 0.1}}) + if got.Health != 117 { + t.Fatalf("health=%v, want 117", got.Health) + } +} + +func TestAggregateStatsMatchesOfficialGrowthSnapshot(t *testing.T) { + // Before tutorial equipment 10010 entered the pictorial book, the real + // AllCharRefresh response advertised HEALTH_PERCENT=0.015. The subsequent + // official CharGrowth response returned HP=512 for level-20 char 350. + got := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Percent: 0.015}}) + if got.Health != 512 { + t.Fatalf("health=%v, want official snapshot 512", got.Health) + } + + // Acquiring the tutorial equipment raised that account-wide pictorial + // bonus to 0.0175. It displays as +8 HP, independently of the equipment's + // own option 3, which is ATTACK_VALUE +7. + afterPictorial := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Percent: 0.0175}}) + if afterPictorial.Health != 513 { + t.Fatalf("post-pictorial health=%v, want 513", afterPictorial.Health) + } +} diff --git a/go/internal/gamedata/battle.go b/go/internal/gamedata/battle.go new file mode 100644 index 0000000..0f83501 --- /dev/null +++ b/go/internal/gamedata/battle.go @@ -0,0 +1,119 @@ +package gamedata + +import ( + "database/sql" + "encoding/binary" + "fmt" + "os" + "path/filepath" + + "bd2server/internal/wire" + _ "modernc.org/sqlite" +) + +// BattleReward is a static victory reward from BattleDeckTable in a pack DB. +// It is a design definition, not a player's owned item or server-generated ID. +type BattleReward struct { + Type uint64 + ID uint64 + Count uint64 +} + +// BattleDeckRewards uses the enemy battle deck actually chosen by the client +// (BattleEnter field 4); a monster may have several different battle decks. +func BattleDeckRewards(root, version string, packID int, deckID uint64) ([]BattleReward, error) { + if packID <= 0 || deckID == 0 || deckID > uint64(^uint(0)>>1) { + return nil, fmt.Errorf("gamedata: invalid battle pack/deck %d/%d", packID, deckID) + } + return readBattleRewards(root, version, packID, int(deckID), 0) +} + +// BattleRewards is a legacy convenience for a monster with exactly one deck. +// Call BattleDeckRewards for a real battle, where the chosen deck is known. +func BattleRewards(root, version string, packID, monsterID int) ([]BattleReward, error) { + if packID <= 0 || monsterID <= 0 { + return nil, fmt.Errorf("gamedata: invalid battle pack/monster %d/%d", packID, monsterID) + } + return readBattleRewards(root, version, packID, 0, monsterID) +} + +func readBattleRewards(root, version string, packID, deckID, monsterID int) ([]BattleReward, error) { + plain, err := ReadDatabase(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() + if monsterID != 0 { + var monster []byte + if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&monster); err != nil { + return nil, fmt.Errorf("gamedata: monster %d: %w", monsterID, err) + } + deckIDs, err := packedInts(monster, 2) + if err != nil || len(deckIDs) != 1 || deckIDs[0] == 0 || deckIDs[0] > uint64(^uint(0)>>1) { + return nil, fmt.Errorf("gamedata: monster %d requires a selected battle deck (found %v): %w", monsterID, deckIDs, err) + } + deckID = int(deckIDs[0]) + } + var design []byte + if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deckID).Scan(&design); err != nil { + return nil, fmt.Errorf("gamedata: battle deck %d: %w", deckID, err) + } + types, err := packedInts(design, 36) + if err != nil { + return nil, err + } + ids, err := packedInts(design, 34) + if err != nil { + return nil, err + } + counts, err := packedInts(design, 33) + if err != nil { + return nil, err + } + if len(types) != len(ids) || len(ids) != len(counts) { + return nil, fmt.Errorf("gamedata: battle deck %d reward arrays differ in length", deckID) + } + rewards := make([]BattleReward, len(types)) + for i := range rewards { + if types[i] == 0 || ids[i] == 0 || counts[i] == 0 { + return nil, fmt.Errorf("gamedata: invalid battle deck %d reward", deckID) + } + rewards[i] = BattleReward{Type: types[i], ID: ids[i], Count: counts[i]} + } + return rewards, nil +} + +func packedInts(proto []byte, number int) ([]uint64, error) { + var out []uint64 + err := wire.Walk(proto, func(f wire.Field) error { + if f.Number != number { + return nil + } + if f.Type != 0 && f.Type != 2 { + return fmt.Errorf("gamedata: field %d has type %d", number, f.Type) + } + for data := f.Value; len(data) > 0; { + value, n := binary.Uvarint(data) + if n <= 0 { + return fmt.Errorf("gamedata: invalid packed field %d", number) + } + out = append(out, value) + data = data[n:] + } + return nil + }) + return out, err +} diff --git a/go/internal/gamedata/battle_test.go b/go/internal/gamedata/battle_test.go new file mode 100644 index 0000000..7149e65 --- /dev/null +++ b/go/internal/gamedata/battle_test.go @@ -0,0 +1,51 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestInstalledPack21FirstMonsterRewards(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + rewards, err := BattleRewards(root, "20260910162539", 21, 1) + if err != nil { + t.Fatal(err) + } + if len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 8, Count: 3}) { + t.Fatalf("pack21 monster1 rewards = %+v, want slime type8/item8 x3", rewards) + } +} + +func TestInstalledTutorialGrowthReachesLevel20(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + level, exp, refunds, err := CharacterGrowth(root, "20260910162539", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 3}}) + if err != nil { + t.Fatal(err) + } + if level != 20 || exp != 0 { + t.Fatalf("char350 + resource8x3 = level %d exp %d, want level20 exp0", level, exp) + } + if len(refunds) != 2 || refunds[0] != (GrowthMaterial{ID: 8, Count: 1}) || refunds[1] != (GrowthMaterial{ID: 7, Count: 3}) { + t.Fatalf("refunds=%v, want resource8x1 + resource7x3", refunds) + } +} + +func TestInstalledTutorialGrowthClientSelectionRefund(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + level, exp, refunds, err := CharacterGrowth(root, "20260910162539", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 2}}) + if err != nil { + t.Fatal(err) + } + if level != 20 || exp != 0 || len(refunds) != 1 || refunds[0] != (GrowthMaterial{ID: 7, Count: 3}) { + t.Fatalf("growth = level%d exp%d refunds%v, want official resource7x3", level, exp, refunds) + } +} diff --git a/go/internal/gamedata/database.go b/go/internal/gamedata/database.go new file mode 100644 index 0000000..01d90a9 --- /dev/null +++ b/go/internal/gamedata/database.go @@ -0,0 +1,79 @@ +package gamedata + +import ( + "archive/zip" + "bytes" + "crypto/sha1" + "fmt" + "io" + "path/filepath" + "strings" + + "bd2server/internal/dbcrypt" +) + +// DatabaseName maps a logical client DB name to its GameData archive entry. +// Version 1 is the current 2.34.13 DB schema generation. +func DatabaseName(logical string) (string, error) { + if logical == "" || strings.ContainsAny(logical, `/\\.`) { + return "", fmt.Errorf("gamedata: invalid logical database name %q", logical) + } + digest := sha1.Sum([]byte(logical + "_v1")) + return fmt.Sprintf("%X", digest), nil +} + +// ReadDatabase extracts and decrypts one SQLite database from the validated +// common-dbdata.bin. The returned bytes are an ordinary SQLite file. +func ReadDatabase(root, version, logical string) ([]byte, error) { + name, err := DatabaseName(logical) + if err != nil { + return nil, err + } + return readEntry(root, version, name, logical) +} + +// questDatabaseEntry is the member in the 2.34.13 GameData archive that +// contains the shared QuestTable* SQLite database, including QuestTable21. +// Quest data is not stored in a per-map "pack21" database. +const questDatabaseEntry = "9F251C63BC72551C681EE75D328FA090D56E444B" + +// ReadQuestDatabase extracts the shared quest database. The member name is +// opaque in the client archive, so callers must not derive it from a map ID. +func ReadQuestDatabase(root, version string) ([]byte, error) { + return readEntry(root, version, questDatabaseEntry, "quest tables") +} + +func readEntry(root, version, name, label string) ([]byte, error) { + archivePath := filepath.Join(root, version, "release", ArchiveName) + reader, err := zip.OpenReader(archivePath) + if err != nil { + return nil, fmt.Errorf("gamedata: open database archive: %w", err) + } + defer reader.Close() + for _, entry := range reader.File { + if !strings.EqualFold(entry.Name, name) { + continue + } + stream, err := entry.Open() + if err != nil { + return nil, fmt.Errorf("gamedata: open database %s: %w", label, err) + } + encrypted, readErr := io.ReadAll(stream) + closeErr := stream.Close() + if readErr != nil { + return nil, fmt.Errorf("gamedata: read database %s: %w", label, readErr) + } + if closeErr != nil { + return nil, fmt.Errorf("gamedata: close database %s: %w", label, closeErr) + } + plain, err := dbcrypt.DecryptPages(encrypted) + if err != nil { + return nil, fmt.Errorf("gamedata: decrypt database %s: %w", label, err) + } + if !bytes.HasPrefix(plain, dbcrypt.Header) { + return nil, fmt.Errorf("gamedata: database %s has no SQLite header after decryption", label) + } + return plain, nil + } + return nil, fmt.Errorf("gamedata: database %s entry %s not found", label, name) +} diff --git a/go/internal/gamedata/database_test.go b/go/internal/gamedata/database_test.go new file mode 100644 index 0000000..94b8de6 --- /dev/null +++ b/go/internal/gamedata/database_test.go @@ -0,0 +1,90 @@ +package gamedata + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "testing" + + "bd2server/internal/dbcrypt" +) + +// These tests cover the production archive/decryption contract. Interactive +// queries against the installed 178 MB GameData belong in tools/gamedata_db.py +// so ordinary Go tests never double as an ad-hoc database console. +func TestReadDatabase(t *testing.T) { + root := t.TempDir() + release := filepath.Join(root, "123", "release") + if err := os.MkdirAll(release, 0o755); err != nil { + t.Fatal(err) + } + plain := make([]byte, dbcrypt.PageSize) + copy(plain, dbcrypt.Header) + copy(plain[100:], []byte("CREATE TABLE QuestTable21")) + encrypted, err := dbcrypt.EncryptPages(plain) + if err != nil { + t.Fatal(err) + } + name, err := DatabaseName("pack21") + if err != nil { + t.Fatal(err) + } + writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), name, encrypted) + got, err := ReadDatabase(root, "123", "pack21") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plain) { + t.Fatal("decrypted database mismatch") + } +} + +func TestReadQuestDatabase(t *testing.T) { + root := t.TempDir() + release := filepath.Join(root, "123", "release") + if err := os.MkdirAll(release, 0o755); err != nil { + t.Fatal(err) + } + plain := make([]byte, dbcrypt.PageSize) + copy(plain, dbcrypt.Header) + copy(plain[100:], []byte("CREATE TABLE QuestTable21")) + encrypted, err := dbcrypt.EncryptPages(plain) + if err != nil { + t.Fatal(err) + } + writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), questDatabaseEntry, encrypted) + got, err := ReadQuestDatabase(root, "123") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plain) { + t.Fatal("decrypted quest database mismatch") + } +} + +func writeDatabaseTestArchive(t *testing.T, path, member string, content []byte) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(file) + entry, err := archive.Create(member) + if err != nil { + file.Close() + t.Fatal(err) + } + if _, err := entry.Write(content); err != nil { + archive.Close() + file.Close() + t.Fatal(err) + } + if err := archive.Close(); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/go/internal/gamedata/equipment_gacha.go b/go/internal/gamedata/equipment_gacha.go new file mode 100644 index 0000000..a9bd089 --- /dev/null +++ b/go/internal/gamedata/equipment_gacha.go @@ -0,0 +1,444 @@ +package gamedata + +import ( + "crypto/rand" + "database/sql" + "errors" + "fmt" + "math" + "math/big" + "os" + "path/filepath" +) + +// EquipmentGachaCatalog is the server-owned portion of the active exclusive +// equipment draws. It deliberately models the three groups which the +// 2026-09-20 GameData actually attaches to current pickup banners; it does +// not infer a schedule or an equipment 12PICK banner from historical tables. +type EquipmentGachaCatalog struct { + Gachas map[uint64]EquipmentGacha + groups map[uint64]EquipmentGachaGroup + byGacha map[uint64]uint64 + fixed EquipmentFixedDesign + equipment map[uint64]EquipmentDesign +} +type EquipmentGacha struct { + ID uint64 + Count int + PriceType, Price uint64 + TicketIDs []uint64 + Pool []WeightedEquipment +} +type EquipmentGachaGroup struct{ ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID uint64 } +type EquipmentFixedDesign struct { + ID, SRCount, URCount uint64 + Reset bool +} +type WeightedEquipment struct { + ID, Weight uint64 + Children []WeightedEquipment +} +type EquipmentDesign struct { + ID uint64 + Main, Sub []OptionGroup + Private []OptionGroup + RankGroupID uint64 +} +type OptionGroup struct { + ID uint64 + Choices []WeightedOption +} +type WeightedOption struct{ ID, Weight uint64 } +type EquipmentOptionChoice struct{ GroupID, ID uint64 } + +func LoadEquipmentGacha(root, version string) (*EquipmentGachaCatalog, error) { + plain, err := ReadQuestDatabase(root, version) + 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() + c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{}, groups: map[uint64]EquipmentGachaGroup{}, byGacha: map[uint64]uint64{}, equipment: map[uint64]EquipmentDesign{}} + // Group 10002 is the permanent equipment draw actually submitted by the + // 2.34.13 client as GachaTable 200/201. Groups 9/133/206 are current + // pickup definitions retained for their own IDs. + for _, groupID := range []uint64{10002, 9, 133, 206} { + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM GachaGroupTable WHERE id=?", groupID).Scan(&raw); err != nil { + return nil, err + } + one, _ := packedInts(raw, 24) + ten, _ := packedInts(raw, 33) + fixed, _ := packedInts(raw, 10) + points, _ := packedInts(raw, 27) + if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] != 1 || len(points) != 1 || points[0] != 1 { + return nil, fmt.Errorf("gamedata: equipment group %d malformed", groupID) + } + // The absence of SelectCount/GachaSubType is deliberate evidence that + // this live group is not an equipment 12PICK configuration. + for _, field := range []int{16, 29, 31, 36} { + if v, _ := packedInts(raw, field); len(v) != 0 { + return nil, fmt.Errorf("gamedata: equipment group %d unexpectedly has selection field %d=%v", groupID, field, v) + } + } + group := EquipmentGachaGroup{ID: groupID, FixedID: fixed[0], PointCount: points[0], OneTimeGachaID: one[0], TenTimeGachaID: ten[0]} + c.groups[groupID] = group + for _, id := range []uint64{one[0], ten[0]} { + g, err := loadEquipmentGacha(db, id) + if err != nil { + return nil, err + } + c.Gachas[id] = g + c.byGacha[id] = groupID + for _, item := range g.Pool { + if err := c.loadEquipmentTree(db, item); err != nil { + return nil, err + } + } + } + } + var fixed []byte + if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=1").Scan(&fixed); err != nil { + return nil, err + } + sr, _ := packedInts(fixed, 1) + ur, _ := packedInts(fixed, 3) + reset, _ := packedInts(fixed, 6) + if len(sr) != 1 || sr[0] != 10 || len(ur) != 1 || ur[0] != 100 || len(reset) != 1 || reset[0] != 1 { + return nil, fmt.Errorf("gamedata: malformed equipment fixed table") + } + c.fixed = EquipmentFixedDesign{ID: 1, SRCount: sr[0], URCount: ur[0], Reset: true} + return c, nil +} + +func loadEquipmentGacha(db *sql.DB, id uint64) (EquipmentGacha, error) { + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", id).Scan(&raw); err != nil { + return EquipmentGacha{}, err + } + count, _ := packedInts(raw, 5) + reward, _ := packedInts(raw, 7) + price, _ := packedInts(raw, 10) + kind, _ := packedInts(raw, 12) + tickets, _ := packedInts(raw, 8) + if len(count) != 1 || len(reward) != 1 || len(price) != 1 || len(kind) != 1 || kind[0] != 3 || price[0] != uint64(count[0])*200 { + return EquipmentGacha{}, fmt.Errorf("gamedata: malformed equipment gacha %d", id) + } + pool, err := loadEquipmentRewardPool(db, reward[0]) + if err != nil { + return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d: %w", id, err) + } + var want []uint64 + switch len(pool) { + case 8: // permanent draw 200/201: one 3% five-star UR branch. + want = []uint64{300, 200, 250, 850, 1700, 400, 1600, 4700} + case 9: // pickup groups: UP and non-UP five-star UR branches. + want = []uint64{150, 150, 200, 250, 850, 1700, 400, 1600, 4700} + default: + return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d has %d branches", id, len(pool)) + } + for i := range pool { + if pool[i].Weight != want[i] { + return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d branch %d rate=%d", id, i, pool[i].Weight) + } + } + return EquipmentGacha{ID: id, Count: int(count[0]), PriceType: kind[0], Price: price[0], TicketIDs: append([]uint64(nil), tickets...), Pool: pool}, nil +} +func loadEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, error) { + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&raw); err != nil { + return nil, err + } + ids, _ := packedInts(raw, 5) + types, _ := packedInts(raw, 6) + weights, _ := packedInts(raw, 8) + if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(weights) { + return nil, errors.New("malformed equipment reward group") + } + out := make([]WeightedEquipment, 0, len(ids)) + for i, id := range ids { + v := WeightedEquipment{Weight: weights[i]} + switch types[i] { + case 10: + v.ID = id + case 9: + children, err := loadEquipmentRewardPool(db, id) + if err != nil { + return nil, err + } + v.Children = children + default: + return nil, fmt.Errorf("unsupported equipment reward type %d", types[i]) + } + out = append(out, v) + } + return out, nil +} +func (c *EquipmentGachaCatalog) loadEquipmentTree(db *sql.DB, item WeightedEquipment) error { + if item.ID == 0 { + for _, child := range item.Children { + if err := c.loadEquipmentTree(db, child); err != nil { + return err + } + } + return nil + } + if _, ok := c.equipment[item.ID]; ok { + return nil + } + d, err := loadEquipmentDesign(db, item.ID) + if err != nil { + return err + } + c.equipment[item.ID] = d + return nil +} +func loadEquipmentDesign(db *sql.DB, id uint64) (EquipmentDesign, error) { + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentTable WHERE id=?", id).Scan(&raw); err != nil { + return EquipmentDesign{}, err + } + main, _ := packedInts(raw, 12) + private, _ := packedInts(raw, 17) + sub, _ := packedInts(raw, 21) + rank, _ := packedInts(raw, 19) + d := EquipmentDesign{ID: id} + var err error + if d.Main, err = loadOptionGroups(db, main); err != nil { + return d, err + } + if d.Sub, err = loadOptionGroups(db, sub); err != nil { + return d, err + } + if d.Private, err = loadOptionGroups(db, private); err != nil { + return d, err + } + if len(rank) == 1 { + d.RankGroupID = rank[0] + } else if len(rank) != 0 { + return d, fmt.Errorf("gamedata: equipment %d malformed rank group", id) + } + return d, nil +} +func loadOptionGroups(db *sql.DB, ids []uint64) ([]OptionGroup, error) { + out := make([]OptionGroup, 0, len(ids)) + for _, groupID := range ids { + rows, err := db.Query("SELECT ProtoBuf FROM EquipmentOptionTable WHERE GroupId=? ORDER BY id", groupID) + if err != nil { + return nil, err + } + g := OptionGroup{ID: groupID} + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + rows.Close() + return nil, err + } + id, _ := packedInts(raw, 5) + weight, _ := packedInts(raw, 2) + if len(id) != 1 || len(weight) != 1 || weight[0] == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: malformed equipment option group %d", groupID) + } + g.Choices = append(g.Choices, WeightedOption{ID: id[0], Weight: weight[0]}) + } + if err := rows.Close(); err != nil { + return nil, err + } + if len(g.Choices) == 0 { + return nil, fmt.Errorf("gamedata: equipment option group %d empty", groupID) + } + out = append(out, g) + } + return out, nil +} +func (c *EquipmentGachaCatalog) Gacha(id uint64) (EquipmentGacha, bool) { + g, ok := c.Gachas[id] + return g, ok +} +func (c *EquipmentGachaCatalog) GroupForGacha(id uint64) (EquipmentGachaGroup, bool) { + group, ok := c.groups[c.byGacha[id]] + return group, ok +} +func (c *EquipmentGachaCatalog) Fixed() EquipmentFixedDesign { return c.fixed } +func (c *EquipmentGachaCatalog) RollOptions(id uint64) (main, sub []EquipmentOptionChoice, private *EquipmentOptionChoice, err error) { + d, ok := c.equipment[id] + if !ok { + return nil, nil, nil, fmt.Errorf("gamedata: equipment %d absent from active gacha", id) + } + pick := func(groups []OptionGroup) ([]EquipmentOptionChoice, error) { + out := make([]EquipmentOptionChoice, 0, len(groups)) + for _, g := range groups { + options := make([]WeightedEquipment, 0, len(g.Choices)) + for _, choice := range g.Choices { + options = append(options, WeightedEquipment{ID: choice.ID, Weight: choice.Weight}) + } + picked, e := rollEquipmentChoiceWith(options, cryptoDraw) + if e != nil { + return nil, e + } + out = append(out, EquipmentOptionChoice{GroupID: g.ID, ID: picked}) + } + return out, nil + } + if main, err = pick(d.Main); err != nil { + return + } + if sub, err = pick(d.Sub); err != nil { + return + } + choices, err := pick(d.Private) + if err != nil { + return nil, nil, nil, err + } + if len(choices) > 1 { + return nil, nil, nil, fmt.Errorf("gamedata: equipment %d has %d private option groups", id, len(choices)) + } + if len(choices) == 1 { + private = &choices[0] + } + return +} +func (g EquipmentGacha) Roll(previousSR, previousUR uint64, fixed EquipmentFixedDesign) ([]uint64, EquipmentRoll, error) { + return g.rollWith(previousSR, previousUR, fixed, cryptoDraw) +} + +type EquipmentRoll struct { + SRCount, URCount uint64 + SRSort, URSort int +} + +func (g EquipmentGacha) rollWith(sr, ur uint64, fixed EquipmentFixedDesign, draw func(uint64) (uint64, error)) ([]uint64, EquipmentRoll, error) { + state := EquipmentRoll{SRCount: sr, URCount: ur, SRSort: -1, URSort: -1} + if g.Count <= 0 || (len(g.Pool) != 8 && len(g.Pool) != 9) { + return nil, state, errors.New("gamedata: invalid equipment gacha") + } + out := make([]uint64, g.Count) + for i := range out { + forceUR := state.URCount+1 >= fixed.URCount + forceSR := !forceUR && state.SRCount+1 >= fixed.SRCount + var id uint64 + var err error + switch { + case forceUR: + id, err = rollEquipmentChoiceWith(equipmentGuaranteedPool(g.Pool, true), draw) + state.URSort = i + case forceSR: + id, err = rollEquipmentChoiceWith(equipmentGuaranteedPool(g.Pool, false), draw) + state.SRSort = i + default: + id, err = rollEquipmentChoiceWith(g.Pool, draw) + } + if err != nil { + return nil, state, err + } + out[i] = id + tier := equipmentTier(g.Pool, id) + switch tier { + case 2: + state.SRCount = 0 + state.URCount = 0 + case 1: + state.SRCount = 0 + state.URCount++ + default: + state.SRCount++ + state.URCount++ + } + } + return out, state, nil +} + +func equipmentGuaranteedPool(pool []WeightedEquipment, ur bool) []WeightedEquipment { + if len(pool) == 8 { + if ur { + return []WeightedEquipment{{Weight: 15, Children: pool[0:1]}, {Weight: 35, Children: pool[2:3]}, {Weight: 50, Children: pool[5:6]}} + } + return []WeightedEquipment{{Weight: 15, Children: pool[1:2]}, {Weight: 35, Children: pool[3:4]}, {Weight: 50, Children: pool[6:7]}} + } + if ur { + return []WeightedEquipment{{Weight: 15, Children: pool[0:2]}, {Weight: 35, Children: pool[3:4]}, {Weight: 50, Children: pool[6:7]}} + } + return []WeightedEquipment{{Weight: 15, Children: pool[2:3]}, {Weight: 35, Children: pool[4:5]}, {Weight: 50, Children: pool[7:8]}} +} +func equipmentTier(pool []WeightedEquipment, id uint64) int { + for i, item := range pool { + if containsEquipment(item, id) { + if len(pool) == 8 { + switch i { + case 0, 2, 5: + return 2 + case 1, 3, 6: + return 1 + default: + return 0 + } + } + switch i { + case 0, 1, 3, 6: + return 2 + case 2, 4, 7: + return 1 + default: + return 0 + } + } + } + return -1 +} +func containsEquipment(item WeightedEquipment, id uint64) bool { + if item.ID != 0 { + return item.ID == id + } + for _, child := range item.Children { + if containsEquipment(child, id) { + return true + } + } + return false +} +func rollEquipmentChoiceWith(pool []WeightedEquipment, draw func(uint64) (uint64, error)) (uint64, error) { + var total uint64 + for _, item := range pool { + if item.Weight == 0 || math.MaxUint64-total < item.Weight { + return 0, errors.New("gamedata: invalid equipment weight") + } + total += item.Weight + } + value, err := draw(total) + if err != nil { + return 0, err + } + if value >= total { + return 0, errors.New("gamedata: random source out of range") + } + for _, item := range pool { + if value < item.Weight { + if item.ID != 0 { + return item.ID, nil + } + return rollEquipmentChoiceWith(item.Children, draw) + } + value -= item.Weight + } + return 0, errors.New("gamedata: equipment selection failed") +} +func cryptoDraw(limit uint64) (uint64, error) { + n, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit)) + if err != nil { + return 0, err + } + return n.Uint64(), nil +} diff --git a/go/internal/gamedata/gacha.go b/go/internal/gamedata/gacha.go new file mode 100644 index 0000000..6859a0f --- /dev/null +++ b/go/internal/gamedata/gacha.go @@ -0,0 +1,1094 @@ +package gamedata + +import ( + "crypto/rand" + "database/sql" + "encoding/binary" + "errors" + "fmt" + "math" + "math/big" + "os" + "path/filepath" + "sort" + + _ "modernc.org/sqlite" + + "bd2server/internal/wire" +) + +const ( + InfiniteGachaID = 9100033 + InfiniteProductGroupID = 1100001 + InfiniteProductID = 9100033 + officialRateScale = 10000 + officialFiveStarRate = 300 + officialFourStarRate = 1400 +) + +// InfiniteGachaDesign is the non-account portion of the 2.34.13 paid +// "infinite reroll" product. The local server deliberately makes final +// confirmation free, but still reads the official result pool from GameData. +type InfiniteGachaDesign struct { + Count int + CostumeIDs []uint64 // five-star compatibility view + FiveStarIDs []uint64 + FourStarIDs []uint64 + ThreeStarIDs []uint64 + characters map[uint64]CharacterDesign +} + +type CharacterDesign struct { + ID uint64 + HP uint64 + CostumeMaxLevel uint64 + OverflowItemType uint64 + OverflowItemID uint64 + OverflowItemCount uint64 +} + +type WeightedCostume struct { + ID uint64 + Weight uint64 + Children []WeightedCostume +} + +type RegularGacha struct { + ID uint64 + Count int + PriceType uint64 + Price uint64 + Pool []WeightedCostume + FixedCostumeID uint64 + TicketIDs []uint64 +} + +type GachaGroupDesign struct { + ID uint64 + FixedID uint64 + PointCount uint64 + PickUpExchangeCost uint64 + PickUpCostumeID uint64 + OneTimeGachaID uint64 + TenTimeGachaID uint64 + SelectCount uint64 + SelectionChoiceRate uint64 + UseSelectionOnlyFixedApply bool + GachaSubType uint64 + IsSelectedFromPity bool +} + +type GachaFixedDesign struct { + ID uint64 + CostumeGrade4Count uint64 + CostumeGrade5Count uint64 + ResetOnMatchingGrade bool +} + +type GachaFixedRoll struct { + CostumeGrade4Count uint64 + CostumeGrade5Count uint64 + CostumeGrade4Sort int + CostumeGrade5Sort int + SelectionSorts []int +} + +type RegularGachaCatalog struct { + Gachas map[uint64]RegularGacha + characters map[uint64]CharacterDesign + groups map[uint64]GachaGroupDesign + byGacha map[uint64]uint64 + fixed map[uint64]GachaFixedDesign + grades map[uint64]uint64 +} + +// GachaMigrationFact is the minimal stable GameData view consumed by the +// cross-language state repair tool. It deliberately excludes roll weights and +// other online-only catalog fields. +type GachaMigrationFact struct { + GachaID, GroupID, FixedID, PointCount uint64 +} + +type CostumeMigrationFact struct { + CostumeID, Grade, MaxLevel uint64 + OverflowItemType, OverflowItemID, OverflowCount uint64 +} + +func (c *RegularGachaCatalog) MigrationFacts() ([]GachaMigrationFact, []CostumeMigrationFact) { + if c == nil { + return nil, nil + } + gachas := make([]GachaMigrationFact, 0, len(c.byGacha)) + for gachaID, groupID := range c.byGacha { + group := c.groups[groupID] + gachas = append(gachas, GachaMigrationFact{GachaID: gachaID, GroupID: group.ID, FixedID: group.FixedID, PointCount: group.PointCount}) + } + sort.Slice(gachas, func(i, j int) bool { return gachas[i].GachaID < gachas[j].GachaID }) + costumes := make([]CostumeMigrationFact, 0, len(c.characters)) + for costumeID, design := range c.characters { + costumes = append(costumes, CostumeMigrationFact{CostumeID: costumeID, Grade: c.grades[costumeID], MaxLevel: design.CostumeMaxLevel, + OverflowItemType: design.OverflowItemType, OverflowItemID: design.OverflowItemID, OverflowCount: design.OverflowItemCount}) + } + sort.Slice(costumes, func(i, j int) bool { return costumes[i].CostumeID < costumes[j].CostumeID }) + return gachas, costumes +} + +func NewRegularGachaCatalog(gachas map[uint64]RegularGacha, characters map[uint64]CharacterDesign) (*RegularGachaCatalog, error) { + if len(gachas) == 0 || len(characters) == 0 { + return nil, errors.New("gamedata: invalid regular gacha catalog") + } + catalog := &RegularGachaCatalog{ + Gachas: make(map[uint64]RegularGacha, len(gachas)), characters: make(map[uint64]CharacterDesign, len(characters)), + groups: map[uint64]GachaGroupDesign{}, byGacha: map[uint64]uint64{}, fixed: map[uint64]GachaFixedDesign{}, grades: map[uint64]uint64{}, + } + for id, character := range characters { + catalog.characters[id] = character + } + for id, gacha := range gachas { + if id == 0 || gacha.ID != id || gacha.Count <= 0 || (gacha.PriceType != 2 && gacha.PriceType != 3) || gacha.Price == 0 { + return nil, errors.New("gamedata: invalid regular gacha definition") + } + if err := validateCostumePool(gacha.Pool, catalog.characters); err != nil { + return nil, err + } + if gacha.FixedCostumeID != 0 { + if _, ok := catalog.characters[gacha.FixedCostumeID]; !ok { + return nil, fmt.Errorf("gamedata: fixed costume %d lacks character design", gacha.FixedCostumeID) + } + } + catalog.Gachas[id] = gacha + catalog.recordPoolGrades(gacha) + } + return catalog, nil +} + +// AddGroupDesign supports small, schema-accurate fixtures. Production uses +// loadGroupsAndFixed to read the same fields from the live GameData tables. +func (c *RegularGachaCatalog) AddGroupDesign(group GachaGroupDesign, fixed GachaFixedDesign) error { + if c == nil || group.ID == 0 || group.PointCount == 0 { + return errors.New("gamedata: invalid gacha group") + } + if group.OneTimeGachaID != 0 { + if _, ok := c.Gachas[group.OneTimeGachaID]; !ok { + return fmt.Errorf("gamedata: missing one-time gacha %d", group.OneTimeGachaID) + } + c.byGacha[group.OneTimeGachaID] = group.ID + } + if group.TenTimeGachaID != 0 { + if _, ok := c.Gachas[group.TenTimeGachaID]; !ok { + return fmt.Errorf("gamedata: missing ten-time gacha %d", group.TenTimeGachaID) + } + c.byGacha[group.TenTimeGachaID] = group.ID + } + if group.FixedID != 0 { + if fixed.ID != group.FixedID || fixed.CostumeGrade4Count == 0 || fixed.CostumeGrade5Count == 0 { + return errors.New("gamedata: invalid gacha fixed group") + } + c.fixed[fixed.ID] = fixed + } + c.groups[group.ID] = group + return nil +} + +func (c *RegularGachaCatalog) GroupForGacha(gachaID uint64) (GachaGroupDesign, bool) { + if c == nil { + return GachaGroupDesign{}, false + } + groupID, ok := c.byGacha[gachaID] + if !ok { + return GachaGroupDesign{}, false + } + group, ok := c.groups[groupID] + return group, ok +} + +func (c *RegularGachaCatalog) Group(groupID uint64) (GachaGroupDesign, bool) { + if c == nil { + return GachaGroupDesign{}, false + } + group, ok := c.groups[groupID] + return group, ok +} + +func (c *RegularGachaCatalog) Fixed(fixedID uint64) (GachaFixedDesign, bool) { + if c == nil { + return GachaFixedDesign{}, false + } + fixed, ok := c.fixed[fixedID] + return fixed, ok +} + +func (c *RegularGachaCatalog) CostumeGrade(costumeID uint64) (uint64, bool) { + if c == nil { + return 0, false + } + grade, ok := c.grades[costumeID] + return grade, ok +} + +func (c *RegularGachaCatalog) recordPoolGrades(gacha RegularGacha) { + if gacha.FixedCostumeID != 0 { + c.grades[gacha.FixedCostumeID] = 5 + } + for branch, item := range gacha.Pool { + grade := uint64(0) + switch len(gacha.Pool) { + case 4: + if branch < 2 { + grade = 5 + } else { + grade = uint64(6 - branch) // branch 2/3 => grade 4/3 + } + case 3: + grade = uint64(5 - branch) + } + if grade != 0 { + recordCostumeGrade(c.grades, item, grade) + } + } +} + +func recordCostumeGrade(grades map[uint64]uint64, item WeightedCostume, grade uint64) { + if item.ID != 0 { + if current := grades[item.ID]; current == 0 || grade > current { + grades[item.ID] = grade + } + return + } + for _, child := range item.Children { + recordCostumeGrade(grades, child, grade) + } +} + +func (c *RegularGachaCatalog) Character(costumeID uint64) (CharacterDesign, bool) { + if c == nil { + return CharacterDesign{}, false + } + value, ok := c.characters[costumeID] + return value, ok +} + +func (c *RegularGachaCatalog) Gacha(id uint64) (RegularGacha, bool) { + if c == nil { + return RegularGacha{}, false + } + value, ok := c.Gachas[id] + return value, ok +} + +func (g RegularGacha) Roll() ([]uint64, error) { + return g.rollWith(func(limit uint64) (uint64, error) { + selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit)) + if err != nil { + return 0, err + } + return selected.Uint64(), nil + }) +} + +// RollWithCostumeFixed applies the resettable GachaFixedTable counters one +// slot at a time. The ordinary pickup pool layout is pickup-5, other-5, +// grade-4, grade-3; a guaranteed five-star therefore preserves the official +// 50/50 pickup split, while a guaranteed four-star uses the real grade-4 pool. +func (g RegularGacha) RollWithCostumeFixed(previous4, previous5 uint64, fixed GachaFixedDesign) ([]uint64, GachaFixedRoll, error) { + return g.RollWithCostumeFixedSelection(previous4, previous5, fixed, nil, nil) +} + +func (g RegularGacha) RollWithCostumeFixedSelection(previous4, previous5 uint64, fixed GachaFixedDesign, normalSelectedFive, pitySelectedFive []uint64) ([]uint64, GachaFixedRoll, error) { + return g.rollWithCostumeFixed(previous4, previous5, fixed, normalSelectedFive, pitySelectedFive, func(limit uint64) (uint64, error) { + selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit)) + if err != nil { + return 0, err + } + return selected.Uint64(), nil + }) +} + +func (g RegularGacha) rollWithCostumeFixed(previous4, previous5 uint64, fixed GachaFixedDesign, normalSelectedFive, pitySelectedFive []uint64, draw func(uint64) (uint64, error)) ([]uint64, GachaFixedRoll, error) { + state := GachaFixedRoll{CostumeGrade4Count: previous4, CostumeGrade5Count: previous5, CostumeGrade4Sort: -1, CostumeGrade5Sort: -1} + if g.Count <= 0 || (len(g.Pool) != 3 && len(g.Pool) != 4) || fixed.ID == 0 || fixed.CostumeGrade4Count == 0 || fixed.CostumeGrade5Count == 0 || draw == nil { + return nil, state, errors.New("gamedata: invalid costume fixed gacha") + } + fiveBranches := 1 + if len(g.Pool) == 4 { + fiveBranches = 2 + } + result := make([]uint64, g.Count) + for i := range result { + grade := uint64(0) + force5 := state.CostumeGrade5Count+1 >= fixed.CostumeGrade5Count + force4 := !force5 && state.CostumeGrade4Count+1 >= fixed.CostumeGrade4Count + var id uint64 + var err error + switch { + case force5: + if len(pitySelectedFive) != 0 { + var selected uint64 + selected, err = draw(uint64(len(pitySelectedFive))) + if err == nil { + if selected >= uint64(len(pitySelectedFive)) { + return nil, state, errors.New("gamedata: selected pity source returned out-of-range value") + } + id = pitySelectedFive[selected] + state.SelectionSorts = append(state.SelectionSorts, i) + } + } else { + id, err = rollCostumeChoiceWith(g.Pool[:fiveBranches], draw) + } + grade = 5 + state.CostumeGrade5Sort = i + case force4: + id, err = rollCostumeChoiceWith([]WeightedCostume{g.Pool[fiveBranches]}, draw) + grade = 4 + state.CostumeGrade4Sort = i + default: + var branch int + branch, id, err = rollCostumeBranchWith(g.Pool, draw) + if branch < fiveBranches { + grade = 5 + if err == nil && len(normalSelectedFive) != 0 { + var selected uint64 + selected, err = draw(uint64(len(normalSelectedFive))) + if err == nil { + if selected >= uint64(len(normalSelectedFive)) { + return nil, state, errors.New("gamedata: selected normal source returned out-of-range value") + } + id = normalSelectedFive[selected] + state.SelectionSorts = append(state.SelectionSorts, i) + } + } + } else { + grade = uint64(4 + fiveBranches - branch) + } + } + if err != nil { + return nil, state, err + } + result[i] = id + switch grade { + case 5: + state.CostumeGrade4Count = 0 + state.CostumeGrade5Count = 0 + case 4: + state.CostumeGrade4Count = 0 + state.CostumeGrade5Count++ + default: + state.CostumeGrade4Count++ + state.CostumeGrade5Count++ + } + } + return result, state, nil +} + +func (g RegularGacha) rollWith(draw func(uint64) (uint64, error)) ([]uint64, error) { + if g.Count <= 0 || len(g.Pool) == 0 { + return nil, errors.New("gamedata: invalid regular gacha") + } + result := make([]uint64, g.Count) + start := 0 + if g.FixedCostumeID != 0 { + result[0] = g.FixedCostumeID + start = 1 + } + hasFourOrFive := g.FixedCostumeID != 0 + for i := start; i < len(result); i++ { + branch, id, err := rollCostumeBranchWith(g.Pool, draw) + if err != nil { + return nil, err + } + if branch < len(g.Pool)-1 { + hasFourOrFive = true + } + result[i] = id + } + // Official costume ten-pulls guarantee grade four or above. The ordinary + // four-branch pools are ordered as pickup-5, other-5, grade-4, grade-3. + // If all ten normal rolls landed in grade 3, replace the final slot with + // an equal-weight draw from the real grade-4 branch. + if g.FixedCostumeID == 0 && g.Count == 10 && len(g.Pool) == 4 && !hasFourOrFive { + id, err := rollCostumeChoiceWith([]WeightedCostume{g.Pool[2]}, draw) + if err != nil { + return nil, err + } + result[len(result)-1] = id + } + return result, nil +} + +func rollCostumeBranch(pool []WeightedCostume) (int, uint64, error) { + return rollCostumeBranchWith(pool, func(limit uint64) (uint64, error) { + selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit)) + if err != nil { + return 0, err + } + return selected.Uint64(), nil + }) +} + +func rollCostumeBranchWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (int, uint64, error) { + var total uint64 + for _, item := range pool { + if item.Weight == 0 || math.MaxUint64-total < item.Weight { + return 0, 0, errors.New("gamedata: invalid regular gacha weight") + } + total += item.Weight + } + value, err := draw(total) + if err != nil { + return 0, 0, err + } + if value >= total { + return 0, 0, errors.New("gamedata: random source returned out-of-range value") + } + for i, item := range pool { + if value < item.Weight { + id, err := rollCostumeChoiceWith([]WeightedCostume{item}, draw) + return i, id, err + } + value -= item.Weight + } + return 0, 0, errors.New("gamedata: regular gacha branch selection failed") +} + +func rollCostumeChoice(pool []WeightedCostume) (uint64, error) { + return rollCostumeChoiceWith(pool, func(limit uint64) (uint64, error) { + selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit)) + if err != nil { + return 0, err + } + return selected.Uint64(), nil + }) +} + +func rollCostumeChoiceWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (uint64, error) { + var total uint64 + for _, item := range pool { + if item.Weight == 0 || math.MaxUint64-total < item.Weight { + return 0, errors.New("gamedata: invalid regular gacha weight") + } + if item.ID == 0 && len(item.Children) == 0 { + return 0, errors.New("gamedata: empty regular gacha choice") + } + total += item.Weight + } + value, err := draw(total) + if err != nil { + return 0, err + } + if value >= total { + return 0, errors.New("gamedata: random source returned out-of-range value") + } + for _, item := range pool { + if value < item.Weight { + if item.ID != 0 { + return item.ID, nil + } + return rollCostumeChoiceWith(item.Children, draw) + } + value -= item.Weight + } + return 0, errors.New("gamedata: regular gacha selection failed") +} + +// LoadRegularCostumeGacha loads only the ordinary costume pools currently +// exposed by this local server. Equipment pools remain hidden until complete +// option-instance generation is implemented. +func LoadRegularCostumeGacha(root, version string) (*RegularGachaCatalog, error) { + infinite, err := LoadInfiniteGacha(root, version) + if err != nil { + return nil, err + } + plain, err := ReadQuestDatabase(root, version) + 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() + 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{}, + } + for id, character := range infinite.characters { + catalog.characters[id] = character + } + for _, id := range []uint64{100, 101, 10100084, 11000084, 10100145, 11000145, 10100072, 11000072, 8100118, 8100119, 8100120, 8100121} { + var proto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", id).Scan(&proto); err != nil { + return nil, fmt.Errorf("gamedata: regular gacha %d: %w", id, err) + } + counts, _ := packedInts(proto, 5) + groups, _ := packedInts(proto, 7) + prices, _ := packedInts(proto, 10) + priceTypes, _ := packedInts(proto, 12) + ticketIDs, _ := packedInts(proto, 8) + if len(counts) != 1 || len(groups) != 1 || len(prices) != 1 || len(priceTypes) != 1 || (priceTypes[0] != 2 && priceTypes[0] != 3) { + return nil, fmt.Errorf("gamedata: malformed regular gacha %d", id) + } + poolGroupID := groups[0] + var fixedCostumeID uint64 + if id == 8100121 { + var root []byte + if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groups[0]).Scan(&root); err != nil { + return nil, err + } + dropCount, _ := packedInts(root, 1) + dropType, _ := packedInts(root, 2) + rootIDs, _ := packedInts(root, 5) + rootTypes, _ := packedInts(root, 6) + if len(dropCount) != 1 || dropCount[0] != 10 || len(dropType) != 1 || dropType[0] != 1 || len(rootIDs) != 2 || len(rootTypes) != 2 || rootTypes[0] != 11 || rootTypes[1] != 9 { + return nil, errors.New("gamedata: malformed fixed pickup ten-pull") + } + fixedCostumeID = rootIDs[0] + poolGroupID = rootIDs[1] + } + pool, err := loadCostumeRewardPool(db, poolGroupID) + if err != nil { + return nil, fmt.Errorf("gamedata: regular gacha %d: %w", id, err) + } + var costumeIDs []uint64 + collectCostumeIDs(pool, &costumeIDs) + if fixedCostumeID != 0 { + costumeIDs = append(costumeIDs, fixedCostumeID) + } + for _, costumeID := range costumeIDs { + if _, ok := catalog.characters[costumeID]; ok { + continue + } + character, err := loadGachaCharacterDesign(db, costumeID) + if err != nil { + return nil, err + } + catalog.characters[costumeID] = character + } + if err := validateCostumePool(pool, catalog.characters); err != nil { + return nil, err + } + if fixedCostumeID == 0 { + var rateError error + if id == 100 || id == 101 { + rateError = validateRegularCostumeRates(pool) + } else { + rateError = validateOfficialPickupRates(pool) + } + if rateError != nil { + return nil, fmt.Errorf("gamedata: regular gacha %d probability: %w", id, rateError) + } + } else if err := validateFixedPickupRemainderRates(pool); err != nil { + return nil, fmt.Errorf("gamedata: regular gacha %d probability: %w", id, err) + } + catalog.Gachas[id] = RegularGacha{ID: id, Count: int(counts[0]), PriceType: priceTypes[0], Price: prices[0], Pool: pool, FixedCostumeID: fixedCostumeID, TicketIDs: ticketIDs} + catalog.recordPoolGrades(catalog.Gachas[id]) + } + if err := catalog.loadGroupsAndFixed(db); err != nil { + return nil, err + } + return catalog, nil +} + +func validateRegularCostumeRates(pool []WeightedCostume) error { + if len(pool) != 3 { + return fmt.Errorf("expected three rarity branches, got %d", len(pool)) + } + want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate} + for i, item := range pool { + if item.Weight != want[i] { + return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i]) + } + } + return nil +} + +func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error { + rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id uint64 + var proto []byte + if err := rows.Scan(&id, &proto); err != nil { + return err + } + readOne := func(field int) uint64 { + values, _ := packedInts(proto, field) + if len(values) == 1 { + return values[0] + } + return 0 + } + group := GachaGroupDesign{ + ID: id, FixedID: readOne(10), PointCount: readOne(27), PickUpExchangeCost: readOne(25), + PickUpCostumeID: readOne(26), OneTimeGachaID: readOne(24), TenTimeGachaID: readOne(33), + SelectCount: readOne(29), SelectionChoiceRate: readOne(31), UseSelectionOnlyFixedApply: readOne(36) != 0, + GachaSubType: readOne(16), IsSelectedFromPity: readOne(21) != 0, + } + _, oneLoaded := c.Gachas[group.OneTimeGachaID] + _, tenLoaded := c.Gachas[group.TenTimeGachaID] + if !oneLoaded && !tenLoaded { + continue + } + c.groups[id] = group + if oneLoaded { + c.byGacha[group.OneTimeGachaID] = id + } + if tenLoaded { + c.byGacha[group.TenTimeGachaID] = id + } + } + if err := rows.Err(); err != nil { + return err + } + for _, group := range c.groups { + if group.FixedID == 0 { + continue + } + if _, exists := c.fixed[group.FixedID]; exists { + continue + } + var proto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=?", group.FixedID).Scan(&proto); err != nil { + return fmt.Errorf("gamedata: gacha fixed %d: %w", group.FixedID, err) + } + four, _ := packedInts(proto, 2) + five, _ := packedInts(proto, 4) + reset, _ := packedInts(proto, 6) + if len(four) != 1 || len(five) != 1 || len(reset) != 1 || four[0] == 0 || five[0] == 0 { + return fmt.Errorf("gamedata: malformed costume gacha fixed %d", group.FixedID) + } + c.fixed[group.FixedID] = GachaFixedDesign{ID: group.FixedID, CostumeGrade4Count: four[0], CostumeGrade5Count: five[0], ResetOnMatchingGrade: reset[0] != 0} + } + return nil +} + +func validateFixedPickupRemainderRates(pool []WeightedCostume) error { + if len(pool) != 3 { + return fmt.Errorf("expected three rarity branches, got %d", len(pool)) + } + want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate} + for i, item := range pool { + if item.Weight != want[i] { + return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i]) + } + } + return nil +} + +// validateOfficialPickupRates prevents a GameData/schema regression from +// silently changing the published costume pickup rates. The two five-star +// branches are pickup 1.5% plus the ordinary five-star pool 1.5%. +func validateOfficialPickupRates(pool []WeightedCostume) error { + if len(pool) != 4 { + return fmt.Errorf("expected four rarity branches, got %d", len(pool)) + } + want := [...]uint64{150, 150, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate} + for i, item := range pool { + if item.Weight != want[i] { + return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i]) + } + } + return nil +} + +func collectCostumeIDs(pool []WeightedCostume, result *[]uint64) { + for _, item := range pool { + if item.ID != 0 { + *result = append(*result, item.ID) + } else { + collectCostumeIDs(item.Children, result) + } + } +} + +func loadGachaCharacterDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) { + characterID := costumeID / 10 + var character []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&character); err != nil { + return CharacterDesign{}, fmt.Errorf("gamedata: costume %d character %d: %w", costumeID, characterID, err) + } + growthIDs, _ := packedInts(character, 1) + baseHP, found, err := fixed64Double(character, 11) + if err != nil || !found || len(growthIDs) != 1 { + return CharacterDesign{}, fmt.Errorf("gamedata: character %d invalid base design", characterID) + } + var growth []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growth); err != nil { + return CharacterDesign{}, err + } + levelGroups, _ := packedInts(growth, 1) + if len(levelGroups) != 1 { + return CharacterDesign{}, fmt.Errorf("gamedata: character %d invalid level group", characterID) + } + var level []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=1", levelGroups[0]).Scan(&level); err != nil { + return CharacterDesign{}, err + } + ratio, found, err := fixed64Double(level, 6) + if err != nil || !found { + return CharacterDesign{}, fmt.Errorf("gamedata: character %d invalid level health", characterID) + } + hp := math.Trunc(baseHP * (1 + ratio)) + if hp <= 0 || hp > math.MaxUint64 { + return CharacterDesign{}, fmt.Errorf("gamedata: character %d invalid HP", characterID) + } + costume, err := loadCostumeDesign(db, costumeID) + if err != nil { + return CharacterDesign{}, err + } + costume.ID = characterID + costume.HP = uint64(hp) + return costume, nil +} + +// loadCostumeDesign reads the duplicate-upgrade boundary and the official +// post-max exchange directly from CostumeTable/CostumeGrowthTable. Mileage has +// no item ID, so a zero OverflowItemID is valid when OverflowItemType is 20. +func loadCostumeDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) { + var costume []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CostumeTable WHERE id=?", costumeID).Scan(&costume); err != nil { + return CharacterDesign{}, fmt.Errorf("gamedata: costume %d: %w", costumeID, err) + } + growthGroups, _ := packedInts(costume, 10) + maxLevels, _ := packedInts(costume, 15) + if len(growthGroups) != 1 || len(maxLevels) != 1 || maxLevels[0] == 0 { + return CharacterDesign{}, fmt.Errorf("gamedata: costume %d invalid growth boundary", costumeID) + } + var growth []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CostumeGrowthTable WHERE GroupId=? AND id=?", growthGroups[0], maxLevels[0]).Scan(&growth); err != nil { + return CharacterDesign{}, fmt.Errorf("gamedata: costume %d max growth: %w", costumeID, err) + } + mileageCounts, _ := packedInts(growth, 9) + mileageIDs, _ := packedInts(growth, 10) + mileageTypes, _ := packedInts(growth, 11) + overCounts, _ := packedInts(growth, 12) + if len(mileageCounts) != 1 || mileageCounts[0] == 0 || len(mileageTypes) != 1 || mileageTypes[0] == 0 || len(overCounts) != 1 || overCounts[0] != maxLevels[0]+1 { + return CharacterDesign{}, fmt.Errorf("gamedata: costume %d invalid overflow exchange", costumeID) + } + var mileageID uint64 + if len(mileageIDs) == 1 { + mileageID = mileageIDs[0] + } else if len(mileageIDs) != 0 { + return CharacterDesign{}, fmt.Errorf("gamedata: costume %d invalid overflow item id", costumeID) + } + return CharacterDesign{ + CostumeMaxLevel: maxLevels[0], OverflowItemType: mileageTypes[0], + OverflowItemID: mileageID, OverflowItemCount: mileageCounts[0], + }, nil +} + +func loadCostumeRewardPool(db *sql.DB, groupID uint64) ([]WeightedCostume, error) { + var proto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&proto); err != nil { + return nil, err + } + ids, _ := packedInts(proto, 5) + types, _ := packedInts(proto, 6) + weights, _ := packedInts(proto, 8) + if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(weights) { + return nil, errors.New("malformed reward group") + } + var result []WeightedCostume + for i, id := range ids { + switch types[i] { + case 11: + result = append(result, WeightedCostume{ID: id, Weight: weights[i]}) + case 9: + children, err := loadCostumeRewardPool(db, id) + if err != nil { + return nil, err + } + result = append(result, WeightedCostume{Weight: weights[i], Children: children}) + default: + return nil, fmt.Errorf("unsupported costume reward type %d", types[i]) + } + } + return result, nil +} + +func validateCostumePool(pool []WeightedCostume, characters map[uint64]CharacterDesign) error { + if len(pool) == 0 { + return errors.New("gamedata: empty costume pool") + } + for _, item := range pool { + if item.ID != 0 { + if _, ok := characters[item.ID]; !ok { + return fmt.Errorf("gamedata: regular gacha costume %d lacks character design", item.ID) + } + continue + } + if err := validateCostumePool(item.Children, characters); err != nil { + return err + } + } + return nil +} + +func NewInfiniteGachaDesign(count int, costumeIDs []uint64, characters map[uint64]CharacterDesign) (*InfiniteGachaDesign, error) { + return NewInfiniteGachaDesignWithRates(count, costumeIDs, costumeIDs, costumeIDs, characters) +} + +// NewInfiniteGachaDesignWithRates builds the local reroll design. Its first +// Count-1 slots use the official costume base rates (3%/14%/83%), while the +// last slot is a five-star guarantee chosen uniformly from the real eligible +// five-star pool. +func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeStarIDs []uint64, characters map[uint64]CharacterDesign) (*InfiniteGachaDesign, error) { + if count <= 0 || len(fiveStarIDs) == 0 || len(fourStarIDs) == 0 || len(threeStarIDs) == 0 || len(characters) == 0 { + return nil, fmt.Errorf("gamedata: invalid infinite gacha design") + } + design := &InfiniteGachaDesign{ + Count: count, CostumeIDs: append([]uint64(nil), fiveStarIDs...), + FiveStarIDs: append([]uint64(nil), fiveStarIDs...), FourStarIDs: append([]uint64(nil), fourStarIDs...), + ThreeStarIDs: append([]uint64(nil), threeStarIDs...), characters: make(map[uint64]CharacterDesign, len(characters)), + } + for _, pool := range [][]uint64{design.FiveStarIDs, design.FourStarIDs, design.ThreeStarIDs} { + for _, id := range pool { + character, ok := characters[id] + if id == 0 || !ok || character.ID == 0 || character.HP == 0 { + return nil, fmt.Errorf("gamedata: costume %d has no valid character", id) + } + design.characters[id] = character + } + } + return design, nil +} + +func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) { + plain, err := ReadQuestDatabase(root, version) + 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() + + var gacha []byte + if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", InfiniteGachaID).Scan(&gacha); err != nil { + return nil, fmt.Errorf("gamedata: infinite gacha: %w", err) + } + count, _ := packedInts(gacha, 5) + reward, _ := packedInts(gacha, 7) + priceCount, _ := packedInts(gacha, 10) + if len(count) != 1 || count[0] != 10 || len(reward) != 1 || reward[0] != InfiniteGachaID || len(priceCount) != 0 { + return nil, fmt.Errorf("gamedata: unexpected infinite gacha definition count=%v reward=%v price=%v", count, reward, priceCount) + } + var cash []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CashProductTable WHERE id=? AND GroupId=?", InfiniteProductID, InfiniteProductGroupID).Scan(&cash); err != nil { + return nil, fmt.Errorf("gamedata: infinite product: %w", err) + } + productID, _ := packedInts(cash, 6) + groupID, _ := packedInts(cash, 5) + if len(productID) != 1 || productID[0] != InfiniteProductID || len(groupID) != 1 || groupID[0] != InfiniteProductGroupID { + return nil, fmt.Errorf("gamedata: unexpected infinite product identity") + } + + // GachaTable.FixedGachaRewardId=9100035 is the official costume pool used + // by the observed infinite previews. RewardGroup item type 11 is Costume. + var pool []byte + if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=9100035").Scan(&pool); err != nil { + return nil, fmt.Errorf("gamedata: infinite costume pool: %w", err) + } + ids, err := packedInts(pool, 5) + if err != nil { + return nil, err + } + types, err := packedInts(pool, 6) + if err != nil || len(ids) == 0 || len(ids) != len(types) { + return nil, fmt.Errorf("gamedata: malformed infinite costume pool") + } + characters := make(map[uint64]CharacterDesign) + // CharLevelTable.Health is a growth ratio, not an absolute HP value. The + // client computes level-one HP as trunc(CharTable.HealthValue * (1+ratio)). + // Keeping the two sources separate also avoids rejecting valid fractional + // ratios such as group 101's 0.2. + levelOneHealthRatio := make(map[uint64]float64) + rows, err := db.Query("SELECT ProtoBuf FROM CharLevelTable") + if err != nil { + return nil, err + } + for rows.Next() { + var levelProto []byte + if err := rows.Scan(&levelProto); err != nil { + rows.Close() + return nil, err + } + groups, _ := packedInts(levelProto, 5) + levels, _ := packedInts(levelProto, 7) + health, healthFound, healthErr := fixed64Double(levelProto, 6) + if healthErr != nil { + rows.Close() + return nil, healthErr + } + if len(groups) == 1 && len(levels) == 1 && levels[0] == 1 && healthFound && health >= 0 && !math.IsNaN(health) && !math.IsInf(health, 0) { + levelOneHealthRatio[groups[0]] = health + } + } + if err := rows.Close(); err != nil { + return nil, err + } + for i, costumeID := range ids { + if types[i] != 11 || costumeID == 0 { + return nil, fmt.Errorf("gamedata: infinite pool entry %d has type %d", costumeID, types[i]) + } + characterID := costumeID / 10 + var character []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&character); err != nil { + return nil, fmt.Errorf("gamedata: costume %d character %d: %w", costumeID, characterID, err) + } + growthID, _ := packedInts(character, 1) + if len(growthID) != 1 { + return nil, fmt.Errorf("gamedata: character %d has invalid growth %v", characterID, growthID) + } + baseHP, baseHPFound, err := fixed64Double(character, 11) + if err != nil { + return nil, fmt.Errorf("gamedata: character %d base HP: %w", characterID, err) + } + if !baseHPFound || baseHP <= 0 || math.IsNaN(baseHP) || math.IsInf(baseHP, 0) { + return nil, fmt.Errorf("gamedata: character %d has invalid base HP %v", characterID, baseHP) + } + var growth []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growth); err != nil { + return nil, fmt.Errorf("gamedata: character %d growth %d: %w", characterID, growthID[0], err) + } + levelGroup, _ := packedInts(growth, 1) + if len(levelGroup) != 1 { + return nil, fmt.Errorf("gamedata: character %d has invalid level group %v", characterID, levelGroup) + } + healthRatio, found := levelOneHealthRatio[levelGroup[0]] + if !found { + return nil, fmt.Errorf("gamedata: character %d has invalid level group %v", characterID, levelGroup) + } + hp := math.Trunc(baseHP * (1 + healthRatio)) + if hp <= 0 || hp > math.MaxUint64 { + return nil, fmt.Errorf("gamedata: character %d computes invalid level-one HP %v", characterID, hp) + } + costume, err := loadCostumeDesign(db, costumeID) + if err != nil { + return nil, err + } + costume.ID = characterID + costume.HP = uint64(hp) + characters[costumeID] = costume + } + fourStarPool, err := loadCostumeRewardPool(db, 70008) + if err != nil { + return nil, fmt.Errorf("gamedata: infinite four-star pool: %w", err) + } + threeStarPool, err := loadCostumeRewardPool(db, 70004) + if err != nil { + return nil, fmt.Errorf("gamedata: infinite three-star pool: %w", err) + } + var fourStarIDs, threeStarIDs []uint64 + collectCostumeIDs(fourStarPool, &fourStarIDs) + collectCostumeIDs(threeStarPool, &threeStarIDs) + if len(ids) != 129 || len(fourStarIDs) != 13 || len(threeStarIDs) != 16 { + return nil, fmt.Errorf("gamedata: unexpected infinite rarity pools five=%d four=%d three=%d", len(ids), len(fourStarIDs), len(threeStarIDs)) + } + for _, costumeID := range append(append([]uint64(nil), fourStarIDs...), threeStarIDs...) { + if _, exists := characters[costumeID]; exists { + continue + } + character, err := loadGachaCharacterDesign(db, costumeID) + if err != nil { + return nil, err + } + characters[costumeID] = character + } + return NewInfiniteGachaDesignWithRates(int(count[0]), ids, fourStarIDs, threeStarIDs, characters) +} + +func fixed64Double(proto []byte, number int) (float64, bool, error) { + var result float64 + found := false + err := wire.Walk(proto, func(field wire.Field) error { + if field.Number != number { + return nil + } + if field.Type != 1 || len(field.Value) != 8 || found { + return fmt.Errorf("gamedata: invalid double field %d", number) + } + result = math.Float64frombits(binary.LittleEndian.Uint64(field.Value)) + found = true + return nil + }) + return result, found, err +} + +func (d *InfiniteGachaDesign) Roll() ([]uint64, error) { + return d.rollWith(func(limit uint64) (uint64, error) { + selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit)) + if err != nil { + return 0, err + } + return selected.Uint64(), nil + }) +} + +func (d *InfiniteGachaDesign) rollWith(draw func(uint64) (uint64, error)) ([]uint64, error) { + if d == nil || d.Count <= 0 || len(d.FiveStarIDs) == 0 || len(d.FourStarIDs) == 0 || len(d.ThreeStarIDs) == 0 || draw == nil { + return nil, fmt.Errorf("gamedata: invalid infinite gacha design") + } + result := make([]uint64, d.Count) + for i := 0; i < d.Count-1; i++ { + rate, err := draw(officialRateScale) + if err != nil { + return nil, err + } + var pool []uint64 + switch { + case rate < officialFiveStarRate: + pool = d.FiveStarIDs + case rate < officialFiveStarRate+officialFourStarRate: + pool = d.FourStarIDs + default: + pool = d.ThreeStarIDs + } + selected, err := draw(uint64(len(pool))) + if err != nil { + return nil, err + } + if selected >= uint64(len(pool)) { + return nil, errors.New("gamedata: random source returned out-of-range value") + } + result[i] = pool[selected] + } + selected, err := draw(uint64(len(d.FiveStarIDs))) + if err != nil { + return nil, err + } + if selected >= uint64(len(d.FiveStarIDs)) { + return nil, errors.New("gamedata: random source returned out-of-range value") + } + result[d.Count-1] = d.FiveStarIDs[selected] + return result, nil +} + +func (d *InfiniteGachaDesign) Character(costumeID uint64) (CharacterDesign, bool) { + if d == nil { + return CharacterDesign{}, false + } + character, ok := d.characters[costumeID] + return character, ok +} diff --git a/go/internal/gamedata/gacha_fixed_test.go b/go/internal/gamedata/gacha_fixed_test.go new file mode 100644 index 0000000..f22058e --- /dev/null +++ b/go/internal/gamedata/gacha_fixed_test.go @@ -0,0 +1,77 @@ +package gamedata + +import "testing" + +func TestCostumeGuaranteedFourAndFiveCountsAcrossOneAndTen(t *testing.T) { + pool := []WeightedCostume{ + {Weight: 150, Children: []WeightedCostume{{ID: 5001, Weight: 1}}}, + {Weight: 150, Children: []WeightedCostume{{ID: 5002, Weight: 1}}}, + {Weight: 1400, Children: []WeightedCostume{{ID: 4001, Weight: 1}}}, + {Weight: 8300, Children: []WeightedCostume{{ID: 3001, Weight: 1}}}, + } + fixed := GachaFixedDesign{ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true} + chooseThree := func(limit uint64) (uint64, error) { + if limit == 10000 { + return 9999, nil + } + return 0, nil + } + // The first of a ten-pull hits the shared four-star boundary after nine + // unsuccessful singles, and the next nine slots continue the new count. + gacha := RegularGacha{ID: 1, Count: 10, PriceType: 3, Price: 2000, Pool: pool} + roll, state, err := gacha.rollWithCostumeFixed(9, 25, fixed, nil, nil, chooseThree) + if err != nil { + t.Fatal(err) + } + if len(roll) != 10 || roll[0] != 4001 || state.CostumeGrade4Sort != 0 || state.CostumeGrade4Count != 9 || state.CostumeGrade5Count != 35 || state.CostumeGrade5Sort != -1 { + t.Fatalf("four-star boundary roll=%v state=%+v", roll, state) + } + // When both boundaries coincide, the five-star guarantee has priority; + // it resets both counters and is sampled from the genuine 50/50 5-star + // branches, never from a synthetic hardcoded character. + roll, state, err = gacha.rollWithCostumeFixed(9, 99, fixed, nil, nil, chooseThree) + if err != nil { + t.Fatal(err) + } + if roll[0] != 5001 || state.CostumeGrade5Sort != 0 || state.CostumeGrade4Sort != -1 || state.CostumeGrade4Count != 9 || state.CostumeGrade5Count != 9 { + t.Fatalf("five-star priority roll=%v state=%+v", roll, state) + } + // Natural five-stars reset both counters without falsely reporting a + // guaranteed slot in GachaFixedDBInfo.ApplySortId. + gacha.Count = 1 + chooseFive := func(limit uint64) (uint64, error) { return 0, nil } + roll, state, err = gacha.rollWithCostumeFixed(8, 98, fixed, nil, nil, chooseFive) + if err != nil || roll[0] != 5001 || state.CostumeGrade4Count != 0 || state.CostumeGrade5Count != 0 || state.CostumeGrade4Sort != -1 || state.CostumeGrade5Sort != -1 { + t.Fatalf("natural five-star roll=%v state=%+v err=%v", roll, state, err) + } +} + +func TestTwelvePickSelectionAppliesToNaturalAndPityFiveStar(t *testing.T) { + pool := []WeightedCostume{ + {Weight: 300, Children: []WeightedCostume{{ID: 5001, Weight: 1}}}, + {Weight: 1400, Children: []WeightedCostume{{ID: 4001, Weight: 1}}}, + {Weight: 8300, Children: []WeightedCostume{{ID: 3001, Weight: 1}}}, + } + gacha := RegularGacha{ID: 101, Count: 1, PriceType: 3, Price: 200, Pool: pool} + fixed := GachaFixedDesign{ID: 8, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true} + selected := []uint64{5012, 5013} + chooseFirst := func(limit uint64) (uint64, error) { return 0, nil } + roll, state, err := gacha.rollWithCostumeFixed(0, 0, fixed, selected, selected, chooseFirst) + if err != nil || len(roll) != 1 || roll[0] != 5012 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 0 || state.CostumeGrade5Sort != -1 { + t.Fatalf("ordinary selected five roll=%v state=%+v err=%v", roll, state, err) + } + roll, state, err = gacha.rollWithCostumeFixed(9, 99, fixed, selected, selected, chooseFirst) + if err != nil || len(roll) != 1 || roll[0] != 5012 || state.CostumeGrade5Sort != 0 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 0 { + t.Fatalf("guaranteed selected five roll=%v state=%+v err=%v", roll, state, err) + } + chooseThree := func(limit uint64) (uint64, error) { + if limit == 10000 { + return 9999, nil + } + return 0, nil + } + roll, state, err = gacha.rollWithCostumeFixed(0, 0, fixed, selected, selected, chooseThree) + if err != nil || roll[0] != 3001 || len(state.SelectionSorts) != 0 || state.CostumeGrade4Count != 1 || state.CostumeGrade5Count != 1 { + t.Fatalf("ordinary three roll=%v state=%+v err=%v", roll, state, err) + } +} diff --git a/go/internal/gamedata/gacha_probability_test.go b/go/internal/gamedata/gacha_probability_test.go new file mode 100644 index 0000000..a68a388 --- /dev/null +++ b/go/internal/gamedata/gacha_probability_test.go @@ -0,0 +1,100 @@ +package gamedata + +import "testing" + +func TestInfiniteGachaOfficialRateBoundariesAndFiveStarGuarantee(t *testing.T) { + characters := map[uint64]CharacterDesign{ + 5001: {ID: 500, HP: 100}, + 4001: {ID: 400, HP: 100}, + 3001: {ID: 300, HP: 100}, + } + design, err := NewInfiniteGachaDesignWithRates(6, []uint64{5001}, []uint64{4001}, []uint64{3001}, characters) + if err != nil { + t.Fatal(err) + } + // The first five pairs are rate roll + pool index. They cover both + // boundaries of 3%, the 14% interval and the start of the 83% interval. + // The final value chooses the guaranteed five-star slot. + draws := []uint64{0, 0, 299, 0, 300, 0, 1699, 0, 1700, 0, 0} + position := 0 + roll, err := design.rollWith(func(limit uint64) (uint64, error) { + if position >= len(draws) { + t.Fatal("unexpected extra random draw") + } + value := draws[position] + position++ + if value >= limit { + t.Fatalf("draw %d exceeds limit %d", value, limit) + } + return value, nil + }) + if err != nil { + t.Fatal(err) + } + want := []uint64{5001, 5001, 4001, 4001, 3001, 5001} + for i := range want { + if roll[i] != want[i] { + t.Fatalf("slot %d=%d want=%d; roll=%v", i, roll[i], want[i], roll) + } + } + if position != len(draws) { + t.Fatalf("used %d random draws, want %d", position, len(draws)) + } +} + +func TestOfficialPickupRateDefinition(t *testing.T) { + pool := []WeightedCostume{ + {Weight: 150, ID: 1}, + {Weight: 150, ID: 2}, + {Weight: 1400, ID: 3}, + {Weight: 8300, ID: 4}, + } + if err := validateOfficialPickupRates(pool); err != nil { + t.Fatal(err) + } + pool[0].Weight++ + if err := validateOfficialPickupRates(pool); err == nil { + t.Fatal("accepted a pickup rate different from official GameData") + } +} + +func TestTenPullGuaranteesGradeFourWhenAllNormalRollsAreGradeThree(t *testing.T) { + gacha := RegularGacha{Count: 10, Pool: []WeightedCostume{ + {Weight: 150, ID: 5001}, {Weight: 150, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001}, + }} + roll, err := gacha.rollWith(func(limit uint64) (uint64, error) { + if limit == officialRateScale { + return 9999, nil + } + return 0, nil + }) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 9; i++ { + if roll[i] != 3001 { + t.Fatalf("slot %d=%d want grade-3", i, roll[i]) + } + } + if roll[9] != 4001 { + t.Fatalf("guaranteed slot=%d want grade-4; roll=%v", roll[9], roll) + } +} + +func TestFixedPickupTenPullAlwaysStartsWithPickupCostume(t *testing.T) { + gacha := RegularGacha{Count: 10, FixedCostumeID: 21201, Pool: []WeightedCostume{ + {Weight: 300, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001}, + }} + roll, err := gacha.rollWith(func(limit uint64) (uint64, error) { return limit - 1, nil }) + if err != nil { + t.Fatal(err) + } + if len(roll) != 10 || roll[0] != 21201 { + t.Fatalf("roll=%v", roll) + } + for i := 1; i < 10; i++ { + if roll[i] != 3001 { + t.Fatalf("slot %d=%d want grade-3", i, roll[i]) + } + } +} diff --git a/go/internal/gamedata/growth.go b/go/internal/gamedata/growth.go new file mode 100644 index 0000000..86453c3 --- /dev/null +++ b/go/internal/gamedata/growth.go @@ -0,0 +1,187 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "sort" + + _ "modernc.org/sqlite" +) + +// GrowthMaterial is a stack of ResourceTable material consumed by CharGrowth. +type GrowthMaterial struct{ ID, Count uint64 } + +// CharacterGrowth applies ResourceTable.MagicValue to the character's exact +// CharGrowthTable/CharLevelTable curve from the installed GameData. +func CharacterGrowth(root, version string, charID int, level, exp uint64, materials []GrowthMaterial) (uint64, uint64, []GrowthMaterial, error) { + if charID <= 0 || level == 0 || len(materials) == 0 { + return 0, 0, nil, fmt.Errorf("gamedata: invalid character growth input") + } + plain, err := ReadQuestDatabase(root, version) + 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() + + var charProto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil { + return 0, 0, nil, fmt.Errorf("gamedata: character %d: %w", charID, err) + } + growthIDs, err := packedInts(charProto, 1) + if err != nil || len(growthIDs) != 1 { + return 0, 0, nil, fmt.Errorf("gamedata: character %d growth ID %v: %w", charID, growthIDs, err) + } + var growthProto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil { + return 0, 0, nil, err + } + groups, err := packedInts(growthProto, 1) + if err != nil || len(groups) != 1 { + return 0, 0, nil, fmt.Errorf("gamedata: growth %d group %v: %w", growthIDs[0], groups, err) + } + maximum, err := packedInts(growthProto, 9) + if err != nil || len(maximum) != 1 || maximum[0] < level { + return 0, 0, nil, fmt.Errorf("gamedata: growth %d maximum %v: %w", growthIDs[0], maximum, err) + } + + var gained uint64 + for _, material := range materials { + if material.ID == 0 || material.Count == 0 { + return 0, 0, nil, fmt.Errorf("gamedata: invalid growth material") + } + var proto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM ResourceTable WHERE id=?", material.ID).Scan(&proto); err != nil { + return 0, 0, nil, fmt.Errorf("gamedata: resource %d: %w", material.ID, err) + } + values, err := packedInts(proto, 9) + if err != nil || len(values) != 1 || values[0] == 0 { + return 0, 0, nil, fmt.Errorf("gamedata: resource %d magic value %v: %w", material.ID, values, err) + } + if material.Count > (^uint64(0)-gained)/values[0] { + return 0, 0, nil, fmt.Errorf("gamedata: growth experience overflow") + } + gained += material.Count * values[0] + } + + curve := make(map[uint64]uint64) + rows, err := db.Query("SELECT ProtoBuf FROM CharLevelTable") + if err != nil { + return 0, 0, nil, err + } + defer rows.Close() + for rows.Next() { + var proto []byte + if err := rows.Scan(&proto); err != nil { + return 0, 0, nil, err + } + group, _ := packedInts(proto, 5) + if len(group) != 1 || group[0] != groups[0] { + continue + } + levels, _ := packedInts(proto, 7) + required, _ := packedInts(proto, 8) + if len(levels) == 1 && len(required) == 1 { + curve[levels[0]] = required[0] + } + } + if err := rows.Err(); err != nil { + return 0, 0, nil, err + } + currentLevel, currentExp := level, exp + for gained > 0 && currentLevel < maximum[0] { + required := curve[currentLevel] + if required == 0 || currentExp >= required { + return 0, 0, nil, fmt.Errorf("gamedata: missing/invalid level curve group %d level %d", groups[0], currentLevel) + } + need := required - currentExp + if gained < need { + currentExp += gained + gained = 0 + break + } + gained -= need + currentLevel++ + currentExp = 0 + } + if currentLevel == maximum[0] { + currentExp = 0 + refunds, err := refundGrowthResources(db, gained) + return currentLevel, currentExp, refunds, err + } + return currentLevel, currentExp, nil, nil +} + +// Convert max-level overflow back into the installed experience resources. +// The official 2.34.13 server uses larger denominations greedily and rounds +// the final remainder up to one smallest slime. This can refund slightly +// more nominal EXP than the overflow: the verified tutorial sample has 551 +// overflow EXP and returns resource 7 x3 (600 EXP), not x2. +func refundGrowthResources(db *sql.DB, overflow uint64) ([]GrowthMaterial, error) { + if overflow == 0 { + return nil, nil + } + rows, err := db.Query("SELECT id,ProtoBuf FROM ResourceTable") + if err != nil { + return nil, err + } + defer rows.Close() + type unit struct{ id, value uint64 } + var units []unit + for rows.Next() { + var id uint64 + var proto []byte + if err := rows.Scan(&id, &proto); err != nil { + return nil, err + } + kind, err := packedInts(proto, 13) + if err != nil { + return nil, err + } + value, err := packedInts(proto, 9) + if err != nil { + return nil, err + } + if len(kind) == 1 && kind[0] == 1 && len(value) == 1 && value[0] > 0 { + units = append(units, unit{id, value[0]}) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(units) == 0 { + return nil, fmt.Errorf("gamedata: missing experience resources") + } + sort.Slice(units, func(i, j int) bool { return units[i].value > units[j].value }) + var refunds []GrowthMaterial + remaining := overflow + for i, unit := range units { + count := remaining / unit.value + if i == len(units)-1 && remaining%unit.value != 0 { + count++ + } + if count != 0 { + refunds = append(refunds, GrowthMaterial{ID: unit.id, Count: count}) + if count > remaining/unit.value { + remaining = 0 + } else { + remaining -= count * unit.value + } + } + } + return refunds, nil +} diff --git a/go/internal/gamedata/missions.go b/go/internal/gamedata/missions.go new file mode 100644 index 0000000..3b3563c --- /dev/null +++ b/go/internal/gamedata/missions.go @@ -0,0 +1,274 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// Reward is an immutable GameData reward definition. Type, ID and Count are +// the exact parallel arrays stored by the client design tables; it is not a +// player inventory row. +type Reward struct { + Type uint64 + ID uint64 + Count uint64 +} + +type MissionKey struct{ GroupType, GroupID, ID uint64 } +type SectionRewardKey struct{ GroupType, ID uint64 } +type AchievementKey struct{ ContentsGroup, GroupID, ID uint64 } + +type MissionDesign struct { + Missions map[MissionKey][]Reward + Conditions map[MissionKey]MissionCondition + Sections map[SectionRewardKey]SectionRewardDesign + Achievements map[AchievementKey]AchievementDesign +} + +type MissionCondition struct { + Type uint64 + SubType uint64 + Params []uint64 + TargetValue uint64 + UnlockPack uint64 + UnlockQuest uint64 +} + +type SectionRewardDesign struct { + SectionValue uint64 + Rewards []Reward +} + +type AchievementDesign struct { + AddExp uint64 + Rewards []Reward +} + +// LoadMissionDesign loads the three non-event design tables from the shared +// 2.34.13 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) + 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() + return loadMissionDesignDB(db) +} + +func loadMissionDesignDB(db *sql.DB) (*MissionDesign, error) { + if db == nil { + return nil, fmt.Errorf("gamedata: nil mission database") + } + design := &MissionDesign{ + Missions: map[MissionKey][]Reward{}, Conditions: map[MissionKey]MissionCondition{}, Sections: map[SectionRewardKey]SectionRewardDesign{}, + Achievements: map[AchievementKey]AchievementDesign{}, + } + if err := loadMissionRows(db, design); err != nil { + return nil, err + } + if err := loadSectionRows(db, design); err != nil { + return nil, err + } + if err := loadAchievementRows(db, design); err != nil { + return nil, err + } + return design, nil +} + +func loadMissionRows(db *sql.DB, design *MissionDesign) error { + rows, err := db.Query("SELECT ProtoBuf FROM MissionTable") + if err != nil { + return fmt.Errorf("gamedata: query MissionTable: %w", err) + } + defer rows.Close() + for rows.Next() { + var proto []byte + if err := rows.Scan(&proto); err != nil { + return err + } + groupID, err := optionalScalar(proto, 6) + if err != nil { + return fmt.Errorf("gamedata: MissionTable group id: %w", err) + } + groupType, err := optionalScalar(proto, 7) + if err != nil { + return fmt.Errorf("gamedata: MissionTable group type: %w", err) + } + id, err := optionalScalar(proto, 8) + if err != nil { + return fmt.Errorf("gamedata: MissionTable id: %w", err) + } + // GroupType zero is DAILY, a valid proto3 default. Rows without a + // group/id identity remain non-claimable templates. + if groupID == 0 || id == 0 { + continue + } + rewards, err := parallelRewards(proto, 15, 14, 13) + if err != nil { + return fmt.Errorf("gamedata: MissionTable %d/%d/%d: %w", groupType, groupID, id, err) + } + key := MissionKey{groupType, groupID, id} + if _, exists := design.Missions[key]; exists { + return fmt.Errorf("gamedata: duplicate MissionTable key %+v", key) + } + design.Missions[key] = rewards + conditionType, err := optionalScalar(proto, 3) + if err != nil { + return fmt.Errorf("gamedata: MissionTable condition type: %w", err) + } + conditionSubType, err := optionalScalar(proto, 1) + if err != nil { + return fmt.Errorf("gamedata: MissionTable condition subtype: %w", err) + } + params, err := packedInts(proto, 2) + if err != nil { + return fmt.Errorf("gamedata: MissionTable params: %w", err) + } + target, err := optionalScalar(proto, 4) + if err != nil { + return fmt.Errorf("gamedata: MissionTable target: %w", err) + } + unlockPack, err := optionalScalar(proto, 19) + if err != nil { + return fmt.Errorf("gamedata: MissionTable unlock pack: %w", err) + } + unlockQuest, err := optionalScalar(proto, 20) + if err != nil { + return fmt.Errorf("gamedata: MissionTable unlock quest: %w", err) + } + design.Conditions[key] = MissionCondition{Type: conditionType, SubType: conditionSubType, Params: params, TargetValue: target, UnlockPack: unlockPack, UnlockQuest: unlockQuest} + } + return rows.Err() +} + +func loadSectionRows(db *sql.DB, design *MissionDesign) error { + rows, err := db.Query("SELECT ProtoBuf FROM MissionSectionRewardTable") + if err != nil { + return fmt.Errorf("gamedata: query MissionSectionRewardTable: %w", err) + } + defer rows.Close() + for rows.Next() { + var proto []byte + if err := rows.Scan(&proto); err != nil { + return err + } + groupType, err := optionalScalar(proto, 1) + if err != nil { + return fmt.Errorf("gamedata: MissionSectionRewardTable group type: %w", err) + } + id, err := optionalScalar(proto, 2) + if err != nil { + return fmt.Errorf("gamedata: MissionSectionRewardTable id: %w", err) + } + if id == 0 { + continue + } + rewards, err := parallelRewards(proto, 5, 4, 3) + if err != nil { + return fmt.Errorf("gamedata: MissionSectionRewardTable %d/%d: %w", groupType, id, err) + } + key := SectionRewardKey{groupType, id} + if _, exists := design.Sections[key]; exists { + return fmt.Errorf("gamedata: duplicate MissionSectionRewardTable key %+v", key) + } + sectionValue, err := optionalScalar(proto, 6) + if err != nil { + return fmt.Errorf("gamedata: MissionSectionRewardTable section value: %w", err) + } + design.Sections[key] = SectionRewardDesign{SectionValue: sectionValue, Rewards: rewards} + } + return rows.Err() +} + +func loadAchievementRows(db *sql.DB, design *MissionDesign) error { + rows, err := db.Query("SELECT ProtoBuf FROM AchievementTable") + if err != nil { + return fmt.Errorf("gamedata: query AchievementTable: %w", err) + } + defer rows.Close() + for rows.Next() { + var proto []byte + if err := rows.Scan(&proto); err != nil { + return err + } + contents, err := optionalScalar(proto, 4) + if err != nil { + return fmt.Errorf("gamedata: AchievementTable contents group: %w", err) + } + groupID, err := optionalScalar(proto, 9) + if err != nil { + return fmt.Errorf("gamedata: AchievementTable group id: %w", err) + } + id, err := optionalScalar(proto, 11) + if err != nil { + return fmt.Errorf("gamedata: AchievementTable id: %w", err) + } + if contents == 0 || groupID == 0 || id == 0 { + continue + } + exp, err := optionalScalar(proto, 8) + if err != nil { + return fmt.Errorf("gamedata: AchievementTable exp: %w", err) + } + rewards, err := parallelRewards(proto, 16, 15, 14) + if err != nil { + return fmt.Errorf("gamedata: AchievementTable %d/%d/%d: %w", contents, groupID, id, err) + } + key := AchievementKey{contents, groupID, id} + if _, exists := design.Achievements[key]; exists { + return fmt.Errorf("gamedata: duplicate AchievementTable key %+v", key) + } + design.Achievements[key] = AchievementDesign{AddExp: exp, Rewards: rewards} + } + return rows.Err() +} + +func parallelRewards(proto []byte, typeField, idField, countField int) ([]Reward, error) { + types, err := packedInts(proto, typeField) + if err != nil { + return nil, err + } + ids, err := packedInts(proto, idField) + if err != nil { + return nil, err + } + counts, err := packedInts(proto, countField) + if err != nil { + return nil, err + } + if len(ids) == 0 && len(types) == len(counts) { + ids = make([]uint64, len(types)) + } + if len(types) != len(ids) || len(ids) != len(counts) { + // A design row may be a mission marker with only display/condition + // values populated. Keeping an empty reward list preserves the + // claimable identity without inventing an item from partial arrays. + return nil, nil + } + rewards := make([]Reward, len(types)) + for i := range rewards { + if types[i] == 0 || counts[i] == 0 { + return nil, fmt.Errorf("invalid reward at %d", i) + } + rewards[i] = Reward{Type: types[i], ID: ids[i], Count: counts[i]} + } + return rewards, nil +} diff --git a/go/internal/gamedata/pack_transition.go b/go/internal/gamedata/pack_transition.go new file mode 100644 index 0000000..fbb6412 --- /dev/null +++ b/go/internal/gamedata/pack_transition.go @@ -0,0 +1,120 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" +) + +// LoadQuestPackChain opens the encrypted quest database once and follows the +// authoritative PackTable.NextPackId chain. Every pack receives its own quest +// map because quest IDs are only unique inside a pack. +func LoadQuestPackChain(root, version string, startPackID int) (map[int]map[int]QuestDesign, map[int]PackTransition, error) { + if startPackID <= 0 { + return nil, nil, fmt.Errorf("gamedata: invalid story start pack %d", startPackID) + } + plain, err := ReadQuestDatabase(root, version) + if err != nil { + return nil, nil, err + } + dir, err := os.MkdirTemp("", "bd2-pack-chain-") + 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() + + packs := make(map[int]map[int]QuestDesign) + transitions := make(map[int]PackTransition) + visited := make(map[int]bool) + for packID := startPackID; packID != 0; { + if visited[packID] { + return nil, nil, fmt.Errorf("gamedata: cyclic next-pack edge at %d", packID) + } + if len(visited) >= 64 { + return nil, nil, fmt.Errorf("gamedata: story pack chain exceeds 64 packs") + } + visited[packID] = true + quests, err := loadQuestDesignDB(db, packID) + if err != nil { + return nil, nil, err + } + if len(quests) == 0 { + return nil, nil, fmt.Errorf("gamedata: QuestTable%d is empty", packID) + } + packs[packID] = quests + + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", packID).Scan(&raw); err != nil { + return nil, nil, fmt.Errorf("gamedata: pack %d: %w", packID, err) + } + next, err := packedInts(raw, 45) + if err != nil || len(next) > 1 { + return nil, nil, fmt.Errorf("gamedata: pack %d has malformed next-pack edge", packID) + } + nextPackID := 0 + if len(next) == 1 { + nextPackID = int(next[0]) + } + transitions[packID] = PackTransition{PackID: packID, NextPackID: nextPackID} + packID = nextPackID + } + return packs, transitions, nil +} + +// PackTransition is the authoritative story edge in PackTable. It is kept +// separate from quest IDs because each story pack owns its own QuestTable. +type PackTransition struct { + PackID int + NextPackID int +} + +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) + 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() + 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) + } + next, err := packedInts(raw, 45) // PackTable.NextPackId + if err != nil || len(next) != 1 || next[0] == 0 { + return PackTransition{}, fmt.Errorf("gamedata: pack %d has invalid next-pack edge", packID) + } + var nextPack []byte + if err := db.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", next[0]).Scan(&nextPack); err != nil { + return PackTransition{}, fmt.Errorf("gamedata: next pack %d: %w", next[0], err) + } + var questCount int + if err := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM QuestTable%d", next[0])).Scan(&questCount); err != nil || questCount == 0 { + return PackTransition{}, fmt.Errorf("gamedata: next pack %d has no quests", next[0]) + } + return PackTransition{PackID: packID, NextPackID: int(next[0])}, nil +} diff --git a/go/internal/gamedata/pictorial.go b/go/internal/gamedata/pictorial.go new file mode 100644 index 0000000..6b9aca2 --- /dev/null +++ b/go/internal/gamedata/pictorial.go @@ -0,0 +1,301 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "sort" +) + +const ( + PictorialCharacter = 1 + PictorialTalent = 2 + PictorialEquipment = 3 + PictorialCooking = 4 + PictorialItem = 5 + PictorialCostume = 7 +) + +type PictorialBuff struct { + ID uint64 + Category uint64 + StatType uint64 + Value float64 +} + +type PictorialBuffStat struct { + Category uint64 + StatType uint64 + Value float64 +} + +type CharacterPictorial struct { + ID uint64 + UniqueIDs []uint64 + BuffID uint64 +} + +type TalentPictorial struct { + ID uint64 + TalentIDs []uint64 +} + +type CostumePictorial struct { + ID uint64 + CostumeID uint64 + BuffIDs []uint64 + Thresholds []uint64 +} + +type EquipmentPictorial struct { + ID uint64 + EquipmentID uint64 + BuffID uint64 +} + +type CookingPictorial struct { + ID uint64 + RecipeID uint64 + BuffID uint64 +} + +type ItemPictorial struct { + ID uint64 + ItemID uint64 + BuffID uint64 + Category uint64 +} + +type CharacterPictorialMeta struct { + UniqueID uint64 + TalentID uint64 + UsePackTemporary bool +} + +// PictorialDesign is immutable 2.34.13 design data. Dynamic account state is +// deliberately not copied here: callers match their current ownership against +// these rows each time they build PictorialBookInfo or AllCharRefresh. +type PictorialDesign struct { + Root string + Version string + Characters []CharacterPictorial + Talents []TalentPictorial + Costumes []CostumePictorial + Equipment []EquipmentPictorial + Cooking []CookingPictorial + Items []ItemPictorial + Buffs map[uint64]PictorialBuff + CharMeta map[uint64]CharacterPictorialMeta +} + +func LoadPictorialDesign(root, version string) (*PictorialDesign, error) { + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer closeDB() + d := &PictorialDesign{Root: root, Version: version, Buffs: map[uint64]PictorialBuff{}, CharMeta: map[uint64]CharacterPictorialMeta{}} + if err := loadProtoRows(db, "CharacterPictorialBookTable", func(proto []byte) error { + id, err := exactlyOne(proto, 5) + if err != nil { + return err + } + buff, err := exactlyOne(proto, 2) + if err != nil { + return err + } + ids, err := packedInts(proto, 1) + if err != nil || len(ids) == 0 { + return fmt.Errorf("gamedata: character pictorial %d ids: %w", id, err) + } + d.Characters = append(d.Characters, CharacterPictorial{ID: id, UniqueIDs: ids, BuffID: buff}) + return nil + }); err != nil { + return nil, err + } + if err := loadProtoRows(db, "TalentPictorialBookTable", func(proto []byte) error { + id, err := exactlyOne(proto, 1) + if err != nil { + return err + } + ids, err := packedInts(proto, 2) + if err != nil || len(ids) == 0 { + return fmt.Errorf("gamedata: talent pictorial %d ids: %w", id, err) + } + d.Talents = append(d.Talents, TalentPictorial{ID: id, TalentIDs: ids}) + return nil + }); err != nil { + return nil, err + } + if err := loadProtoRows(db, "CostumePictorialBookTable", func(proto []byte) error { + id, err := exactlyOne(proto, 5) + if err != nil { + return err + } + costumeID, err := exactlyOne(proto, 3) + if err != nil { + return err + } + buffs, err := packedInts(proto, 2) + if err != nil { + return err + } + thresholds, err := packedInts(proto, 4) + if err != nil || len(buffs) == 0 || len(buffs) != len(thresholds) { + return fmt.Errorf("gamedata: costume pictorial %d has mismatched buff thresholds", id) + } + d.Costumes = append(d.Costumes, CostumePictorial{ID: id, CostumeID: costumeID, BuffIDs: buffs, Thresholds: thresholds}) + return nil + }); err != nil { + return nil, err + } + if err := loadProtoRows(db, "EquipmentPictorialBookTable", func(proto []byte) error { + id, err := exactlyOne(proto, 5) + if err != nil { + return err + } + equipmentID, err := exactlyOne(proto, 4) + if err != nil { + return err + } + buff, err := exactlyOne(proto, 2) + if err != nil { + return err + } + d.Equipment = append(d.Equipment, EquipmentPictorial{ID: id, EquipmentID: equipmentID, BuffID: buff}) + return nil + }); err != nil { + return nil, err + } + if err := loadProtoRows(db, "CookingPictorialBookTable", func(proto []byte) error { + buff, err := exactlyOne(proto, 1) + if err != nil { + return err + } + recipe, err := exactlyOne(proto, 2) + if err != nil { + return err + } + id, err := exactlyOne(proto, 3) + if err != nil { + return err + } + d.Cooking = append(d.Cooking, CookingPictorial{ID: id, RecipeID: recipe, BuffID: buff}) + return nil + }); err != nil { + return nil, err + } + if err := loadProtoRows(db, "ItemPictorialBookTable", func(proto []byte) error { + category, err := zeroOrOne(proto, 1) + if err != nil { + return err + } + buff, err := exactlyOne(proto, 2) + if err != nil { + return err + } + id, err := exactlyOne(proto, 3) + if err != nil { + return err + } + item, err := exactlyOne(proto, 4) + if err != nil { + return err + } + d.Items = append(d.Items, ItemPictorial{ID: id, ItemID: item, BuffID: buff, Category: category}) + return nil + }); err != nil { + return nil, err + } + if err := loadProtoRows(db, "AdventuregroupBuffTable", func(proto []byte) error { + id, err := exactlyOne(proto, 2) + if err != nil { + return err + } + stat, err := exactlyOne(proto, 3) + if err != nil { + return err + } + category, err := zeroOrOne(proto, 1) + if err != nil { + return err + } + value, found, err := fixed64Double(proto, 4) + if err != nil { + return fmt.Errorf("gamedata: pictorial buff %d value: %w", id, err) + } + if !found || value <= 0 { + // Many collection-discovery rows have no battle-stat reward. + return nil + } + d.Buffs[id] = PictorialBuff{ID: id, Category: category, StatType: stat, Value: value} + return nil + }); err != nil { + return nil, err + } + if err := loadProtoRows(db, "CharTable", func(proto []byte) error { + id, err := exactlyOne(proto, 12) + if err != nil { + return err + } + uniqueID, err := exactlyOne(proto, 20) + if err != nil { + return err + } + talentID, err := zeroOrOne(proto, 18) + if err != nil { + return err + } + temporary, err := zeroOrOne(proto, 21) + if err != nil { + return err + } + d.CharMeta[id] = CharacterPictorialMeta{UniqueID: uniqueID, TalentID: talentID, UsePackTemporary: temporary != 0} + return nil + }); err != nil { + return nil, err + } + sort.Slice(d.Characters, func(i, j int) bool { return d.Characters[i].ID < d.Characters[j].ID }) + sort.Slice(d.Talents, func(i, j int) bool { return d.Talents[i].ID < d.Talents[j].ID }) + sort.Slice(d.Costumes, func(i, j int) bool { return d.Costumes[i].ID < d.Costumes[j].ID }) + sort.Slice(d.Equipment, func(i, j int) bool { return d.Equipment[i].ID < d.Equipment[j].ID }) + sort.Slice(d.Cooking, func(i, j int) bool { return d.Cooking[i].ID < d.Cooking[j].ID }) + sort.Slice(d.Items, func(i, j int) bool { return d.Items[i].ID < d.Items[j].ID }) + return d, nil +} + +func loadProtoRows(db *sql.DB, table string, consume func([]byte) error) error { + rows, err := db.Query("SELECT ProtoBuf FROM " + table) + if err != nil { + return fmt.Errorf("gamedata: query %s: %w", table, err) + } + defer rows.Close() + for rows.Next() { + var proto []byte + if err := rows.Scan(&proto); err != nil { + return err + } + if err := consume(proto); err != nil { + return fmt.Errorf("gamedata: decode %s: %w", table, err) + } + } + return rows.Err() +} + +func exactlyOne(proto []byte, field int) (uint64, error) { + values, err := packedInts(proto, field) + if err != nil || len(values) != 1 { + return 0, fmt.Errorf("field %d values %v: %w", field, values, err) + } + return values[0], nil +} + +func zeroOrOne(proto []byte, field int) (uint64, error) { + values, err := packedInts(proto, field) + if err != nil || len(values) > 1 { + return 0, fmt.Errorf("field %d values %v: %w", field, values, err) + } + if len(values) == 0 { + return 0, nil + } + return values[0], nil +} diff --git a/go/internal/gamedata/quest_formation.go b/go/internal/gamedata/quest_formation.go new file mode 100644 index 0000000..e157f54 --- /dev/null +++ b/go/internal/gamedata/quest_formation.go @@ -0,0 +1,247 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// QuestFormation is the static party design attached to a quest. The IDs in +// this structure are GameData design IDs, not player inventory indices. +// Runtime code must resolve them against the current account before emitting +// DeckDBInfo. +type QuestFormation struct { + QuestID uint64 + CharGroupID uint64 + StoryCharGroupID uint64 + DeckList []uint64 + Characters []QuestCharacterDesign + StoryCostumes []QuestCostumeDesign +} + +// QuestCharacterDesign is one row from CharGroupTable. Order is the row's Id +// inside the group and Level is the story-provided level. +type QuestCharacterDesign struct { + Order uint64 + CharacterID uint64 + Level uint64 + Banned bool +} + +// QuestCostumeDesign is one row from StoryCharGroupTable. UniqueCharacterID +// comes from CostumeTable.UseUniqueCharId and is useful for matching a story +// costume to its character design. Costume 996000 is the official placeholder +// used for an account-owned party slot; callers must not treat it as owned. +type QuestCostumeDesign struct { + Order uint64 + CostumeID uint64 + UniqueCharacterID uint64 +} + +// LoadQuestFormations reads all static formation definitions for one pack +// from the authoritative shared quest database. It intentionally does not +// invent player inventory indices or positions for an empty DeckList. +func LoadQuestFormations(root, version string, packID int) (map[int]QuestFormation, error) { + if packID <= 0 { + return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID) + } + plain, err := ReadQuestDatabase(root, version) + 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() + return loadQuestFormationsDB(db, packID) +} + +func loadQuestFormationsDB(db *sql.DB, packID int) (map[int]QuestFormation, error) { + if db == nil || packID <= 0 { + return nil, fmt.Errorf("gamedata: invalid quest formation source") + } + rows, err := db.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d ORDER BY id", packID)) + if err != nil { + return nil, fmt.Errorf("gamedata: query QuestTable%d formations: %w", packID, err) + } + defer rows.Close() + + formations := make(map[int]QuestFormation) + charGroups := make(map[uint64][]QuestCharacterDesign) + storyGroups := make(map[uint64][]QuestCostumeDesign) + 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) + } + charGroupID, err := optionalScalar(proto, 4) + if err != nil { + return nil, fmt.Errorf("gamedata: quest %d char group: %w", questID, err) + } + deckList, err := packedInts(proto, 12) + if err != nil { + return nil, fmt.Errorf("gamedata: quest %d deck list: %w", questID, err) + } + storyGroupID, err := optionalScalar(proto, 61) + if err != nil { + return nil, fmt.Errorf("gamedata: quest %d story group: %w", questID, err) + } + formation := QuestFormation{ + QuestID: uint64(questID), + CharGroupID: charGroupID, + StoryCharGroupID: storyGroupID, + DeckList: append([]uint64(nil), deckList...), + } + if charGroupID != 0 { + group, ok := charGroups[charGroupID] + if !ok { + group, err = loadCharacterGroup(db, charGroupID) + if err != nil { + return nil, fmt.Errorf("gamedata: quest %d: %w", questID, err) + } + charGroups[charGroupID] = group + } + formation.Characters = append([]QuestCharacterDesign(nil), group...) + } + if storyGroupID != 0 { + group, ok := storyGroups[storyGroupID] + if !ok { + group, err = loadStoryCostumeGroup(db, storyGroupID) + if err != nil { + return nil, fmt.Errorf("gamedata: quest %d: %w", questID, err) + } + storyGroups[storyGroupID] = group + } + formation.StoryCostumes = append([]QuestCostumeDesign(nil), group...) + } + formations[questID] = formation + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("gamedata: iterate QuestTable%d formations: %w", packID, err) + } + return formations, nil +} + +func loadCharacterGroup(db *sql.DB, groupID uint64) ([]QuestCharacterDesign, error) { + rows, err := db.Query("SELECT id,ProtoBuf FROM CharGroupTable WHERE GroupId=? ORDER BY id", groupID) + if err != nil { + return nil, fmt.Errorf("query character group %d: %w", groupID, err) + } + defer rows.Close() + var result []QuestCharacterDesign + for rows.Next() { + var rowID int + var proto []byte + if err := rows.Scan(&rowID, &proto); err != nil { + return nil, err + } + characterID, err := requiredScalar(proto, 1) + if err != nil { + return nil, fmt.Errorf("character group %d row %d: %w", groupID, rowID, err) + } + order, err := requiredScalar(proto, 3) + if err != nil { + return nil, fmt.Errorf("character group %d row %d: %w", groupID, rowID, err) + } + banned, err := optionalScalar(proto, 4) + if err != nil { + return nil, fmt.Errorf("character group %d row %d banned: %w", groupID, rowID, err) + } + level, err := requiredScalar(proto, 5) + if err != nil { + return nil, fmt.Errorf("character group %d row %d level: %w", groupID, rowID, err) + } + result = append(result, QuestCharacterDesign{Order: order, CharacterID: characterID, Level: level, Banned: banned != 0}) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("character group %d is empty", groupID) + } + return result, nil +} + +func loadStoryCostumeGroup(db *sql.DB, groupID uint64) ([]QuestCostumeDesign, error) { + rows, err := db.Query("SELECT id,ProtoBuf FROM StoryCharGroupTable WHERE GroupId=? ORDER BY id", groupID) + if err != nil { + return nil, fmt.Errorf("query story costume group %d: %w", groupID, err) + } + defer rows.Close() + var result []QuestCostumeDesign + for rows.Next() { + var rowID int + var proto []byte + if err := rows.Scan(&rowID, &proto); err != nil { + return nil, err + } + costumeID, err := requiredScalar(proto, 1) + if err != nil { + return nil, fmt.Errorf("story costume group %d row %d: %w", groupID, rowID, err) + } + order, err := requiredScalar(proto, 3) + if err != nil { + return nil, fmt.Errorf("story costume group %d row %d: %w", groupID, rowID, err) + } + var costumeProto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CostumeTable WHERE id=?", costumeID).Scan(&costumeProto); err != nil { + return nil, fmt.Errorf("story costume %d: %w", costumeID, err) + } + uniqueCharacterID, err := requiredScalar(costumeProto, 27) + if err != nil { + return nil, fmt.Errorf("story costume %d unique character: %w", costumeID, err) + } + result = append(result, QuestCostumeDesign{Order: order, CostumeID: costumeID, UniqueCharacterID: uniqueCharacterID}) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("story costume group %d is empty", groupID) + } + return result, nil +} + +func optionalScalar(proto []byte, field int) (uint64, error) { + values, err := packedInts(proto, field) + if err != nil { + return 0, err + } + if len(values) == 0 { + return 0, nil + } + if len(values) != 1 { + return 0, fmt.Errorf("field %d has %d scalar values", field, len(values)) + } + return values[0], nil +} + +func requiredScalar(proto []byte, field int) (uint64, error) { + value, err := optionalScalar(proto, field) + if err != nil { + return 0, err + } + if value == 0 { + return 0, fmt.Errorf("field %d is missing or zero", field) + } + return value, nil +} diff --git a/go/internal/gamedata/quest_formation_test.go b/go/internal/gamedata/quest_formation_test.go new file mode 100644 index 0000000..746a1e9 --- /dev/null +++ b/go/internal/gamedata/quest_formation_test.go @@ -0,0 +1,108 @@ +package gamedata + +import ( + "database/sql" + "os" + "path/filepath" + "reflect" + "testing" + + "bd2server/internal/wire" +) + +func TestLoadQuestFormationsDB(t *testing.T) { + path := filepath.Join(t.TempDir(), "quest.sqlite") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + 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)", + "CREATE TABLE StoryCharGroupTable (id INTEGER NOT NULL, GroupId INTEGER NOT NULL, ProtoBuf BLOB NOT NULL)", + "CREATE TABLE CostumeTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB NOT NULL)", + } { + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } + } + quest := wire.AppendVarint(nil, 4, 2102) + quest = wire.AppendVarint(quest, 12, 9) + quest = wire.AppendVarint(quest, 12, 7) + quest = wire.AppendVarint(quest, 61, 2103) + if _, err := db.Exec("INSERT INTO QuestTable21(id,ProtoBuf) VALUES(29,?)", quest); err != nil { + t.Fatal(err) + } + character := wire.AppendVarint(nil, 1, 10383) + character = wire.AppendVarint(character, 2, 2102) + character = wire.AppendVarint(character, 3, 1) + character = wire.AppendVarint(character, 5, 15) + if _, err := db.Exec("INSERT INTO CharGroupTable(id,GroupId,ProtoBuf) VALUES(1,2102,?)", character); err != nil { + t.Fatal(err) + } + story := wire.AppendVarint(nil, 1, 3801) + story = wire.AppendVarint(story, 2, 2103) + story = wire.AppendVarint(story, 3, 3) + if _, err := db.Exec("INSERT INTO StoryCharGroupTable(id,GroupId,ProtoBuf) VALUES(3,2103,?)", story); err != nil { + t.Fatal(err) + } + costume := wire.AppendVarint(nil, 11, 3801) + costume = wire.AppendVarint(costume, 27, 38) + if _, err := db.Exec("INSERT INTO CostumeTable(id,ProtoBuf) VALUES(3801,?)", costume); err != nil { + t.Fatal(err) + } + + formations, err := loadQuestFormationsDB(db, 21) + if err != nil { + t.Fatal(err) + } + want := QuestFormation{ + QuestID: 29, + CharGroupID: 2102, + StoryCharGroupID: 2103, + DeckList: []uint64{9, 7}, + Characters: []QuestCharacterDesign{{Order: 1, CharacterID: 10383, Level: 15}}, + StoryCostumes: []QuestCostumeDesign{{Order: 3, CostumeID: 3801, UniqueCharacterID: 38}}, + } + if got := formations[29]; !reflect.DeepEqual(got, want) { + t.Fatalf("formation mismatch\n got: %#v\nwant: %#v", got, want) + } +} + +func TestRealQuest21Formations(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + formations, err := LoadQuestFormations(root, "20260910162539", 21) + if err != nil { + t.Fatal(err) + } + quest29 := formations[29] + if quest29.CharGroupID != 2102 || quest29.StoryCharGroupID != 2103 || len(quest29.DeckList) != 0 { + t.Fatalf("unexpected quest 29 groups: %#v", quest29) + } + wantCharacters := []QuestCharacterDesign{ + {Order: 1, CharacterID: 10383, Level: 15}, + {Order: 2, CharacterID: 10372, Level: 15}, + {Order: 3, CharacterID: 10412, Level: 15}, + } + if !reflect.DeepEqual(quest29.Characters, wantCharacters) { + t.Fatalf("quest 29 characters = %#v", quest29.Characters) + } + wantCostumes := []QuestCostumeDesign{ + {Order: 1, CostumeID: 996000, UniqueCharacterID: 9960}, + {Order: 2, CostumeID: 3501, UniqueCharacterID: 35}, + {Order: 3, CostumeID: 3801, UniqueCharacterID: 38}, + {Order: 4, CostumeID: 3701, UniqueCharacterID: 37}, + {Order: 5, CostumeID: 4101, UniqueCharacterID: 41}, + } + if !reflect.DeepEqual(quest29.StoryCostumes, wantCostumes) { + t.Fatalf("quest 29 story costumes = %#v", quest29.StoryCostumes) + } + quest31 := formations[31] + if quest31.StoryCharGroupID != 2107 || len(quest31.StoryCostumes) != 3 { + t.Fatalf("unexpected quest 31 formation: %#v", quest31) + } +} diff --git a/go/internal/gamedata/quests.go b/go/internal/gamedata/quests.go new file mode 100644 index 0000000..0e87816 --- /dev/null +++ b/go/internal/gamedata/quests.go @@ -0,0 +1,96 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// QuestDesign is the authoritative reward definition from QuestTable. +// Reward slot zero is normal story difficulty; later slots are separate +// difficulty tiers and must never be added to the same clear. +type QuestDesign struct { + ID int + Rewards [5][]Reward +} + +func LoadQuestDesign(root, version string, packID int) (map[int]QuestDesign, error) { + if packID <= 0 { + return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID) + } + plain, err := ReadQuestDatabase(root, version) + 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() + return loadQuestDesignDB(db, packID) +} + +func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) { + if db == nil || packID <= 0 { + return nil, fmt.Errorf("gamedata: invalid quest design configuration") + } + rows, err := db.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d ORDER BY id", packID)) + if err != nil { + return nil, fmt.Errorf("gamedata: query QuestTable%d: %w", packID, err) + } + defer rows.Close() + result := make(map[int]QuestDesign) + for rows.Next() { + var id int + var proto []byte + if err := rows.Scan(&id, &proto); err != nil { + return nil, err + } + if id <= 0 { + continue + } + entry := QuestDesign{ID: id} + for slot := 0; slot < len(entry.Rewards); slot++ { + types, err := packedInts(proto, 56+slot) + if err != nil { + return nil, fmt.Errorf("gamedata: quest %d reward slot %d type: %w", id, slot, err) + } + ids, err := packedInts(proto, 51+slot) + if err != nil { + return nil, fmt.Errorf("gamedata: quest %d reward slot %d id: %w", id, slot, err) + } + counts, err := packedInts(proto, 46+slot) + if err != nil { + return nil, fmt.Errorf("gamedata: quest %d reward slot %d count: %w", id, slot, err) + } + if len(types) != len(ids) || len(ids) != len(counts) { + return nil, fmt.Errorf("gamedata: quest %d reward slot %d has mismatched arrays", id, slot) + } + for i := range types { + // Currency has id zero; non-stackable costume/equipment rewards + // have count zero. Both are valid GameData representations. + if types[i] == 0 { + return nil, fmt.Errorf("gamedata: quest %d reward slot %d has zero type", id, slot) + } + entry.Rewards[slot] = append(entry.Rewards[slot], Reward{Type: types[i], ID: ids[i], Count: counts[i]}) + } + } + result[id] = entry + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} diff --git a/go/internal/gamedata/stat_design.go b/go/internal/gamedata/stat_design.go new file mode 100644 index 0000000..18b2c43 --- /dev/null +++ b/go/internal/gamedata/stat_design.go @@ -0,0 +1,207 @@ +package gamedata + +import ( + "database/sql" + "encoding/binary" + "fmt" + "math" + "os" + "path/filepath" + + "bd2server/internal/wire" + _ "modernc.org/sqlite" +) + +// EquipmentOption identifies the exact server-owned option stored in +// EquipBaseInfo. Rank values use Define_EquipRankType: 0=None, 1=C ... 4=S. +type EquipmentOption struct { + GroupID uint64 + ID uint64 + Level int + Rank [3]int +} + +// CharacterBaseStats reads CharTable -> CharGrowthTable -> CharLevelTable and +// applies the client's level formula. It returns design stats only; account +// systems such as equipment and costumes are aggregated separately. +func CharacterBaseStats(root, version string, charID, level int) (BaseStats, error) { + if charID <= 0 || level <= 0 { + return BaseStats{}, fmt.Errorf("gamedata: invalid character stat input") + } + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return BaseStats{}, err + } + defer closeDB() + var character []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&character); err != nil { + return BaseStats{}, fmt.Errorf("gamedata: character stats %d: %w", charID, err) + } + growthIDs, err := packedInts(character, 1) + if err != nil || len(growthIDs) != 1 { + return BaseStats{}, fmt.Errorf("gamedata: character %d growth id %v: %w", charID, growthIDs, err) + } + var growth []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growth); err != nil { + return BaseStats{}, err + } + groups, err := packedInts(growth, 1) + if err != nil || len(groups) != 1 { + return BaseStats{}, fmt.Errorf("gamedata: character growth group %v: %w", groups, err) + } + var levelProto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", groups[0], level).Scan(&levelProto); err != nil { + return BaseStats{}, fmt.Errorf("gamedata: character level group=%d level=%d: %w", groups[0], level, err) + } + baseHP, ok, err := fixed64Double(character, 11) + if err != nil || !ok { + return BaseStats{}, fmt.Errorf("gamedata: character %d health: %w", charID, err) + } + baseAttack, _, err := fixed64Double(character, 17) + if err != nil { + return BaseStats{}, err + } + baseMagic, _, err := fixed64Double(character, 14) + if err != nil { + return BaseStats{}, err + } + hpRatio, _, err := fixed64Double(levelProto, 6) + if err != nil { + return BaseStats{}, err + } + attackRatio, _, err := fixed64Double(levelProto, 12) + if err != nil { + return BaseStats{}, err + } + magicRatio, _, err := fixed64Double(levelProto, 10) + if err != nil { + return BaseStats{}, err + } + return BaseStats{Health: math.Trunc(baseHP * (1 + hpRatio)), Attack: math.Trunc(baseAttack * (1 + attackRatio)), Magic: math.Trunc(baseMagic * (1 + magicRatio))}, nil +} + +// EquipmentOptionContribution resolves an EquipOptionInfo through the real +// EquipmentOptionTable, including level and three rank breakpoints. +func EquipmentOptionContribution(root, version string, option EquipmentOption) (StatContribution, error) { + if option.GroupID == 0 || option.ID == 0 || option.Level < 0 { + return StatContribution{}, fmt.Errorf("gamedata: invalid equipment option") + } + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return StatContribution{}, err + } + defer closeDB() + var proto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentOptionTable WHERE GroupId=? AND id=?", option.GroupID, option.ID).Scan(&proto); err != nil { + return StatContribution{}, fmt.Errorf("gamedata: equipment option %d/%d: %w", option.GroupID, option.ID, err) + } + defaultValue, _, err := fixed64Double(proto, 1) + if err != nil { + return StatContribution{}, err + } + growthValue, _, err := fixed64Double(proto, 4) + if err != nil { + return StatContribution{}, err + } + levels, err := fixed32Floats(proto, 6) + if err != nil { + return StatContribution{}, err + } + if len(levels) != 0 && option.Level >= len(levels) { + return StatContribution{}, fmt.Errorf("gamedata: equipment level %d outside option curve", option.Level) + } + levelValue := 0.0 + if len(levels) != 0 { + levelValue = levels[option.Level] + } + rankValue := 0.0 + for i, field := range []int{7, 8, 9} { + values, err := fixed32Floats(proto, field) + if err != nil { + return StatContribution{}, err + } + rank := option.Rank[i] + if rank == 0 { + continue + } + if rank < 1 || rank > len(values) { + return StatContribution{}, fmt.Errorf("gamedata: invalid equipment rank %d", rank) + } + rankValue += values[rank-1] + } + return optionContribution(option.ID, defaultValue+growthValue*(levelValue+rankValue)) +} + +func optionContribution(id uint64, value float64) (StatContribution, error) { + percentage := id != 1 && id != 3 && id != 5 + digits := 0 + if percentage { + digits = 4 + } + factor := math.Pow10(digits) + value = math.Round(value*factor) / factor + contribution := StatContribution{} + switch id { + case 1, 2: + contribution.Stat = StatHealth + case 3, 4: + contribution.Stat = StatAttack + case 5, 6: + contribution.Stat = StatMagic + case 7: + contribution.Stat = StatDefencePercent + case 8: + contribution.Stat = StatMagicResistancePercent + default: + return StatContribution{}, fmt.Errorf("gamedata: unsupported equipment stat option %d", id) + } + if percentage { + contribution.Percent = value / 100 + } else { + contribution.Flat = value + } + return contribution, nil +} + +func fixed32Floats(proto []byte, number int) ([]float64, error) { + var values []float64 + err := wire.Walk(proto, func(field wire.Field) error { + if field.Number != number { + return nil + } + if field.Type == 5 { + values = append(values, float64(math.Float32frombits(binary.LittleEndian.Uint32(field.Value)))) + return nil + } + if field.Type != 2 || len(field.Value)%4 != 0 { + return fmt.Errorf("field %d invalid float encoding", number) + } + for data := field.Value; len(data) != 0; data = data[4:] { + values = append(values, float64(math.Float32frombits(binary.LittleEndian.Uint32(data)))) + } + return nil + }) + return values, err +} + +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 +} diff --git a/go/internal/gamedata/validate.go b/go/internal/gamedata/validate.go new file mode 100644 index 0000000..50f8e52 --- /dev/null +++ b/go/internal/gamedata/validate.go @@ -0,0 +1,282 @@ +// Package gamedata validates the versioned archive served to the client. +package gamedata + +import ( + "archive/zip" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + ArchiveName = "common-dbdata.bin" + InfoName = "common-dbdata.info" +) + +// Result describes a completely readable GameData archive. Validation reads +// every entry to EOF, which makes archive/zip verify each entry's CRC32. +type Result struct { + ArchivePath string + ArchiveSize int64 + EntryCount int + UncompressedSize int64 +} + +// Ensure returns a verified local archive. If local validation fails, it +// downloads the official size metadata and archive into staging files, +// validates the complete ZIP (including every entry CRC), and only then +// replaces the bad files. Existing files are preserved as .broken.* backups. +func Ensure(ctx context.Context, client *http.Client, root, version, origin string) (Result, bool, error) { + if result, err := Validate(root, version); err == nil { + return result, false, nil + } + if client == nil { + client = &http.Client{Timeout: 30 * time.Minute} + } + origin = strings.TrimRight(origin, "/") + if origin == "" { + return Result{}, false, fmt.Errorf("gamedata: download origin is empty") + } + release := filepath.Join(root, version, "release") + if err := os.MkdirAll(release, 0o755); err != nil { + return Result{}, false, fmt.Errorf("gamedata: create release directory: %w", err) + } + baseURL := origin + "/" + version + "/release/" + + infoBytes, err := downloadSmall(ctx, client, baseURL+InfoName, 1024) + if err != nil { + return Result{}, false, err + } + expected, err := parseExpectedSize(infoBytes) + if err != nil { + return Result{}, false, err + } + + archiveStage, err := os.CreateTemp(release, ".common-dbdata-*.tmp") + if err != nil { + return Result{}, false, fmt.Errorf("gamedata: create archive staging file: %w", err) + } + archiveStagePath := archiveStage.Name() + keepStage := false + defer func() { + _ = archiveStage.Close() + if !keepStage { + _ = os.Remove(archiveStagePath) + } + }() + if err := downloadExact(ctx, client, baseURL+ArchiveName, archiveStage, expected); err != nil { + return Result{}, false, err + } + if err := archiveStage.Sync(); err != nil { + return Result{}, false, fmt.Errorf("gamedata: sync staged archive: %w", err) + } + if err := archiveStage.Close(); err != nil { + return Result{}, false, fmt.Errorf("gamedata: close staged archive: %w", err) + } + verified, err := validateArchive(archiveStagePath, expected) + if err != nil { + return Result{}, false, fmt.Errorf("gamedata: downloaded archive failed validation: %w", err) + } + + infoStage, err := os.CreateTemp(release, ".common-dbdata-info-*.tmp") + if err != nil { + return Result{}, false, fmt.Errorf("gamedata: create info staging file: %w", err) + } + infoStagePath := infoStage.Name() + defer os.Remove(infoStagePath) + if _, err := infoStage.Write(infoBytes); err != nil { + infoStage.Close() + return Result{}, false, fmt.Errorf("gamedata: write staged info: %w", err) + } + if err := infoStage.Sync(); err != nil { + infoStage.Close() + return Result{}, false, fmt.Errorf("gamedata: sync staged info: %w", err) + } + if err := infoStage.Close(); err != nil { + return Result{}, false, fmt.Errorf("gamedata: close staged info: %w", err) + } + + archivePath := filepath.Join(release, ArchiveName) + infoPath := filepath.Join(release, InfoName) + stamp := time.Now().UTC().Format("20060102T150405.000000000Z") + archiveBackup, archiveMoved, err := preserveExisting(archivePath, stamp) + if err != nil { + return Result{}, false, err + } + infoBackup, infoMoved, err := preserveExisting(infoPath, stamp) + if err != nil { + if archiveMoved { + _ = os.Rename(archiveBackup, archivePath) + } + return Result{}, false, err + } + rollback := func() { + _ = os.Remove(archivePath) + _ = os.Remove(infoPath) + if archiveMoved { + _ = os.Rename(archiveBackup, archivePath) + } + if infoMoved { + _ = os.Rename(infoBackup, infoPath) + } + } + if err := os.Rename(archiveStagePath, archivePath); err != nil { + rollback() + return Result{}, false, fmt.Errorf("gamedata: install verified archive: %w", err) + } + keepStage = true + if err := os.Rename(infoStagePath, infoPath); err != nil { + rollback() + return Result{}, false, fmt.Errorf("gamedata: install size metadata: %w", err) + } + + verified.ArchivePath = archivePath + return verified, true, nil +} + +func Validate(root, version string) (Result, error) { + if root == "" || version == "" || strings.ContainsAny(version, `/\\`) { + return Result{}, fmt.Errorf("gamedata: invalid root or version") + } + release := filepath.Join(root, version, "release") + archivePath := filepath.Join(release, ArchiveName) + infoPath := filepath.Join(release, InfoName) + + infoBytes, err := os.ReadFile(infoPath) + if err != nil { + return Result{}, fmt.Errorf("gamedata: read size metadata: %w", err) + } + expected, err := parseExpectedSize(infoBytes) + if err != nil { + return Result{}, err + } + stat, err := os.Stat(archivePath) + if err != nil { + return Result{}, fmt.Errorf("gamedata: stat archive: %w", err) + } + if stat.Size() != expected { + return Result{}, fmt.Errorf("gamedata: archive size %d does not match metadata %d", stat.Size(), expected) + } + + return validateArchive(archivePath, expected) +} + +func validateArchive(archivePath string, expected int64) (Result, error) { + stat, err := os.Stat(archivePath) + if err != nil { + return Result{}, fmt.Errorf("gamedata: stat archive: %w", err) + } + if stat.Size() != expected { + return Result{}, fmt.Errorf("gamedata: archive size %d does not match metadata %d", stat.Size(), expected) + } + reader, err := zip.OpenReader(archivePath) + if err != nil { + return Result{}, fmt.Errorf("gamedata: open archive: %w", err) + } + defer reader.Close() + if len(reader.File) == 0 { + return Result{}, fmt.Errorf("gamedata: archive is empty") + } + + var unpacked int64 + for index, entry := range reader.File { + stream, err := entry.Open() + if err != nil { + return Result{}, fmt.Errorf("gamedata: open entry %d %q: %w", index, entry.Name, err) + } + read, copyErr := io.Copy(io.Discard, stream) + closeErr := stream.Close() + if copyErr != nil { + return Result{}, fmt.Errorf("gamedata: verify entry %d %q: %w", index, entry.Name, copyErr) + } + if closeErr != nil { + return Result{}, fmt.Errorf("gamedata: close entry %d %q: %w", index, entry.Name, closeErr) + } + if read != int64(entry.UncompressedSize64) { + return Result{}, fmt.Errorf("gamedata: entry %d %q size %d, expected %d", index, entry.Name, read, entry.UncompressedSize64) + } + unpacked += read + } + + return Result{ + ArchivePath: archivePath, + ArchiveSize: stat.Size(), + EntryCount: len(reader.File), + UncompressedSize: unpacked, + }, nil +} + +func parseExpectedSize(infoBytes []byte) (int64, error) { + text := strings.TrimSpace(string(infoBytes)) + expected, err := strconv.ParseInt(text, 10, 64) + if err != nil || expected <= 0 { + return 0, fmt.Errorf("gamedata: invalid size metadata %q", text) + } + return expected, nil +} + +func downloadSmall(ctx context.Context, client *http.Client, url string, limit int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("gamedata: build metadata request: %w", err) + } + response, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("gamedata: download %s: %w", InfoName, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gamedata: download %s: HTTP %s", InfoName, response.Status) + } + b, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, fmt.Errorf("gamedata: read %s: %w", InfoName, err) + } + if int64(len(b)) > limit { + return nil, fmt.Errorf("gamedata: %s exceeds %d bytes", InfoName, limit) + } + return b, nil +} + +func downloadExact(ctx context.Context, client *http.Client, url string, target *os.File, expected int64) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("gamedata: build archive request: %w", err) + } + response, err := client.Do(req) + if err != nil { + return fmt.Errorf("gamedata: download %s: %w", ArchiveName, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("gamedata: download %s: HTTP %s", ArchiveName, response.Status) + } + written, err := io.Copy(target, io.LimitReader(response.Body, expected+1)) + if err != nil { + return fmt.Errorf("gamedata: write staged archive: %w", err) + } + if written != expected { + return fmt.Errorf("gamedata: downloaded archive size %d, expected %d", written, expected) + } + return nil +} + +func preserveExisting(path, stamp string) (string, bool, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, fmt.Errorf("gamedata: inspect existing file %q: %w", path, err) + } + backup := path + ".broken." + stamp + if err := os.Rename(path, backup); err != nil { + return "", false, fmt.Errorf("gamedata: preserve invalid file %q: %w", path, err) + } + return backup, true, nil +} diff --git a/go/internal/gamedata/validate_test.go b/go/internal/gamedata/validate_test.go new file mode 100644 index 0000000..0f12d9f --- /dev/null +++ b/go/internal/gamedata/validate_test.go @@ -0,0 +1,136 @@ +package gamedata + +import ( + "archive/zip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestValidate(t *testing.T) { + root := t.TempDir() + release := filepath.Join(root, "123", "release") + if err := os.MkdirAll(release, 0o755); err != nil { + t.Fatal(err) + } + archive := filepath.Join(release, ArchiveName) + f, err := os.Create(archive) + if err != nil { + t.Fatal(err) + } + w := zip.NewWriter(f) + entry, err := w.Create("common-hash") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte("verified payload")); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + stat, err := os.Stat(archive) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(release, InfoName), []byte(strconv.FormatInt(stat.Size(), 10)), 0o644); err != nil { + t.Fatal(err) + } + + result, err := Validate(root, "123") + if err != nil { + t.Fatal(err) + } + if result.EntryCount != 1 || result.UncompressedSize != int64(len("verified payload")) { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestValidateRejectsSizeMismatch(t *testing.T) { + root := t.TempDir() + release := filepath.Join(root, "123", "release") + if err := os.MkdirAll(release, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(release, ArchiveName), []byte("not a zip"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(release, InfoName), []byte("999"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Validate(root, "123"); err == nil { + t.Fatal("expected validation failure") + } +} + +func TestEnsureDownloadsAndPreservesBrokenArchive(t *testing.T) { + served := t.TempDir() + writeTestArchive(t, served, "123", []byte("healthy archive payload")) + origin := httptest.NewServer(http.FileServer(http.Dir(served))) + defer origin.Close() + + root := t.TempDir() + release := filepath.Join(root, "123", "release") + if err := os.MkdirAll(release, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(release, ArchiveName), []byte("broken"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(release, InfoName), []byte("6"), 0o644); err != nil { + t.Fatal(err) + } + + result, downloaded, err := Ensure(context.Background(), origin.Client(), root, "123", origin.URL) + if err != nil { + t.Fatal(err) + } + if !downloaded || result.EntryCount != 1 { + t.Fatalf("unexpected ensure result: downloaded=%v result=%+v", downloaded, result) + } + broken, err := filepath.Glob(filepath.Join(release, ArchiveName+".broken.*")) + if err != nil || len(broken) != 1 { + t.Fatalf("broken archive was not preserved: %v %v", broken, err) + } +} + +func writeTestArchive(t *testing.T, root, version string, payload []byte) { + t.Helper() + release := filepath.Join(root, version, "release") + if err := os.MkdirAll(release, 0o755); err != nil { + t.Fatal(err) + } + archive := filepath.Join(release, ArchiveName) + f, err := os.Create(archive) + if err != nil { + t.Fatal(err) + } + w := zip.NewWriter(f) + entry, err := w.Create("common-hash") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(payload); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + stat, err := os.Stat(archive) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(release, InfoName), []byte(strconv.FormatInt(stat.Size(), 10)), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/go/internal/introdb/introdb.go b/go/internal/introdb/introdb.go new file mode 100644 index 0000000..37e133c --- /dev/null +++ b/go/internal/introdb/introdb.go @@ -0,0 +1,482 @@ +// Package introdb patches Brown Dust II's embedded Intro database without +// changing the size or layout of Unity's resources.assets file. +package introdb + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "bd2server/internal/dbcrypt" +) + +const ( + oldURL = "https://mt.bd2.pmang.cloud/" +) + +var salt = dbcrypt.Header + +// Result describes a completed in-place client patch. BackupPath is the +// immutable pre-patch copy and is never overwritten by a later invocation. +type Result struct { + AssetsPath string + BackupPath string + ObjectPath int64 + ObjectSize uint32 + OldURL string + NewURL string +} + +// VerifyResult is useful to patch-client's --verify mode and to diagnostics. +type VerifyResult struct { + AssetsPath string + ObjectPath int64 + ObjectSize uint32 + URL string +} + +// PatchClient resolves resources.assets below gameDir, verifies the embedded +// TextAsset named Intro, makes a one-time .bak copy, then atomically replaces +// resources.assets. newURL must be exactly as long as the original URL. +func PatchClient(gameDir, newURL string) (Result, error) { + if len(newURL) != len(oldURL) { + return Result{}, fmt.Errorf("introdb: local URL must be exactly %d bytes (got %d): %q", len(oldURL), len(newURL), newURL) + } + if !isASCII(newURL) { + return Result{}, errors.New("introdb: local URL must be ASCII") + } + assets, err := ResourcesPath(gameDir) + if err != nil { + return Result{}, err + } + b, err := os.ReadFile(assets) + if err != nil { + return Result{}, fmt.Errorf("introdb: read assets: %w", err) + } + entry, script, err := findIntro(b) + if err != nil { + return Result{}, err + } + plain, err := DecryptPages(script) + if err != nil { + return Result{}, fmt.Errorf("introdb: decrypt Intro TextAsset: %w", err) + } + // A repeated command is intentionally a no-op. This makes automation safe + // while refusing to overwrite an Intro database that was patched to a + // different endpoint by another tool. + if bytes.Count(plain, []byte(oldURL)) == 0 { + current, findErr := urlInDB(plain) + if findErr != nil { + return Result{}, findErr + } + if current == newURL { + return Result{assets, assets + ".bak", entry.pathID, entry.size, current, newURL}, nil + } + return Result{}, fmt.Errorf("introdb: LIVE_URL is already %q, not the expected official URL", current) + } + if err := validateIntroDB(plain, oldURL); err != nil { + return Result{}, err + } + updated := bytes.Replace(plain, []byte(oldURL), []byte(newURL), 1) + ciphertext, err := EncryptPages(updated) + if err != nil { + return Result{}, err + } + copy(b[entry.scriptStart:entry.scriptStart+int64(len(ciphertext))], ciphertext) + // Verify the staged bytes before touching the installed asset. This catches + // a parser/cipher regression even though the payload length never changes. + _, stagedScript, err := findIntro(b) + if err != nil { + return Result{}, fmt.Errorf("introdb: re-read staged Intro: %w", err) + } + stagedPlain, err := DecryptPages(stagedScript) + if err != nil { + return Result{}, fmt.Errorf("introdb: decrypt staged Intro: %w", err) + } + if err := validateIntroDB(stagedPlain, newURL); err != nil { + return Result{}, fmt.Errorf("introdb: staged verification: %w", err) + } + + backup := assets + ".bak" + if _, err := os.Stat(backup); errors.Is(err, os.ErrNotExist) { + if err := copyFile(assets, backup); err != nil { + return Result{}, fmt.Errorf("introdb: create backup: %w", err) + } + } else if err != nil { + return Result{}, fmt.Errorf("introdb: inspect backup: %w", err) + } + if err := atomicWrite(assets, b); err != nil { + return Result{}, err + } + return Result{assets, backup, entry.pathID, entry.size, oldURL, newURL}, nil +} + +// VerifyClient reads and decrypts the embedded Intro TextAsset. It verifies +// the SQLite header plus the expected table/key markers before returning URL. +func VerifyClient(gameDir string) (VerifyResult, error) { + assets, err := ResourcesPath(gameDir) + if err != nil { + return VerifyResult{}, err + } + b, err := os.ReadFile(assets) + if err != nil { + return VerifyResult{}, fmt.Errorf("introdb: read assets: %w", err) + } + e, script, err := findIntro(b) + if err != nil { + return VerifyResult{}, err + } + p, err := DecryptPages(script) + if err != nil { + return VerifyResult{}, fmt.Errorf("introdb: decrypt Intro TextAsset: %w", err) + } + if !bytes.HasPrefix(p, salt) { + return VerifyResult{}, errors.New("introdb: decrypted Intro is not a SQLite database") + } + if !bytes.Contains(p, []byte("ServerURLTable")) || !bytes.Contains(p, []byte("LIVE_URL")) { + return VerifyResult{}, errors.New("introdb: Intro database lacks ServerURLTable/LIVE_URL markers") + } + url, err := urlInDB(p) + if err != nil { + return VerifyResult{}, err + } + return VerifyResult{assets, e.pathID, e.size, url}, nil +} + +// ResourcesPath returns the conventional standalone Windows resource location. +func ResourcesPath(gameDir string) (string, error) { + if gameDir == "" { + return "", errors.New("introdb: empty game directory") + } + p := filepath.Join(gameDir, "BrownDust II_Data", "resources.assets") + st, err := os.Stat(p) + if err != nil { + return "", fmt.Errorf("introdb: resources.assets not found at %q: %w", p, err) + } + if st.IsDir() { + return "", fmt.Errorf("introdb: resources.assets path is a directory: %q", p) + } + return p, nil +} + +// DecryptPages decrypts the game's independent 4096-byte AES-CBC pages. +func DecryptPages(in []byte) ([]byte, error) { return dbcrypt.DecryptPages(in) } + +// EncryptPages encrypts the game's independent 4096-byte AES-CBC pages. +func EncryptPages(in []byte) ([]byte, error) { return dbcrypt.EncryptPages(in) } + +func validateIntroDB(p []byte, expected string) error { + if !bytes.HasPrefix(p, salt) { + return errors.New("introdb: decrypted Intro is not a SQLite database (wrong cipher parameters or asset)") + } + if !bytes.Contains(p, []byte("ServerURLTable")) || !bytes.Contains(p, []byte("LIVE_URL")) { + return errors.New("introdb: database does not contain ServerURLTable/LIVE_URL") + } + if bytes.Count(p, []byte(expected)) != 1 { + return fmt.Errorf("introdb: expected LIVE_URL value %q exactly once, found %d", expected, bytes.Count(p, []byte(expected))) + } + return nil +} +func urlInDB(p []byte) (string, error) { + if bytes.Count(p, []byte(oldURL)) == 1 { + return oldURL, nil + } + // Patching stays strictly exact-length, so an already-patched value can be + // diagnosed without needing a SQLite C dependency. Search only the record + // containing LIVE_URL; unrelated configuration URLs are also present. + marker := []byte("LIVE_URL") + for start := 0; ; { + i := bytes.Index(p[start:], marker) + if i < 0 { + break + } + i += start + len(marker) + limit := i + 128 + if limit > len(p) { + limit = len(p) + } + if j := bytes.Index(p[i:limit], []byte("http")); j >= 0 { + at := i + j + if at+len(oldURL) <= len(p) { + u := p[at : at+len(oldURL)] + if bytes.HasSuffix(u, []byte("/")) && isASCII(string(u)) { + return string(u), nil + } + } + } + start = i + } + return "", errors.New("introdb: cannot locate LIVE_URL value") +} +func isASCII(s string) bool { + for _, c := range []byte(s) { + if c < 0x20 || c > 0x7e { + return false + } + } + return true +} +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, e := io.Copy(out, in) + ce := out.Close() + if e != nil { + return e + } + return ce +} +func atomicWrite(path string, data []byte) error { + tmp := path + ".bd2server.tmp" + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("introdb: stage patched assets: %w", err) + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return fmt.Errorf("introdb: stage patched assets: %w", err) + } + if err := f.Sync(); err != nil { + _ = f.Close() + return fmt.Errorf("introdb: sync staged assets: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("introdb: close staged assets: %w", err) + } + // Go's Windows implementation replaces an existing destination (covered by + // TestPatchClientTransaction). Prefer that one-step replacement. A rollback + // path remains for Windows filesystems that reject replacement by rename. + if err := os.Rename(tmp, path); err == nil { + return nil + } + rollback := path + ".bd2server.rollback" + if _, err := os.Stat(rollback); err == nil { + return fmt.Errorf("introdb: cannot safely replace assets; rollback file exists: %q", rollback) + } + if err := os.Rename(path, rollback); err != nil { + return fmt.Errorf("introdb: replace assets: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + if restoreErr := os.Rename(rollback, path); restoreErr != nil { + return fmt.Errorf("introdb: replacement failed (%v) and rollback restore failed (%v); original is %q", err, restoreErr, rollback) + } + return fmt.Errorf("introdb: replace assets (original restored): %w", err) + } + if err := os.Remove(rollback); err != nil { + return fmt.Errorf("introdb: patched successfully but could not remove rollback copy %q: %w", rollback, err) + } + return nil +} + +// Kept here so the object parser and patch transaction stay together. +type textAsset struct { + pathID int64 + size uint32 + scriptStart int64 + scriptSize int +} +type reader struct { + b []byte + off int + little bool +} + +func (r *reader) need(n int) error { + if n < 0 || r.off+n > len(r.b) { + return io.ErrUnexpectedEOF + } + return nil +} +func (r *reader) u8() (byte, error) { + if err := r.need(1); err != nil { + return 0, err + } + v := r.b[r.off] + r.off++ + return v, nil +} +func (r *reader) u16() (uint16, error) { + if err := r.need(2); err != nil { + return 0, err + } + v := binary.LittleEndian.Uint16(r.b[r.off:]) + if !r.little { + v = binary.BigEndian.Uint16(r.b[r.off:]) + } + r.off += 2 + return v, nil +} +func (r *reader) u32() (uint32, error) { + if err := r.need(4); err != nil { + return 0, err + } + v := binary.LittleEndian.Uint32(r.b[r.off:]) + if !r.little { + v = binary.BigEndian.Uint32(r.b[r.off:]) + } + r.off += 4 + return v, nil +} +func (r *reader) i32() (int32, error) { v, e := r.u32(); return int32(v), e } +func (r *reader) i64() (int64, error) { + if e := r.need(8); e != nil { + return 0, e + } + v := binary.LittleEndian.Uint64(r.b[r.off:]) + if !r.little { + v = binary.BigEndian.Uint64(r.b[r.off:]) + } + r.off += 8 + return int64(v), nil +} +func (r *reader) u64() (uint64, error) { v, e := r.i64(); return uint64(v), e } +func (r *reader) skip(n int) error { + if e := r.need(n); e != nil { + return e + } + r.off += n + return nil +} +func (r *reader) align4() { r.off = (r.off + 3) &^ 3 } +func (r *reader) str() (string, error) { + n, e := r.u32() + if e != nil { + return "", e + } + if n > uint32(len(r.b)-r.off) { + return "", io.ErrUnexpectedEOF + } + s := string(r.b[r.off : r.off+int(n)]) + r.off += int(n) + r.align4() + return s, nil +} +func (r *reader) cstr() (string, error) { + start := r.off + for r.off < len(r.b) && r.b[r.off] != 0 { + r.off++ + } + if r.off == len(r.b) { + return "", io.ErrUnexpectedEOF + } + s := string(r.b[start:r.off]) + r.off++ + return s, nil +} + +func findIntro(file []byte) (textAsset, []byte, error) { + if len(file) < 48 { + return textAsset{}, nil, errors.New("introdb: truncated Unity serialized file") + } + version := binary.BigEndian.Uint32(file[8:12]) + if version < 14 { + return textAsset{}, nil, fmt.Errorf("introdb: Unity serialized version %d is unsupported", version) + } + dataOff := binary.BigEndian.Uint64(file[32:40]) + if dataOff >= uint64(len(file)) { + return textAsset{}, nil, errors.New("introdb: invalid Unity data offset") + } + // Unity records endian at byte 16: 0 means little endian for metadata. + r := reader{b: file[48:int(dataOff)], little: file[16] == 0} + if _, e := r.cstr(); e != nil { + return textAsset{}, nil, fmt.Errorf("introdb: user version: %w", e) + } + if _, e := r.i32(); e != nil { + return textAsset{}, nil, e + } + if _, e := r.u8(); e != nil { + return textAsset{}, nil, e + } + typeCount, e := r.i32() + if e != nil || typeCount < 0 || typeCount > 100000 { + return textAsset{}, nil, fmt.Errorf("introdb: invalid type count %d", typeCount) + } + classes := make([]int32, typeCount) + for i := range classes { + c, e := r.i32() + if e != nil { + return textAsset{}, nil, e + } + classes[i] = c + if _, e = r.u8(); e != nil { + return textAsset{}, nil, e + } + if _, e = r.u16(); e != nil { + return textAsset{}, nil, e + } + if c == 114 { + if e = r.skip(16); e != nil { + return textAsset{}, nil, e + } + } + if e = r.skip(16); e != nil { + return textAsset{}, nil, e + } + } + count, e := r.i32() + if e != nil || count < 0 || count > 10000000 { + return textAsset{}, nil, fmt.Errorf("introdb: invalid object count %d", count) + } + var candidates []textAsset + for i := int32(0); i < count; i++ { + // Since serialized version 14, Unity aligns object records to four bytes + // before their 64-bit path ID (not to an eight-byte boundary). + r.off = (r.off + 48 + 3) &^ 3 + r.off -= 48 + pid, e := r.i64() + if e != nil { + return textAsset{}, nil, e + } + start, e := r.u64() + if e != nil { + return textAsset{}, nil, e + } + size, e := r.u32() + if e != nil { + return textAsset{}, nil, e + } + typ, e := r.i32() + if e != nil { + return textAsset{}, nil, e + } + if typ < 0 || int(typ) >= len(classes) { + return textAsset{}, nil, fmt.Errorf("introdb: object %d has invalid type ID %d", i, typ) + } + if classes[typ] != 49 { + continue + } + abs := int64(dataOff) + int64(start) + if abs < 0 || abs+int64(size) > int64(len(file)) { + return textAsset{}, nil, fmt.Errorf("introdb: TextAsset object %d range outside file (start=%d size=%d)", i, start, size) + } + candidates = append(candidates, textAsset{pathID: pid, size: size, scriptStart: abs}) + } + for _, c := range candidates { + object := reader{b: file[c.scriptStart : c.scriptStart+int64(c.size)], little: r.little} + name, e := object.str() + if e != nil { + continue + } + n, e := object.u32() + if e != nil || n > uint32(len(object.b)-object.off) { + continue + } + if name == "Intro" { + c.scriptStart += int64(object.off) + c.scriptSize = int(n) + return c, file[c.scriptStart : c.scriptStart+int64(n)], nil + } + } + return textAsset{}, nil, errors.New("introdb: TextAsset named Intro was not found") +} diff --git a/go/internal/introdb/introdb_test.go b/go/internal/introdb/introdb_test.go new file mode 100644 index 0000000..84d72a6 --- /dev/null +++ b/go/internal/introdb/introdb_test.go @@ -0,0 +1,125 @@ +package introdb + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "bd2server/internal/dbcrypt" +) + +func referenceClientDir(t *testing.T) string { + t.Helper() + dir := os.Getenv("BD2_TEST_CLIENT_DIR") + if dir == "" { + t.Skip("set BD2_TEST_CLIENT_DIR to enable read-only client integration tests") + } + return dir +} + +func TestPagesRoundTrip(t *testing.T) { + p := make([]byte, dbcrypt.PageSize*2) + copy(p, salt) + for i := 16; i < len(p); i++ { + p[i] = byte(i * 31) + } + c, err := EncryptPages(p) + if err != nil { + t.Fatal(err) + } + got, err := DecryptPages(c) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, p) { + t.Fatal("page cipher did not round-trip") + } +} +func TestPagesRejectPartialPage(t *testing.T) { + if _, err := DecryptPages(make([]byte, dbcrypt.PageSize-1)); err == nil { + t.Fatal("accepted partial page") + } +} +func TestValidateRequiresUniqueURL(t *testing.T) { + p := append([]byte{}, salt...) + p = append(p, []byte(" ServerURLTable LIVE_URL "+oldURL)...) + if err := validateIntroDB(p, oldURL); err != nil { + t.Fatal(err) + } + if err := validateIntroDB(append(p, []byte(oldURL)...), oldURL); err == nil { + t.Fatal("accepted duplicated URL") + } +} + +// This is deliberately read-only. When the reference client is present, it +// proves the Unity metadata parser and crypto parameters against the real file. +func TestKnownClientVerify(t *testing.T) { + knownClientDir := referenceClientDir(t) + if _, err := os.Stat(knownClientDir); os.IsNotExist(err) { + t.Skip("reference client is not available") + } + v, err := VerifyClient(knownClientDir) + if err != nil { + t.Fatal(err) + } + if v.URL != oldURL && v.URL != "http://127.0.0.1:8080/game/" { + t.Fatalf("unexpected LIVE_URL=%q", v.URL) + } +} + +// The transaction test copies the reference asset into a test directory, never +// mutating the installed client. It covers Unity object lookup, backup, +// encryption, atomic replacement, and the post-patch diagnostic together. +func TestPatchClientTransaction(t *testing.T) { + knownClientDir := referenceClientDir(t) + if _, err := os.Stat(knownClientDir); os.IsNotExist(err) { + t.Skip("reference client is not available") + } + tmp := t.TempDir() + dir := filepath.Join(tmp, "BrownDust II_Data") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + src, err := ResourcesPath(knownClientDir) + if err != nil { + t.Fatal(err) + } + if current, err := VerifyClient(knownClientDir); err == nil && current.URL != oldURL { + // The installed research client is normally patched. Exercise the + // transaction against its immutable pre-patch backup in that case. + if _, err := os.Stat(src + ".bak"); err != nil { + t.Skipf("official pre-patch asset is unavailable: %v", err) + } + src += ".bak" + } + dst := filepath.Join(dir, "resources.assets") + in, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, in, 0o600); err != nil { + t.Fatal(err) + } + const local = "http://127.0.0.1:8080/bd2x/" + r, err := PatchClient(tmp, local) + if err != nil { + t.Fatal(err) + } + if r.OldURL != oldURL || r.NewURL != local || r.BackupPath != dst+".bak" { + t.Fatalf("unexpected patch result: %#v", r) + } + if _, err := os.Stat(r.BackupPath); err != nil { + t.Fatalf("backup missing: %v", err) + } + v, err := VerifyClient(tmp) + if err != nil { + t.Fatal(err) + } + if v.URL != local { + t.Fatalf("LIVE_URL=%q, want %q", v.URL, local) + } + if _, err := PatchClient(tmp, local); err != nil { + t.Fatalf("idempotent patch: %v", err) + } +} diff --git a/go/internal/mail/mail.go b/go/internal/mail/mail.go new file mode 100644 index 0000000..7c992ec --- /dev/null +++ b/go/internal/mail/mail.go @@ -0,0 +1,391 @@ +// Package mail owns the local new-player mailbox. Its seed is source data, +// not a captured protobuf response: responses are encoded field-by-field here. +package mail + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +const packetCode = 131 +const openPacketCode = 132 + +// MailDBInfo is the persisted shape used by the client. A mail either has a +// literal title/body (ordinary system mail), or a TemplateID and Sender (the +// localized/template-driven mail form). Reward fields are parallel arrays: +// type, table ID, and amount respectively. +type MailDBInfo struct { + MailID uint64 `json:"mail_id"` + MailType uint64 `json:"mail_type,omitempty"` + TemplateID uint64 `json:"template_id,omitempty"` + Sender string `json:"sender,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + ExpiresAt uint64 `json:"expires_at"` + RewardTypes []uint64 `json:"reward_types,omitempty"` + RewardIDs []uint64 `json:"reward_ids,omitempty"` + RewardCounts []uint64 `json:"reward_counts,omitempty"` + SentAt uint64 `json:"sent_at"` +} + +type Starter struct { + Version string `json:"version"` + Mails []MailDBInfo `json:"mails"` + MailCount uint64 `json:"mail_count"` + MaxMailID uint64 `json:"max_mail_id"` +} + +func Load(path string) (*Starter, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("mail: read seed: %w", err) + } + var seed Starter + if err := json.Unmarshal(data, &seed); err != nil { + return nil, fmt.Errorf("mail: decode seed: %w", err) + } + if err := seed.Validate(); err != nil { + return nil, err + } + return &seed, nil +} + +func (s *Starter) Validate() error { + if s == nil || s.Version != "2.34.13" { + return errors.New("mail: wrong starter version") + } + if s.MailCount != uint64(len(s.Mails))+1 { + return errors.New("mail: mail_count must include the server sentinel") + } + for _, m := range s.Mails { + if m.MailID == 0 || m.ExpiresAt == 0 || m.SentAt == 0 { + return errors.New("mail: invalid mail identity or time") + } + if m.MailType == 0 && m.TemplateID == 0 { + return errors.New("mail: each mail needs mail_type or template_id") + } + if len(m.RewardTypes) != len(m.RewardIDs) || len(m.RewardTypes) != len(m.RewardCounts) { + return errors.New("mail: reward arrays differ in length") + } + } + return nil +} + +func packed(values []uint64) []byte { + var result []byte + for _, value := range values { + result = binary.AppendUvarint(result, value) + } + return result +} + +func (m MailDBInfo) encode() []byte { + result := wire.AppendVarint(nil, 1, m.MailID) + if m.MailType != 0 { + result = wire.AppendVarint(result, 2, m.MailType) + } + if m.TemplateID != 0 { + result = wire.AppendVarint(result, 3, m.TemplateID) + } + if m.Sender != "" { + result = wire.AppendString(result, 4, m.Sender) + } + if m.Title != "" { + result = wire.AppendString(result, 5, m.Title) + } + if m.Body != "" { + result = wire.AppendString(result, 6, m.Body) + } + result = wire.AppendVarint(result, 7, m.ExpiresAt) + if len(m.RewardTypes) != 0 { + result = wire.AppendBytes(result, 8, packed(m.RewardTypes)) + } + if len(m.RewardIDs) != 0 { + result = wire.AppendBytes(result, 9, packed(m.RewardIDs)) + } + if len(m.RewardCounts) != 0 { + result = wire.AppendBytes(result, 10, packed(m.RewardCounts)) + } + return wire.AppendVarint(result, 13, m.SentAt) +} + +func (s *Starter) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/MailInfo" { + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("mail: %s invalid sequence", path) + } + var result []byte + for _, entry := range s.Mails { + result = wire.AppendBytes(result, 1, entry.encode()) + } + result = wire.AppendVarint(result, 2, s.MailCount) + result = wire.AppendVarint(result, 3, s.MaxMailID) + return packetCode, result, true, nil +} + +func (s *Starter) Write(path string) error { + if err := s.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func unpack(data []byte) ([]uint64, error) { + var result []uint64 + for len(data) != 0 { + value, count := binary.Uvarint(data) + if count <= 0 { + return nil, errors.New("mail: malformed packed values") + } + result, data = append(result, value), data[count:] + } + return result, nil +} + +type stateSnapshot struct { + Version string `json:"version"` + Opened []uint64 `json:"opened,omitempty"` +} + +// Service owns mailbox visibility and idempotent reward delivery. Starter is +// immutable source data; only opened IDs are persisted in the player state. +type Service struct { + mu sync.Mutex + Starter *Starter + path string + inventory *player.Inventory + wallet *player.Wallet + state stateSnapshot +} + +func OpenService(path string, starter *Starter, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) { + if path == "" || starter == nil || inventory == nil || wallet == nil { + return nil, errors.New("mail: invalid service configuration") + } + if err := starter.Validate(); err != nil { + return nil, err + } + s := &Service{Starter: starter, path: filepath.Clean(path), inventory: inventory, wallet: wallet, + state: stateSnapshot{Version: "2.34.13"}} + b, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("mail: read state: %w", err) + } + if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != "2.34.13" { + return nil, errors.New("mail: malformed state") + } + sort.Slice(s.state.Opened, func(i, j int) bool { return s.state.Opened[i] < s.state.Opened[j] }) + for i := 1; i < len(s.state.Opened); i++ { + if s.state.Opened[i] == s.state.Opened[i-1] { + return nil, errors.New("mail: duplicate opened ID") + } + } + return s, nil +} + +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/MailInfo" && path != "/MailOpen" { + return 0, nil, false, nil + } + if s == nil || s.Starter == nil || s.inventory == nil || s.wallet == nil { + return 0, nil, true, errors.New("mail: unavailable service") + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("mail: %s invalid sequence", path) + } + s.mu.Lock() + defer s.mu.Unlock() + if path == "/MailInfo" { + return packetCode, s.info(), true, nil + } + response, err := s.open(request) + return openPacketCode, response, true, err +} + +func (s *Service) info() []byte { + var result []byte + remaining := uint64(0) + for _, entry := range s.Starter.Mails { + if containsID(s.state.Opened, entry.MailID) { + continue + } + result = wire.AppendBytes(result, 1, entry.encode()) + remaining++ + } + // The official total includes one server-side sentinel row. + result = wire.AppendVarint(result, 2, remaining+1) + result = wire.AppendVarint(result, 3, s.Starter.MaxMailID) + return result +} + +func (s *Service) open(request []byte) ([]byte, error) { + ids, err := requestMailIDs(request) + if err != nil || len(ids) == 0 { + return nil, fmt.Errorf("mail: invalid open request: %w", err) + } + selected := make([]MailDBInfo, 0, len(ids)) + seen := make(map[uint64]bool, len(ids)) + for _, id := range ids { + if id == 0 || seen[id] { + return nil, errors.New("mail: duplicate or zero mail ID") + } + seen[id] = true + found := false + for _, entry := range s.Starter.Mails { + if entry.MailID == id { + selected = append(selected, entry) + found = true + break + } + } + if !found { + return nil, fmt.Errorf("mail: unknown mail %d", id) + } + } + + var bundle []byte + next := stateSnapshot{Version: s.state.Version, Opened: append([]uint64(nil), s.state.Opened...)} + for _, entry := range selected { + identity := fmt.Sprintf("mail:%d", entry.MailID) + rewards := make([]gamedata.Reward, len(entry.RewardTypes)) + var items []gamedata.BattleReward + for i := range entry.RewardTypes { + reward := gamedata.Reward{Type: entry.RewardTypes[i], ID: entry.RewardIDs[i], Count: entry.RewardCounts[i]} + rewards[i] = reward + switch reward.Type { + case 3, 4: + currency := wire.AppendVarint(nil, 3, reward.Type) + currency = wire.AppendVarint(currency, 4, reward.Count) + bundle = wire.AppendBytes(bundle, 1, currency) + case 8: + if reward.ID == 0 || reward.Count == 0 { + return nil, errors.New("mail: invalid item reward") + } + items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + default: + return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type) + } + } + if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { + return nil, err + } + granted, err := s.inventory.GrantOnce(identity+":items", items) + if err != nil { + return nil, err + } + if len(granted) == 0 { + granted = s.inventory.GrantedItems(identity + ":items") + } + for _, item := range granted { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + view := wire.AppendVarint(nil, 2, item.ID) + view = wire.AppendVarint(view, 3, item.Type) + view = wire.AppendVarint(view, 4, item.Count) + bundle = wire.AppendBytes(bundle, 6, view) + } + if !containsID(next.Opened, entry.MailID) { + next.Opened = append(next.Opened, entry.MailID) + } + } + sort.Slice(next.Opened, func(i, j int) bool { return next.Opened[i] < next.Opened[j] }) + if err := s.commit(next); err != nil { + return nil, err + } + return wire.AppendBytes(nil, 1, bundle), nil +} + +func requestMailIDs(request []byte) ([]uint64, error) { + var result []uint64 + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + switch field.Type { + case 0: + value, _ := binary.Uvarint(field.Value) + result = append(result, value) + case 2: + values, err := unpack(field.Value) + if err != nil { + return err + } + result = append(result, values...) + default: + return errors.New("mail: invalid InvenIndex wire type") + } + return nil + }) + return result, err +} + +func (s *Service) commit(next stateSnapshot) error { + if len(next.Opened) == len(s.state.Opened) { + equal := true + for i := range next.Opened { + if next.Opened[i] != s.state.Opened[i] { + equal = false + break + } + } + if equal { + return nil + } + } + b, err := json.Marshal(next) + if err != nil { + return err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + f, err := os.CreateTemp(dir, ".mail-*.tmp") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(b); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(f.Name(), s.path) + } + if err != nil { + return fmt.Errorf("mail: persist state: %w", err) + } + s.state = next + return nil +} + +func containsID(values []uint64, want uint64) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/go/internal/mail/mail_test.go b/go/internal/mail/mail_test.go new file mode 100644 index 0000000..dd03185 --- /dev/null +++ b/go/internal/mail/mail_test.go @@ -0,0 +1,132 @@ +package mail + +import ( + "bytes" + "path/filepath" + "testing" + + "bd2server/internal/fixture" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +func TestStarterAnswersMailInfoWithoutCapture(t *testing.T) { + seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "mail.json")) + if err != nil { + t.Fatal(err) + } + code, proto, ok, err := seed.Handle("/MailInfo", wire.AppendVarint(nil, 1, 249)) + if err != nil || !ok || code != packetCode || len(proto) == 0 { + t.Fatalf("MailInfo: code=%d bytes=%d ok=%t err=%v", code, len(proto), ok, err) + } + if _, _, ok, err := seed.Handle("/MailOpen", wire.AppendVarint(nil, 1, 1)); ok || err != nil { + t.Fatal("seed claimed an endpoint it does not own") + } + if _, _, ok, err := seed.Handle("/MailInfo", nil); !ok || err == nil { + t.Fatal("missing sequence was accepted") + } +} + +func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) { + dir := t.TempDir() + seed := &Starter{Version: "2.34.13", MailCount: 3, MaxMailID: 12, Mails: []MailDBInfo{ + {MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{3}, RewardIDs: []uint64{0}, RewardCounts: []uint64{70}}, + {MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{8}, RewardIDs: []uint64{9}, RewardCounts: []uint64{10}}, + }} + starter := &player.Starter{Version: "2.34.13"} + inv, err := player.OpenInventory(filepath.Join(dir, "items.json"), starter) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{}) + if err != nil { + t.Fatal(err) + } + service, err := OpenService(filepath.Join(dir, "mail.json"), seed, inv, wallet) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendBytes(request, 2, packed([]uint64{11, 12})) + code, response, ok, err := service.Handle("/MailOpen", request) + if err != nil || !ok || code != 132 { + t.Fatalf("code=%d ok=%v err=%v", code, ok, err) + } + if wallet.Snapshot().FreeJewelry != 70 { + t.Fatalf("wallet=%+v", wallet.Snapshot()) + } + if len(inv.All()) != 1 || inv.All()[0].ID != 9 || inv.All()[0].Count != 10 { + t.Fatalf("items=%+v", inv.All()) + } + bundle, found, _ := wire.Bytes(response, 1) + if !found || len(bundle) == 0 { + t.Fatal("reward bundle missing") + } + infoReq := wire.AppendVarint(nil, 1, 2) + _, info, _, err := service.Handle("/MailInfo", infoReq) + if err != nil { + t.Fatal(err) + } + if _, found, _ := wire.Bytes(info, 1); found { + t.Fatal("opened mail remained visible") + } + count, found, _ := wire.Varint(info, 2) + if !found || count != 1 { + t.Fatalf("mail count=%d found=%v", count, found) + } + service, err = OpenService(filepath.Join(dir, "mail.json"), seed, inv, wallet) + if err != nil { + t.Fatal(err) + } + _, info, _, _ = service.Handle("/MailInfo", infoReq) + if _, found, _ := wire.Bytes(info, 1); found { + t.Fatal("opened mail was not persisted") + } +} + +func TestMailOpenAcceptsOfficialPackedRequest(t *testing.T) { + request := []byte{0x08, 0xef, 0x01, 0x12, 0x0f, 0x85, 0xc1, 0xe1, 0xce, 0x30, 0x88, 0xc1, 0xe1, 0xce, 0x30, 0x8a, 0xc1, 0xe1, 0xce, 0x30} + ids, err := requestMailIDs(request) + if err != nil { + t.Fatal(err) + } + want := []uint64{13050077317, 13050077320, 13050077322} + if len(ids) != len(want) { + t.Fatalf("ids=%v", ids) + } + for i := range want { + if ids[i] != want[i] { + t.Fatalf("ids=%v", ids) + } + } +} + +func TestStarterMatchesInitialAccountSample(t *testing.T) { + set, err := fixture.Load(filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) + if err != nil { + t.Skipf("optional capture unavailable: %v", err) + } + recorded, err := set.RecordedResponseForClientSequence("/MailInfo", 249) + if err != nil { + t.Fatal(err) + } + payload, _, err := set.RecordedPayloadAt("/MailInfo", recorded.RequestSequence) + if err != nil { + t.Fatal(err) + } + seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "mail.json")) + if err != nil { + t.Fatal(err) + } + code, actual, ok, err := seed.Handle("/MailInfo", wire.AppendVarint(nil, 1, 249)) + if err != nil || !ok || code != payload.PacketCode || !bytes.Equal(actual, payload.Proto) { + t.Fatalf("MailInfo differs: code=%d want=%d ok=%v err=%v\\ngot =%x\\nwant=%x", code, payload.PacketCode, ok, err, actual, payload.Proto) + } +} + +func TestValidateRejectsRewardLengthMismatch(t *testing.T) { + seed := &Starter{Version: "2.34.13", Mails: []MailDBInfo{{MailID: 1, MailType: 2, ExpiresAt: 1, SentAt: 1, RewardTypes: []uint64{8}, RewardIDs: []uint64{1}}}, MailCount: 2, MaxMailID: 1} + if seed.Validate() == nil { + t.Fatal("accepted bad rewards") + } +} diff --git a/go/internal/missions/missions.go b/go/internal/missions/missions.go new file mode 100644 index 0000000..a6abe31 --- /dev/null +++ b/go/internal/missions/missions.go @@ -0,0 +1,820 @@ +// Package missions owns local completion eligibility and claim state for the +// three regular (non-scheduled-event) mission endpoints. Static definitions +// come from GameData; this package never replays an HTTP capture. +package missions + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +const ( + missionClearPacket = 120 + missionSectionPacket = 122 + achievementClearPacket = 168 +) + +var ErrInvalidRequest = errors.New("missions: invalid request") + +// Service is deliberately given completion signals by authoritative gameplay +// code. A request cannot manufacture a mission completion merely by naming a +// table row. AchievementClear is different: its request carries the exact +// client-calculated completed achievement ids, as in the official protocol. +type Service struct { + mu sync.Mutex + path string + design *gamedata.MissionDesign + inventory *player.Inventory + wallet *player.Wallet + state snapshot +} + +func (s *Service) AttachWallet(wallet *player.Wallet) error { + if wallet == nil { + return errors.New("missions: nil wallet") + } + s.wallet = wallet + return nil +} + +type snapshot struct { + Version string `json:"version"` + Completed []string `json:"completed"` + Claimed []string `json:"claimed"` + Progress map[string]uint64 `json:"progress,omitempty"` +} + +func Open(path string, design *gamedata.MissionDesign, inventory *player.Inventory) (*Service, error) { + if path == "" || design == nil || inventory == nil { + return nil, errors.New("missions: invalid service configuration") + } + s := &Service{path: filepath.Clean(path), design: design, inventory: inventory, state: snapshot{Version: "2.34.13", Progress: map[string]uint64{}}} + b, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("missions: read state: %w", err) + } + if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != "2.34.13" { + return nil, errors.New("missions: malformed state") + } + if s.state.Progress == nil { + s.state.Progress = map[string]uint64{} + } + return s, nil +} + +// CompleteMission is the only way normal mission eligibility enters this +// package. It is persistent and idempotent. Gameplay/event handlers should +// call it only after they have independently verified the table condition. +func (s *Service) CompleteMission(key gamedata.MissionKey) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.design.Missions[key]; !ok { + return fmt.Errorf("missions: unknown mission %+v", key) + } + condition := s.design.Conditions[key] + value := condition.TargetValue + if value == 0 { + value = 1 + } + return s.setProgressLocked(key, value) +} + +func (s *Service) SetProgress(key gamedata.MissionKey, value uint64) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.setProgressLocked(key, value) +} + +func (s *Service) setProgressLocked(key gamedata.MissionKey, value uint64) error { + if _, ok := s.design.Missions[key]; !ok { + return fmt.Errorf("missions: unknown mission %+v", key) + } + name := missionName(key) + if s.state.Progress[name] >= value { + return nil + } + next := cloneSnapshot(s.state) + next.Progress[name] = value + condition := s.design.Conditions[key] + target := condition.TargetValue + if target == 0 { + target = 1 + } + if s.state.Progress[name] < target && value >= target { + s.applyCompletionDependencies(&next, key) + } + return s.commit(next) +} + +func (s *Service) applyCompletionDependencies(next *snapshot, completed gamedata.MissionKey) { + queue := []gamedata.MissionKey{completed} + seen := map[string]bool{} + for len(queue) != 0 { + current := queue[0] + queue = queue[1:] + currentName := missionName(current) + if seen[currentName] { + continue + } + seen[currentName] = true + for key, condition := range s.design.Conditions { + if key.GroupType == 2 { + continue + } + matched := condition.Type == 30 && key.GroupType == current.GroupType + matched = matched || (condition.Type == 31 && condition.SubType == current.GroupType) + if !matched { + continue + } + name := missionName(key) + target := condition.TargetValue + if target == 0 { + target = 1 + } + before := next.Progress[name] + if before >= target { + continue + } + next.Progress[name] = before + 1 + if next.Progress[name] > target { + next.Progress[name] = target + } + if before < target && next.Progress[name] >= target { + queue = append(queue, key) + } + } + } +} + +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + switch path { + case "/MissionInfo": + if err := requireSeq(request); err != nil { + return 118, nil, true, err + } + return 118, s.missionInfo(), true, nil + case "/MissionUpdate": + if err := s.update(request); err != nil { + return 119, nil, true, err + } + return 119, nil, true, nil + case "/AchievementInfo": + if err := requireSeq(request); err != nil { + return 166, nil, true, err + } + return 166, s.achievementInfo(), true, nil + case "/MissionClear": + response, err := s.clearMission(request) + return missionClearPacket, response, true, err + case "/MissionSectionReward": + response, err := s.clearSection(request) + return missionSectionPacket, response, true, err + case "/AchievementClear": + response, err := s.clearAchievements(request) + return achievementClearPacket, response, true, err + default: + return 0, nil, false, nil + } +} + +func (s *Service) missionInfo() []byte { + var response []byte + for _, key := range s.progressMissionKeys() { + entry := wire.AppendVarint(nil, 1, key.GroupID) + entry = wire.AppendVarint(entry, 2, key.ID) + if key.GroupType != 0 { + entry = wire.AppendVarint(entry, 3, key.GroupType) + } + entry = wire.AppendVarint(entry, 4, s.state.Progress[missionName(key)]) + if contains(s.state.Claimed, "mission:"+missionName(key)) { + entry = wire.AppendVarint(entry, 5, 1) + } + response = wire.AppendBytes(response, 1, entry) + } + for _, identity := range s.state.Claimed { + var groupType, id uint64 + if _, err := fmt.Sscanf(identity, "section:%d/%d", &groupType, &id); err != nil { + continue + } + entry := wire.AppendVarint(nil, 1, groupType) + entry = wire.AppendVarint(entry, 2, id) + response = wire.AppendBytes(response, 2, entry) + } + now := time.Now().UTC() + daily := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + daysUntilMonday := (8 - int(now.Weekday())) % 7 + if daysUntilMonday == 0 { + daysUntilMonday = 7 + } + weekly := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 0, 0, 0, 0, time.UTC) + response = wire.AppendVarint(response, 3, uint64(daily.UnixMilli())) + response = wire.AppendVarint(response, 4, uint64(weekly.UnixMilli())) + return response +} + +func (s *Service) update(request []byte) error { + if err := requireSeq(request); err != nil { + return err + } + var updates int + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + if field.Type != 2 { + return ErrInvalidRequest + } + groupID, err := scalar(field.Value, 1) + if err != nil || groupID == 0 { + return ErrInvalidRequest + } + id, err := scalar(field.Value, 2) + if err != nil || id == 0 { + return ErrInvalidRequest + } + value, err := scalar(field.Value, 3) + if err != nil { + return ErrInvalidRequest + } + var key gamedata.MissionKey + found := false + for candidate := range s.design.Missions { + if candidate.GroupID == groupID && candidate.ID == id && candidate.GroupType != 2 { + if found { + return ErrInvalidRequest + } + key, found = candidate, true + } + } + if !found { + return ErrInvalidRequest + } + if err := s.setProgressLocked(key, value); err != nil { + return err + } + updates++ + return nil + }) + if err != nil { + return err + } + if updates == 0 { + return ErrInvalidRequest + } + return nil +} + +func (s *Service) achievementInfo() []byte { + // AchievementInfo contains progress and last completed IDs. This local + // account currently records acknowledgement/claim state through the + // inventory idempotency key, but deliberately does not fabricate progress. + return nil +} + +func (s *Service) clearMission(request []byte) ([]byte, error) { + if err := requireSeq(request); err != nil { + return nil, err + } + all, err := boolean(request, 2) + if err != nil { + return nil, err + } + groupType, err := scalar(request, 3) + if err != nil { + return nil, err + } + groupID, err := scalar(request, 4) + if err != nil { + return nil, err + } + id, err := scalar(request, 5) + if err != nil { + return nil, err + } + if groupType == 2 { + return nil, errors.New("missions: scheduled event missions require an event service") + } + var keys []gamedata.MissionKey + if all { + if groupType != 0 || groupID != 0 || id != 0 { + return nil, fmt.Errorf("%w: bulk MissionClear has identifiers", ErrInvalidRequest) + } + keys = s.completedMissionKeys() + } else { + if groupID == 0 || id == 0 { + return nil, fmt.Errorf("%w: MissionClear identity", ErrInvalidRequest) + } + keys = []gamedata.MissionKey{{GroupType: groupType, GroupID: groupID, ID: id}} + } + items, err := s.claimMissions(keys) + if err != nil { + return nil, err + } + bundle := rewardBundle(items) + for _, key := range keys { + for _, reward := range s.design.Missions[key] { + if reward.Type == 3 || reward.Type == 4 { + entry := wire.AppendVarint(nil, 3, reward.Type) + entry = wire.AppendVarint(entry, 4, reward.Count) + bundle = wire.AppendBytes(bundle, 1, entry) + } + } + } + return bundle, nil +} + +func (s *Service) clearSection(request []byte) ([]byte, error) { + if err := requireSeq(request); err != nil { + return nil, err + } + groupType, err := scalar(request, 2) + if err != nil { + return nil, fmt.Errorf("%w: section group type", ErrInvalidRequest) + } + all, err := boolean(request, 3) + if err != nil { + return nil, err + } + id, err := scalar(request, 4) + if err != nil { + return nil, err + } + var keys []gamedata.SectionRewardKey + if all { + if id != 0 { + return nil, fmt.Errorf("%w: bulk section id", ErrInvalidRequest) + } + keys = s.eligibleSections(groupType) + } else { + if id == 0 { + return nil, fmt.Errorf("%w: section id", ErrInvalidRequest) + } + key := gamedata.SectionRewardKey{GroupType: groupType, ID: id} + if !s.sectionEligible(key) { + return nil, fmt.Errorf("%w: section is not complete", ErrInvalidRequest) + } + keys = []gamedata.SectionRewardKey{key} + } + items, err := s.claimSections(keys) + if err != nil { + return nil, err + } + var response []byte + for _, item := range items { + response = wire.AppendBytes(response, 1, player.ItemWire(item)) + } + for _, key := range keys { + for _, reward := range s.design.Sections[key].Rewards { + if reward.Type == 3 || reward.Type == 4 { + entry := wire.AppendVarint(nil, 3, reward.Type) + entry = wire.AppendVarint(entry, 4, reward.Count) + response = wire.AppendBytes(response, 1, entry) + } + } + } + return response, nil +} + +func (s *Service) clearAchievements(request []byte) ([]byte, error) { + if err := requireSeq(request); err != nil { + return nil, err + } + contents, err := scalar(request, 2) + if err != nil { + return nil, fmt.Errorf("%w: achievement contents group", ErrInvalidRequest) + } + claims, err := achievementClaims(request) + if err != nil || len(claims) == 0 { + return nil, fmt.Errorf("%w: achievement clear info", ErrInvalidRequest) + } + var allItems []player.Item + var addExp uint64 + next := cloneSnapshot(s.state) + for _, claim := range claims { + for _, id := range claim.IDs { + key, design, ok := s.achievementDesign(contents, claim.GroupID, id) + if !ok { + return nil, fmt.Errorf("%w: unknown achievement %+v", ErrInvalidRequest, key) + } + identity := "achievement:" + achievementName(key) + if contains(next.Claimed, identity) { + continue + } + items, err := s.inventory.GrantOnce(identity, battleRewards(design.Rewards)) + if err != nil { + return nil, err + } + addExp += design.AddExp + allItems = append(allItems, items...) + next.Claimed = append(next.Claimed, identity) + } + } + if addExp > uint64(^uint(0)>>1) { + return nil, errors.New("missions: achievement exp overflow") + } + if err := s.commit(next); err != nil { + return nil, err + } + response := wire.AppendVarint(nil, 1, addExp) + return wire.AppendBytes(response, 2, rewardBundle(allItems)), nil +} + +// The official all-clear request omits ContentsGroup (protobuf value zero). +// In that form GroupID+ID must identify exactly one static row; ambiguity is +// rejected rather than resolved by map iteration order. +func (s *Service) achievementDesign(contents, groupID, id uint64) (gamedata.AchievementKey, gamedata.AchievementDesign, bool) { + if contents != 0 { + key := gamedata.AchievementKey{ContentsGroup: contents, GroupID: groupID, ID: id} + design, ok := s.design.Achievements[key] + return key, design, ok + } + var foundKey gamedata.AchievementKey + var found gamedata.AchievementDesign + matched := false + for key, design := range s.design.Achievements { + if key.GroupID != groupID || key.ID != id { + continue + } + if matched { + return gamedata.AchievementKey{}, gamedata.AchievementDesign{}, false + } + foundKey, found, matched = key, design, true + } + return foundKey, found, matched +} + +func (s *Service) progressMissionKeys() []gamedata.MissionKey { + result := make([]gamedata.MissionKey, 0, len(s.state.Progress)) + for name, value := range s.state.Progress { + if value == 0 { + continue + } + for key := range s.design.Missions { + if missionName(key) == name { + result = append(result, key) + break + } + } + } + sort.Slice(result, func(i, j int) bool { + a, b := result[i], result[j] + if a.GroupType != b.GroupType { + return a.GroupType < b.GroupType + } + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + return a.ID < b.ID + }) + return result +} + +func (s *Service) completedMissionKeys() []gamedata.MissionKey { + var result []gamedata.MissionKey + for key, condition := range s.design.Conditions { + target := condition.TargetValue + if target == 0 { + target = 1 + } + name := missionName(key) + if s.state.Progress[name] >= target && !contains(s.state.Claimed, "mission:"+name) { + result = append(result, key) + } + } + sort.Slice(result, func(i, j int) bool { + a, b := result[i], result[j] + if a.GroupType != b.GroupType { + return a.GroupType < b.GroupType + } + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + return a.ID < b.ID + }) + return result +} + +func (s *Service) claimMissions(keys []gamedata.MissionKey) ([]player.Item, error) { + next := cloneSnapshot(s.state) + var result []player.Item + for _, key := range keys { + condition := s.design.Conditions[key] + target := condition.TargetValue + if target == 0 { + target = 1 + } + if s.state.Progress[missionName(key)] < target { + return nil, fmt.Errorf("%w: mission is not complete", ErrInvalidRequest) + } + rewards, ok := s.design.Missions[key] + if !ok { + return nil, fmt.Errorf("%w: unknown mission %+v", ErrInvalidRequest, key) + } + identity := "mission:" + missionName(key) + items, err := s.grantRewards(identity, rewards) + if err != nil { + return nil, err + } + result = append(result, items...) + if !contains(next.Claimed, identity) { + next.Claimed = append(next.Claimed, identity) + } + } + if err := s.commit(next); err != nil { + return nil, err + } + return result, nil +} + +func (s *Service) eligibleSections(groupType uint64) []gamedata.SectionRewardKey { + var result []gamedata.SectionRewardKey + for key := range s.design.Sections { + // Proto3 omits group_type for the official bulk request. Zero means + // all mission groups there, not an invalid group. + if (groupType == 0 || key.GroupType == groupType) && s.sectionEligible(key) { + result = append(result, key) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +// Section reward thresholds are authoritative static values. The completed +// set, rather than the claimed set, is deliberately counted as the client does. +func (s *Service) sectionEligible(key gamedata.SectionRewardKey) bool { + section, ok := s.design.Sections[key] + if !ok { + return false + } + if section.SectionValue == 0 { + return false + } + var count uint64 + for _, identity := range s.state.Claimed { + var group, missionGroup, missionID uint64 + if _, err := fmt.Sscanf(identity, "mission:%d/%d/%d", &group, &missionGroup, &missionID); err == nil && group == key.GroupType { + count++ + } + } + return count >= section.SectionValue +} + +func (s *Service) claimSections(keys []gamedata.SectionRewardKey) ([]player.Item, error) { + next := cloneSnapshot(s.state) + var result []player.Item + for _, key := range keys { + section, ok := s.design.Sections[key] + if !ok || !s.sectionEligible(key) { + return nil, fmt.Errorf("%w: section is not complete", ErrInvalidRequest) + } + identity := fmt.Sprintf("section:%d/%d", key.GroupType, key.ID) + items, err := s.grantRewards(identity, section.Rewards) + if err != nil { + return nil, err + } + result = append(result, items...) + if !contains(next.Claimed, identity) { + next.Claimed = append(next.Claimed, identity) + } + } + if err := s.commit(next); err != nil { + return nil, err + } + return result, nil +} + +func (s *Service) grantRewards(identity string, rewards []gamedata.Reward) ([]player.Item, error) { + if s.wallet != nil { + if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { + return nil, err + } + } + var stack []gamedata.BattleReward + for _, reward := range rewards { + if reward.Type == 3 || reward.Type == 4 { + continue + } + if reward.ID == 0 || reward.Count == 0 { + // Type 12 is an account resource whose full/overflow conversion is + // server-dynamic. It is intentionally not fabricated as ItemDBInfo. + continue + } + stack = append(stack, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + } + if len(stack) == 0 { + return nil, nil + } + items, err := s.inventory.GrantOnce(identity+":items", stack) + if err != nil { + return nil, err + } + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + return items, nil +} + +func rewardBundle(items []player.Item) []byte { + var bundle []byte + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + view := wire.AppendVarint(nil, 2, item.ID) + view = wire.AppendVarint(view, 3, item.Type) + view = wire.AppendVarint(view, 4, item.Count) + bundle = wire.AppendBytes(bundle, 6, view) + } + return bundle +} + +func battleRewards(rewards []gamedata.Reward) []gamedata.BattleReward { + result := make([]gamedata.BattleReward, len(rewards)) + for i, reward := range rewards { + result[i] = gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count} + } + return result +} + +func (s *Service) commit(next snapshot) error { + sort.Strings(next.Completed) + sort.Strings(next.Claimed) + if equalStrings(next.Completed, s.state.Completed) && equalStrings(next.Claimed, s.state.Claimed) && equalProgress(next.Progress, s.state.Progress) { + return nil + } + b, err := json.Marshal(next) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(s.path), ".missions-*.tmp") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(b); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + if err := os.Rename(f.Name(), s.path); err != nil { + return err + } + s.state = next + return nil +} +func equalProgress(a, b map[string]uint64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + if b[key] != value { + return false + } + } + return true +} + +func cloneSnapshot(in snapshot) snapshot { + progress := make(map[string]uint64, len(in.Progress)) + for key, value := range in.Progress { + progress[key] = value + } + return snapshot{Version: in.Version, Completed: append([]string(nil), in.Completed...), Claimed: append([]string(nil), in.Claimed...), Progress: progress} +} +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} +func missionName(k gamedata.MissionKey) string { + return fmt.Sprintf("%d/%d/%d", k.GroupType, k.GroupID, k.ID) +} +func achievementName(k gamedata.AchievementKey) string { + return fmt.Sprintf("%d/%d/%d", k.ContentsGroup, k.GroupID, k.ID) +} + +func requireSeq(b []byte) error { + seq, err := scalar(b, 1) + if err != nil || seq == 0 { + return ErrInvalidRequest + } + return nil +} +func scalar(b []byte, number int) (uint64, error) { + value, found, err := wire.Varint(b, number) + if err != nil { + return 0, ErrInvalidRequest + } + if !found { + return 0, nil + } + return value, nil +} +func boolean(b []byte, number int) (bool, error) { + value, err := scalar(b, number) + if err != nil || value > 1 { + return false, ErrInvalidRequest + } + return value == 1, nil +} + +type achievementClaim struct { + GroupID uint64 + IDs []uint64 +} + +func achievementClaims(b []byte) ([]achievementClaim, error) { + var claims []achievementClaim + err := wire.Walk(b, func(field wire.Field) error { + if field.Number != 3 { + return nil + } + if field.Type != 2 { + return ErrInvalidRequest + } + group, err := scalar(field.Value, 1) + if err != nil || group == 0 { + return ErrInvalidRequest + } + ids, err := packed(field.Value, 2) + if err != nil || len(ids) == 0 { + return ErrInvalidRequest + } + claims = append(claims, achievementClaim{group, ids}) + return nil + }) + return claims, err +} +func packed(b []byte, number int) ([]uint64, error) { + var values []uint64 + err := wire.Walk(b, func(field wire.Field) error { + if field.Number != number { + return nil + } + if field.Type == 0 { + value, _ := decode(field.Value) + values = append(values, value) + return nil + } + if field.Type != 2 { + return ErrInvalidRequest + } + for remaining := field.Value; len(remaining) > 0; { + value, n := decode(remaining) + if n == 0 { + return ErrInvalidRequest + } + values = append(values, value) + remaining = remaining[n:] + } + return nil + }) + return values, err +} +func decode(b []byte) (uint64, int) { + var value uint64 + for i, x := range b { + value |= uint64(x&127) << (7 * i) + if x < 128 { + return value, i + 1 + } + if i == 9 { + return 0, 0 + } + } + return 0, 0 +} diff --git a/go/internal/missions/missions_test.go b/go/internal/missions/missions_test.go new file mode 100644 index 0000000..2fd7bb4 --- /dev/null +++ b/go/internal/missions/missions_test.go @@ -0,0 +1,141 @@ +package missions + +import ( + "path/filepath" + "testing" + + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +func TestAchievementClaimsAcceptPackedAndUnpackedIDs(t *testing.T) { + info := wire.AppendVarint(nil, 1, 7) + packed := []byte{1, 2, 3} + info = wire.AppendBytes(info, 2, packed) + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, 1) + request = wire.AppendBytes(request, 3, info) + claims, err := achievementClaims(request) + if err != nil { + t.Fatal(err) + } + if len(claims) != 1 || claims[0].GroupID != 7 || len(claims[0].IDs) != 3 || claims[0].IDs[2] != 3 { + t.Fatalf("claims=%+v", claims) + } +} + +func TestRequireSeqRejectsMissingSequence(t *testing.T) { + if err := requireSeq(nil); err == nil { + t.Fatal("expected missing sequence error") + } +} + +func TestOfficialBulkAchievementRequestMayOmitContentsGroup(t *testing.T) { + design := &gamedata.MissionDesign{ + Missions: map[gamedata.MissionKey][]gamedata.Reward{}, Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{}, + Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{ + {ContentsGroup: 7, GroupID: 1, ID: 1}: {AddExp: 3}, + }, + } + starter := &player.Starter{Version: "2.34.13"} + inventory, err := player.OpenInventory(filepath.Join(t.TempDir(), "items.json"), starter) + if err != nil { + t.Fatal(err) + } + service, err := Open(filepath.Join(t.TempDir(), "missions.json"), design, inventory) + if err != nil { + t.Fatal(err) + } + info := wire.AppendVarint(nil, 1, 1) + info = wire.AppendVarint(info, 2, 1) + request := wire.AppendVarint(nil, 1, 242) + request = wire.AppendBytes(request, 3, info) + code, response, handled, err := service.Handle("/AchievementClear", request) + if err != nil { + t.Fatal(err) + } + if !handled || code != 168 { + t.Fatalf("handled=%v code=%d", handled, code) + } + exp, found, err := wire.Varint(response, 1) + if err != nil || !found || exp != 3 { + t.Fatalf("exp=%d found=%v err=%v", exp, found, err) + } +} + +func TestOfficialBulkSectionRequestAcceptsDefaultGroupType(t *testing.T) { + design := &gamedata.MissionDesign{ + Missions: map[gamedata.MissionKey][]gamedata.Reward{}, Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{}, + Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{}, + } + starter := &player.Starter{Version: "2.34.13"} + inventory, err := player.OpenInventory(filepath.Join(t.TempDir(), "items.json"), starter) + if err != nil { + t.Fatal(err) + } + service, err := Open(filepath.Join(t.TempDir(), "missions.json"), design, inventory) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 233) + request = wire.AppendVarint(request, 3, 1) + code, _, handled, err := service.Handle("/MissionSectionReward", request) + if err != nil { + t.Fatal(err) + } + if !handled || code != 122 { + t.Fatalf("handled=%v code=%d", handled, code) + } +} + +func TestMissionProgressIsNotClaimedAndDailyTypeZeroIsValid(t *testing.T) { + key := gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 101} + design := &gamedata.MissionDesign{ + Missions: map[gamedata.MissionKey][]gamedata.Reward{key: {{Type: 4, Count: 1000}}}, + Conditions: map[gamedata.MissionKey]gamedata.MissionCondition{key: {TargetValue: 1}}, + Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{}, Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{}, + } + dir := t.TempDir() + starter := &player.Starter{Version: "2.34.13"} + inv, err := player.OpenInventory(filepath.Join(dir, "items.json"), starter) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{}) + if err != nil { + t.Fatal(err) + } + service, err := Open(filepath.Join(dir, "missions.json"), design, inv) + if err != nil { + t.Fatal(err) + } + if err := service.AttachWallet(wallet); err != nil { + t.Fatal(err) + } + if err := service.CompleteMission(key); err != nil { + t.Fatal(err) + } + _, response, _, err := service.Handle("/MissionInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil { + t.Fatal(err) + } + entry, found, _ := wire.Bytes(response, 1) + if !found { + t.Fatal("mission missing") + } + value, _, _ := wire.Varint(entry, 4) + claimed, claimedFound, _ := wire.Varint(entry, 5) + if value != 1 || claimedFound || claimed != 0 { + t.Fatalf("value=%d claimed=%d/%v", value, claimed, claimedFound) + } + clear := wire.AppendVarint(nil, 1, 2) + clear = wire.AppendVarint(clear, 4, 1) + clear = wire.AppendVarint(clear, 5, 101) + if _, _, _, err := service.Handle("/MissionClear", clear); err != nil { + t.Fatal(err) + } + if got := wallet.Snapshot().Gold; got != 1000 { + t.Fatalf("gold=%d", got) + } +} diff --git a/go/internal/pictorial/service.go b/go/internal/pictorial/service.go new file mode 100644 index 0000000..9f7f260 --- /dev/null +++ b/go/internal/pictorial/service.go @@ -0,0 +1,278 @@ +// Package pictorial resolves account-owned collections through the installed +// GameData. Captured protobufs are never runtime inputs. +package pictorial + +import ( + "errors" + "fmt" + "math" + "sort" + "sync" + + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +type Owned interface { + PictorialCharacters() []player.Character + PictorialCostumes() []player.Costume + PictorialItems() []player.Item + PictorialEquipment() []player.Equipment + PictorialDiscovered() []player.Pictorial +} + +type Service struct { + Design *gamedata.PictorialDesign + Owned Owned + baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP +} + +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/PictorialBookInfo" && path != "/AllCharRefresh" { + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("pictorial: %s missing sequence", path) + } + entries, buffs, err := s.Snapshot() + if err != nil { + return 0, nil, true, err + } + var response []byte + if path == "/PictorialBookInfo" { + for _, entry := range entries { + book := wire.AppendVarint(nil, 1, entry.GroupID) + book = wire.AppendVarint(book, 2, entry.ID) + response = wire.AppendBytes(response, 1, book) + } + return 111, response, true, nil + } + for _, buff := range buffs { + response = wire.AppendBytes(response, 1, BuffWire(buff)) + } + return 165, response, true, nil +} + +type Entry struct { + GroupID uint64 + ID uint64 + BuffID uint64 +} + +func BuffWire(buff gamedata.PictorialBuffStat) []byte { + proto := wire.AppendVarint(nil, 1, buff.StatType) + proto = wire.AppendDouble(proto, 2, buff.Value) + if buff.Category != 0 { + proto = wire.AppendVarint(proto, 3, buff.Category) + } + return proto +} + +// Snapshot computes both network responses from the exact same owned-account +// view. Identical buff IDs on distinct completed pictorial entries contribute +// once per entry (e.g. three different collection quests all use buff 2002). +// Duplicate inventory instances of the SAME entry cannot contribute twice. +func (s *Service) Snapshot() ([]Entry, []gamedata.PictorialBuffStat, error) { + if s == nil || s.Design == nil || s.Owned == nil { + return nil, nil, errors.New("pictorial: missing design or account ownership") + } + d := s.Design + unique, talents := map[uint64]bool{}, map[uint64]bool{} + for _, character := range s.Owned.PictorialCharacters() { + meta, found := d.CharMeta[character.ID] + if !found { + return nil, nil, fmt.Errorf("pictorial: unknown owned character design %d", character.ID) + } + if meta.UsePackTemporary { + continue + } + unique[meta.UniqueID] = true + if meta.TalentID != 0 { + talents[meta.TalentID] = true + } + } + costumes := map[uint64]uint64{} + for _, costume := range s.Owned.PictorialCostumes() { + if previous, found := costumes[costume.ID]; !found || costume.Level > previous { + costumes[costume.ID] = costume.Level + } + // Level 0 is the first, owned-only threshold. Presence in the map + // is sufficient even when zero is the stored level. + } + equipment := map[uint64]bool{} + for _, owned := range s.Owned.PictorialEquipment() { + equipment[owned.ID] = true + } + items := map[[2]uint64]bool{} + itemOrder := map[uint64]int{} + itemIndex := 0 + books := map[Entry]bool{} + for _, book := range s.Owned.PictorialDiscovered() { + books[Entry{GroupID: book.ID, ID: book.GroupID}] = true + } + for _, owned := range s.Owned.PictorialItems() { + if owned.Count != 0 { + items[[2]uint64{owned.Type, owned.ID}] = true + if _, found := itemOrder[owned.ID]; !found && (owned.Type == 13 || owned.Type == 17) { + itemOrder[owned.ID] = itemIndex + itemIndex++ + } + if owned.Pictorialbook != nil { + books[Entry{GroupID: owned.Pictorialbook.ID, ID: owned.Pictorialbook.GroupID}] = true + } + } + } + var entries []Entry + entryOrder := map[uint64]int{} + for _, row := range d.Characters { + if allPresent(row.UniqueIDs, unique) { + entries = append(entries, Entry{gamedata.PictorialCharacter, row.ID, row.BuffID}) + } + } + for _, row := range d.Talents { + if allPresent(row.TalentIDs, talents) || books[Entry{GroupID: gamedata.PictorialTalent, ID: row.ID}] { + entries = append(entries, Entry{gamedata.PictorialTalent, row.ID, 0}) + } + } + for _, row := range d.Costumes { + level, found := costumes[row.CostumeID] + if !found { + continue + } + buffID := uint64(0) + for i, threshold := range row.Thresholds { + if threshold <= level { + buffID = row.BuffIDs[i] + } + } + entries = append(entries, Entry{gamedata.PictorialCostume, row.ID, buffID}) + } + for _, row := range d.Equipment { + if equipment[row.EquipmentID] { + entries = append(entries, Entry{gamedata.PictorialEquipment, row.ID, row.BuffID}) + } + } + for _, row := range d.Cooking { + if items[[2]uint64{14, row.RecipeID}] || books[Entry{GroupID: gamedata.PictorialCooking, ID: row.ID}] { + entries = append(entries, Entry{gamedata.PictorialCooking, row.ID, row.BuffID}) + } + } + for _, row := range d.Items { + itemType := uint64(17) + if row.Category == 1 { + itemType = 13 + } + if items[[2]uint64{itemType, row.ItemID}] || books[Entry{GroupID: gamedata.PictorialItem, ID: row.ID}] { + entry := Entry{gamedata.PictorialItem, row.ID, row.BuffID} + entries = append(entries, entry) + order, found := itemOrder[row.ItemID] + if !found { + order = len(itemOrder) + int(row.ID) + } + entryOrder[row.ID] = order + } + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].GroupID != entries[j].GroupID { + return entries[i].GroupID < entries[j].GroupID + } + if entries[i].GroupID == gamedata.PictorialItem { + // Official PictorialBookInfo preserves discovery order for item + // collections (starter 30021, then quest 2101 and 2102). + first, second := entryOrder[entries[i].ID], entryOrder[entries[j].ID] + if first != second { + return first < second + } + } + return entries[i].ID < entries[j].ID + }) + type key struct{ category, stat uint64 } + values := map[key]float64{} + for _, entry := range entries { + if entry.BuffID == 0 { + continue + } + buff, found := d.Buffs[entry.BuffID] + if !found { + return nil, nil, fmt.Errorf("pictorial: buff %d missing for entry %d/%d", entry.BuffID, entry.GroupID, entry.ID) + } + // DB StatValue is defined to four decimal places. Accumulate fixed + // ten-thousandths to prevent binary float drift across many rows. + values[key{buff.Category, buff.StatType}] += buff.Value + } + buffs := make([]gamedata.PictorialBuffStat, 0, len(values)) + for k, value := range values { + buffs = append(buffs, gamedata.PictorialBuffStat{Category: k.category, StatType: k.stat, Value: math.Round(value*100000000) / 100000000}) + } + sort.Slice(buffs, func(i, j int) bool { + if buffs[i].Category != buffs[j].Category { + return buffs[i].Category < buffs[j].Category + } + return buffs[i].StatType < buffs[j].StatType + }) + return entries, buffs, nil +} + +func allPresent(ids []uint64, available map[uint64]bool) bool { + for _, id := range ids { + if !available[id] { + return false + } + } + return len(ids) != 0 +} + +func (s *Service) Contributions() ([]gamedata.StatContribution, error) { + _, buffs, err := s.Snapshot() + if err != nil { + return nil, err + } + var result []gamedata.StatContribution + for _, buff := range buffs { + if buff.Category != 0 { + continue + } + contribution := gamedata.StatContribution{Percent: buff.Value} + switch buff.StatType { + case 1, 2: + contribution.Stat = gamedata.StatHealth + case 3, 4: + contribution.Stat = gamedata.StatAttack + case 5, 6: + contribution.Stat = gamedata.StatMagic + default: + continue + } + if buff.StatType%2 == 1 { + contribution.Flat, contribution.Percent = contribution.Percent, 0 + } + result = append(result, contribution) + } + return result, nil +} + +func (s *Service) MaxHealth(character player.Character) (uint64, error) { + if s == nil || s.Design == nil { + return 0, errors.New("pictorial: missing character stat design") + } + cacheKey := [2]uint64{character.ID, character.Level} + value, found := s.baseHealth.Load(cacheKey) + if !found { + base, err := gamedata.CharacterBaseStats(s.Design.Root, s.Design.Version, int(character.ID), int(character.Level)) + if err != nil { + return 0, err + } + value, _ = s.baseHealth.LoadOrStore(cacheKey, base.Health) + } + contributions, err := s.Contributions() + if err != nil { + return 0, err + } + maxHP := gamedata.AggregateStats(gamedata.BaseStats{Health: value.(float64)}, contributions).Health + if maxHP < 1 || maxHP > float64(^uint64(0)) { + return 0, fmt.Errorf("pictorial: invalid maximum health for character %d: %v", character.ID, maxHP) + } + return uint64(maxHP), nil +} diff --git a/go/internal/pictorial/service_test.go b/go/internal/pictorial/service_test.go new file mode 100644 index 0000000..0de231e --- /dev/null +++ b/go/internal/pictorial/service_test.go @@ -0,0 +1,162 @@ +package pictorial + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "math" + "os" + "path/filepath" + "testing" + + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/wire" +) + +type ownedState struct { + characters []player.Character + costumes []player.Costume + items []player.Item + equipment []player.Equipment + discovered []player.Pictorial +} + +func (s *ownedState) PictorialCharacters() []player.Character { return s.characters } +func (s *ownedState) PictorialCostumes() []player.Costume { return s.costumes } +func (s *ownedState) PictorialItems() []player.Item { return s.items } +func (s *ownedState) PictorialEquipment() []player.Equipment { return s.equipment } +func (s *ownedState) PictorialDiscovered() []player.Pictorial { return s.discovered } + +func TestOfficialPictorialProgression(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("set BD2_REAL_GAMEDATA to verify installed 2.34.13 GameData") + } + design, err := gamedata.LoadPictorialDesign(root, "20260910162539") + if err != nil { + t.Fatal(err) + } + starter, err := player.Load(filepath.Join("..", "..", "seed", "v2_34_13", "starter_player.json")) + if err != nil { + t.Fatal(err) + } + var world struct { + RewardCharacter player.Character `json:"reward_character"` + RewardCostume player.Costume `json:"reward_costume"` + StoryCharacters []player.Character `json:"story_characters"` + } + data, err := os.ReadFile(filepath.Join("..", "..", "seed", "v2_34_13", "world.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &world); err != nil { + t.Fatal(err) + } + owned := &ownedState{ + characters: append(append([]player.Character(nil), starter.Characters...), world.RewardCharacter), + costumes: append(append([]player.Costume(nil), starter.Costumes...), world.RewardCostume), + items: append([]player.Item(nil), starter.Items...), + discovered: append([]player.Pictorial(nil), starter.Pictorialbook...), + } + owned.items = append(owned.items, player.Item{ID: 2101, Type: 17, Count: 1}, player.Item{ID: 2102, Type: 17, Count: 1}) + service := &Service{Design: design, Owned: owned} + assertSnapshot := func(health, attack float64, equip bool, officialHex string) { + t.Helper() + entries, buffs, err := service.Snapshot() + if err != nil { + t.Fatal(err) + } + if !equip && len(entries) != 18 { + t.Fatalf("initial account pictorial entries=%+v, want 18 official entries", entries) + } + if equip && len(entries) != 20 { + t.Fatalf("quest-28 pictorial entries=%+v, want 20", entries) + } + got := map[uint64]float64{} + for _, buff := range buffs { + got[buff.StatType] = buff.Value + } + if math.Abs(got[2]-health) > 1e-9 || math.Abs(got[4]-attack) > 1e-9 { + t.Fatalf("entries=%+v buffs=%+v, want health=%v attack=%v", entries, buffs, health, attack) + } + foundEquip := false + for _, entry := range entries { + if entry.GroupID == gamedata.PictorialEquipment && entry.ID == 1 { + foundEquip = true + } + } + if foundEquip != equip { + t.Fatalf("equipment pictorial=%v, want=%v", foundEquip, equip) + } + if !equip { + _, bookResponse, handled, err := service.Handle("/PictorialBookInfo", wire.AppendVarint(nil, 1, 41)) + if err != nil || !handled { + t.Fatalf("encode pictorial entries: handled=%v err=%v", handled, err) + } + officialBook, err := hex.DecodeString("0a040801101f0a04080110200a04080110210a04080110220a04080110230a040801103c" + + "0a04080210010a04080210090a0408041001" + + "0a06080510c5ea010a05080510b5100a05080510b610" + + "0a040807101c0a040807103d0a040807103e0a040807103f0a04080710400a0408071074") + if err != nil || !bytes.Equal(bookResponse, officialBook) { + t.Fatalf("pictorial response=%x official=%x err=%v", bookResponse, officialBook, err) + } + } + _, actual, ok, err := service.Handle("/AllCharRefresh", wire.AppendVarint(nil, 1, 42)) + if err != nil || !ok { + t.Fatalf("encode account buffs: handled=%v err=%v", ok, err) + } + official, err := hex.DecodeString(officialHex) + if err != nil || !bytes.Equal(actual, official) { + t.Fatalf("account buffs proto=%x, official=%x err=%v", actual, official, err) + } + } + assertSnapshot(.015, .0096, false, "0a0b080211b81e85eb51b88e3f0a0b080411613255302aa9833f") + owned.equipment = append(owned.equipment, player.Equipment{InvenIndex: 910000001, ID: 10010}) + owned.items = append(owned.items, player.Item{ID: 2103, Type: 17, Count: 1}) + assertSnapshot(.0175, .01, true, "0a0b080211ec51b81e85eb913f0a0b0804117b14ae47e17a843f") + owned.equipment = append(owned.equipment, player.Equipment{InvenIndex: 910000002, ID: 10010}) + assertSnapshot(.0175, .01, true, "0a0b080211ec51b81e85eb913f0a0b0804117b14ae47e17a843f") // same entry does not stack + owned.characters[5].Level = 20 + maxHP, err := service.MaxHealth(owned.characters[5]) + if err != nil || maxHP != 513 { + t.Fatalf("official level-20 character HP=%d err=%v, want 513", maxHP, err) + } + code, response, ok, err := service.Handle("/AllCharRefresh", wire.AppendVarint(nil, 1, 42)) + if err != nil || !ok || code != 165 { + t.Fatalf("AllCharRefresh: code=%d handled=%v err=%v", code, ok, err) + } + var count int + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 1 { + count++ + } + return nil + }); err != nil || count != 2 { + t.Fatalf("buff proto: count=%d err=%v", count, err) + } +} + +func TestSharedBuffIDCountsDistinctEntriesButNotDuplicateInventory(t *testing.T) { + owned := &ownedState{items: []player.Item{{ID: 2101, Type: 17, Count: 1}, {ID: 2101, Type: 17, Count: 1}, {ID: 2102, Type: 17, Count: 1}, {ID: 2103, Type: 8, Count: 99}}} + design := &gamedata.PictorialDesign{ + Items: []gamedata.ItemPictorial{{ID: 2101, ItemID: 2101, BuffID: 2002}, {ID: 2102, ItemID: 2102, BuffID: 2002}}, + Buffs: map[uint64]gamedata.PictorialBuff{2002: {ID: 2002, StatType: 2, Value: .0025}}, + } + entries, buffs, err := (&Service{Design: design, Owned: owned}).Snapshot() + if err != nil || len(entries) != 2 || len(buffs) != 1 || buffs[0].Value != .005 { + t.Fatalf("distinct-entries aggregation entries=%v buffs=%v err=%v", entries, buffs, err) + } +} + +func TestGrowthResourceIDCannotMasqueradeAsCollectionItem(t *testing.T) { + owned := &ownedState{items: []player.Item{{ID: 8, Type: 8, Count: 99}}} + design := &gamedata.PictorialDesign{ + Items: []gamedata.ItemPictorial{{ID: 8, ItemID: 8, BuffID: 2002, Category: 0}}, + Buffs: map[uint64]gamedata.PictorialBuff{2002: {ID: 2002, StatType: 2, Value: .0025}}, + } + entries, buffs, err := (&Service{Design: design, Owned: owned}).Snapshot() + if err != nil || len(entries) != 0 || len(buffs) != 0 { + t.Fatalf("resource incorrectly unlocked pictorial entries=%v buffs=%v err=%v", entries, buffs, err) + } +} diff --git a/go/internal/player/collection.go b/go/internal/player/collection.go new file mode 100644 index 0000000..57037a1 --- /dev/null +++ b/go/internal/player/collection.go @@ -0,0 +1,794 @@ +package player + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "bd2server/internal/gamedata" +) + +type CostumeUpgrade struct { + InvenIndex uint64 `json:"inven_index"` + CostumeID uint64 `json:"costume_id"` + Before uint64 `json:"before"` + After uint64 `json:"after"` + SortID uint64 `json:"sort_id"` +} + +type CostumeExchange struct { + InvenIndex uint64 `json:"inven_index,omitempty"` + OriginalItemType uint64 `json:"original_item_type"` + OriginalItemID uint64 `json:"original_item_id"` + OriginalCount uint64 `json:"original_count"` + ExchangeItemType uint64 `json:"exchange_item_type"` + ExchangeItemID uint64 `json:"exchange_item_id,omitempty"` + ExchangeCount uint64 `json:"exchange_count"` + SortID uint64 `json:"sort_id"` +} + +type CollectionGrant struct { + CharacterIndices []uint64 `json:"character_indices,omitempty"` + CostumeIndices []uint64 `json:"costume_indices,omitempty"` + Upgrades []CostumeUpgrade `json:"upgrades,omitempty"` + Exchanges []CostumeExchange `json:"exchanges,omitempty"` + ViewCostumeIDs []uint64 `json:"view_costume_ids"` + GachaGroupID uint64 `json:"gacha_group_id,omitempty"` + GachaPoint uint64 `json:"gacha_point,omitempty"` + GachaFixed []GachaFixedState `json:"gacha_fixed,omitempty"` + SelectionApplySortIDs []uint64 `json:"selection_apply_sort_ids,omitempty"` +} + +type GachaUserState struct { + GroupID uint64 `json:"group_id"` + Point uint64 `json:"point"` + TotalBuyCount uint64 `json:"total_buy_count,omitempty"` + OneFreePickCount uint64 `json:"one_free_pick_count,omitempty"` + OneCashPickCount uint64 `json:"one_cash_pick_count,omitempty"` + TenFreePickCount uint64 `json:"ten_free_pick_count,omitempty"` + TenCashPickCount uint64 `json:"ten_cash_pick_count,omitempty"` + ExchangeItemCount uint64 `json:"exchange_item_count,omitempty"` + ExchangeMileageCount uint64 `json:"exchange_mileage_count,omitempty"` +} + +type GachaFixedState struct { + FixedID uint64 `json:"fixed_id"` + Type uint64 `json:"type"` + Count uint64 `json:"count"` + ApplySort int `json:"apply_sort_id"` +} + +type GachaPointExchange struct { + GroupID uint64 `json:"group_id"` + Count uint64 `json:"count"` +} + +type GachaPurchase struct { + Group gamedata.GachaGroupDesign + BuyType uint64 + Fixed []GachaFixedState + SelectionApplySortIDs []uint64 +} + +type GachaSelection struct { + GroupID uint64 `json:"group_id"` + Slot uint64 `json:"slot"` + ItemID uint64 `json:"item_id"` +} + +type collectionSnapshot struct { + Version string `json:"version"` + NextCharacterIndex uint64 `json:"next_character_index"` + NextCostumeIndex uint64 `json:"next_costume_index"` + LatestPreview []uint64 `json:"latest_preview,omitempty"` + PreviewEventIndex uint64 `json:"preview_event_index,omitempty"` + PreviewLocked bool `json:"preview_locked,omitempty"` + Characters []Character `json:"characters,omitempty"` + Costumes []Costume `json:"costumes,omitempty"` + BaseCostumeLevels map[string]uint64 `json:"base_costume_levels,omitempty"` + GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"` + StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"` + GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"` + GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"` + GachaApplied map[string]bool `json:"gacha_applied,omitempty"` + GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"` + GachaCountCorrected bool `json:"gacha_count_corrected,omitempty"` + Grants map[string]CollectionGrant `json:"grants"` +} + +// CollectionStore owns non-stackable character/costume rewards as one atomic +// save. It is shared by gacha, CharInfo, CostumeInfo and CharGrowth so a roll +// cannot exist only in the result animation and disappear after relogging. +type CollectionStore struct { + mu sync.Mutex + path string + base []Costume + baseCharacters []Character + data collectionSnapshot +} + +func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error) { + s := &CollectionStore{path: filepath.Clean(path), base: append([]Costume(nil), base...), data: collectionSnapshot{ + Version: "2.34.13", NextCharacterIndex: 920000001, NextCostumeIndex: 930000001, + BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, + StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{}, + GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{}, + }} + b, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(b, &s.data); err != nil { + return nil, fmt.Errorf("player: decode collection: %w", err) + } + if s.data.Version != "2.34.13" || s.data.NextCharacterIndex < 920000001 || s.data.NextCostumeIndex < 930000001 || s.data.Grants == nil { + return nil, errors.New("player: invalid collection save") + } + if s.data.BaseCostumeLevels == nil { + s.data.BaseCostumeLevels = map[string]uint64{} + } + if s.data.GachaSelections == nil { + s.data.GachaSelections = map[string][]GachaSelection{} + } + if s.data.StepUpProgress == nil { + s.data.StepUpProgress = map[string]uint64{} + } + if s.data.GachaUsers == nil { + s.data.GachaUsers = map[string]GachaUserState{} + } + if s.data.GachaFixed == nil { + s.data.GachaFixed = map[string]GachaFixedState{} + } + if s.data.GachaApplied == nil { + s.data.GachaApplied = map[string]bool{} + } + if s.data.GachaPointExchange == nil { + s.data.GachaPointExchange = map[string]GachaPointExchange{} + } + if err := validateCharacters(s.data.Characters); err != nil && len(s.data.Characters) != 0 { + return nil, err + } + return s, nil +} + +// AttachBaseCharacters supplies the character instances owned by the world +// and starter stores. Collection characters must share those instances: a new +// costume for an already-owned CharTable ID is not a second character. The +// repair also migrates saves written by the old one-character-per-costume +// implementation without deleting any costume, level, or gacha grant. +func (s *CollectionStore) AttachBaseCharacters(base []Character) error { + if err := validateCharacters(base); err != nil && len(base) != 0 { + return fmt.Errorf("player: invalid base characters: %w", err) + } + s.mu.Lock() + defer s.mu.Unlock() + + next := cloneCollection(s.data) + canonical := make(map[uint64]uint64, len(base)+len(next.Characters)) + for _, character := range base { + if _, exists := canonical[character.ID]; !exists { + canonical[character.ID] = character.InvenIndex + } + } + + redirect := make(map[uint64]uint64) + kept := make([]Character, 0, len(next.Characters)) + for _, character := range next.Characters { + if existing, exists := canonical[character.ID]; exists { + redirect[character.InvenIndex] = existing + continue + } + canonical[character.ID] = character.InvenIndex + kept = append(kept, character) + } + + changed := len(kept) != len(next.Characters) + if changed { + next.Characters = kept + for i := range next.Costumes { + if replacement, exists := redirect[next.Costumes[i].UseChar]; exists { + next.Costumes[i].UseChar = replacement + } + } + for identity, grant := range next.Grants { + indices := grant.CharacterIndices[:0] + for _, index := range grant.CharacterIndices { + if _, removed := redirect[index]; !removed { + indices = append(indices, index) + } + } + grant.CharacterIndices = indices + next.Grants[identity] = grant + } + if err := s.commit(next); err != nil { + return err + } + } + s.baseCharacters = append([]Character(nil), base...) + return nil +} + +// BindBaseCharacters attaches the authoritative base roster after the +// Haskell startup repair has removed duplicate designs. It never writes state. +func (s *CollectionStore) BindBaseCharacters(base []Character) error { + if err := validateCharacters(base); err != nil && len(base) != 0 { + return fmt.Errorf("player: invalid base characters: %w", err) + } + s.mu.Lock() + defer s.mu.Unlock() + seen := make(map[uint64]uint64, len(base)+len(s.data.Characters)) + for _, character := range base { + if prior := seen[character.ID]; prior != 0 { + return fmt.Errorf("player: duplicate base character design %d", character.ID) + } + seen[character.ID] = character.InvenIndex + } + for _, character := range s.data.Characters { + if prior := seen[character.ID]; prior != 0 { + return fmt.Errorf("player: unrepaired duplicate character design %d (%d and %d)", character.ID, prior, character.InvenIndex) + } + seen[character.ID] = character.InvenIndex + } + s.baseCharacters = append([]Character(nil), base...) + return nil +} + +// RepairStepUpProgress derives the completed step count from durable grants +// written before step-up progress had its own save field. Grant identities may +// contain either the legacy request sequence or the newer login-session ID. +func (s *CollectionStore) RepairStepUpProgress(groupID uint64, gachaIDs []uint64) (uint64, error) { + if groupID == 0 || len(gachaIDs) == 0 { + return 0, errors.New("player: invalid step-up design") + } + s.mu.Lock() + defer s.mu.Unlock() + key := strconv.FormatUint(groupID, 10) + completed := s.data.StepUpProgress[key] + if completed > uint64(len(gachaIDs)) { + return 0, fmt.Errorf("player: step-up group %d progress %d exceeds %d", groupID, completed, len(gachaIDs)) + } + derived := uint64(0) + for i, gachaID := range gachaIDs { + prefix := fmt.Sprintf("regular-gacha:%d:", gachaID) + found := false + for identity := range s.data.Grants { + if strings.HasPrefix(identity, prefix) { + found = true + break + } + } + if !found { + break + } + derived = uint64(i + 1) + } + if derived <= completed { + return completed, nil + } + next := cloneCollection(s.data) + next.StepUpProgress[key] = derived + if err := s.commit(next); err != nil { + return 0, err + } + return derived, nil +} + +func (s *CollectionStore) StepUpProgress(groupID uint64) uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.StepUpProgress[strconv.FormatUint(groupID, 10)] +} + +// CompleteStepUp atomically advances one group. Repeating the same completed +// step is allowed so a lost HTTP response can be retried without reopening it. +func (s *CollectionStore) CompleteStepUp(groupID, step uint64) error { + if groupID == 0 || step == 0 { + return errors.New("player: invalid step-up completion") + } + s.mu.Lock() + defer s.mu.Unlock() + key := strconv.FormatUint(groupID, 10) + completed := s.data.StepUpProgress[key] + if completed >= step { + return nil + } + if completed+1 != step { + return fmt.Errorf("player: step-up group %d expects step %d, got %d", groupID, completed+1, step) + } + next := cloneCollection(s.data) + next.StepUpProgress[key] = step + return s.commit(next) +} + +func (s *CollectionStore) SaveGachaSelections(groupID uint64, selections []GachaSelection) error { + if groupID == 0 || len(selections) == 0 { + return errors.New("player: invalid gacha selection") + } + s.mu.Lock() + defer s.mu.Unlock() + next := cloneCollection(s.data) + next.GachaSelections[strconv.FormatUint(groupID, 10)] = append([]GachaSelection(nil), selections...) + return s.commit(next) +} + +func (s *CollectionStore) GachaSelections(groupID uint64) []GachaSelection { + s.mu.Lock() + defer s.mu.Unlock() + return append([]GachaSelection(nil), s.data.GachaSelections[strconv.FormatUint(groupID, 10)]...) +} + +func (s *CollectionStore) SetPreview(costumeIDs []uint64) error { + if len(costumeIDs) != 10 { + return errors.New("player: infinite preview must contain ten costumes") + } + s.mu.Lock() + defer s.mu.Unlock() + next := cloneCollection(s.data) + next.LatestPreview = append([]uint64(nil), costumeIDs...) + next.PreviewEventIndex = 0 + next.PreviewLocked = false + return s.commit(next) +} + +// LockPreview records the official Resemara confirmation boundary. The +// client sends the schedule's EventIndex after the player accepts the latest +// preview; generating another preview clears this lock again. +func (s *CollectionStore) LockPreview(eventIndex uint64) error { + if eventIndex == 0 { + return errors.New("player: invalid infinite preview event index") + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.data.LatestPreview) != 10 { + return errors.New("player: no complete infinite gacha preview to lock") + } + next := cloneCollection(s.data) + next.PreviewEventIndex = eventIndex + next.PreviewLocked = true + return s.commit(next) +} + +func (s *CollectionStore) PreviewLock() (uint64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.PreviewEventIndex, s.data.PreviewLocked +} + +func (s *CollectionStore) LatestPreview() []uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return append([]uint64(nil), s.data.LatestPreview...) +} + +func (s *CollectionStore) ConfirmInfinite(identity string, design *gamedata.InfiniteGachaDesign) (CollectionGrant, error) { + if identity == "" || design == nil { + return CollectionGrant{}, errors.New("player: invalid collection grant") + } + s.mu.Lock() + preview := append([]uint64(nil), s.data.LatestPreview...) + locked := s.data.PreviewLocked && s.data.PreviewEventIndex != 0 + s.mu.Unlock() + if len(preview) != design.Count { + return CollectionGrant{}, errors.New("player: no complete infinite gacha preview to confirm") + } + if !locked { + return CollectionGrant{}, errors.New("player: infinite gacha preview is not locked") + } + return s.grantCostumes(identity, preview, design.Character, nil) +} + +func (s *CollectionStore) GrantRegular(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog) (CollectionGrant, error) { + if identity == "" || len(costumeIDs) == 0 || design == nil { + return CollectionGrant{}, errors.New("player: invalid regular gacha grant") + } + return s.grantCostumes(identity, costumeIDs, design.Character, nil) +} + +func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog, purchase GachaPurchase) (CollectionGrant, error) { + if identity == "" || len(costumeIDs) == 0 || design == nil || purchase.Group.ID == 0 { + return CollectionGrant{}, errors.New("player: invalid regular gacha purchase") + } + return s.grantCostumes(identity, costumeIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error { + applyGachaPurchase(next, identity, costumeIDs, purchase, grant) + return nil + }) +} + +// GrantEquipmentPurchase records only the durable gacha accounting. Equipment +// instances belong to EquipmentInventory (they have a different wire type), +// while points and fixed counters share GachaUserDBInfo/GachaFixedDBInfo with +// costume draws. The stored empty grant is the idempotency marker. +func (s *CollectionStore) GrantEquipmentPurchase(identity string, count uint64, purchase GachaPurchase) (CollectionGrant, error) { + if identity == "" || count == 0 || purchase.Group.ID == 0 { + return CollectionGrant{}, errors.New("player: invalid equipment gacha purchase") + } + s.mu.Lock() + defer s.mu.Unlock() + if grant, ok := s.data.Grants[identity]; ok { + return cloneGrant(grant), nil + } + next := cloneCollection(s.data) + grant := CollectionGrant{} + applyGachaPurchase(&next, identity, make([]uint64, count), purchase, &grant) + next.Grants[identity] = cloneGrant(grant) + if err := s.commit(next); err != nil { + return CollectionGrant{}, err + } + return grant, nil +} + +func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) { + s.mu.Lock() + defer s.mu.Unlock() + if grant, ok := s.data.Grants[identity]; ok { + return cloneGrant(grant), nil + } + next := cloneCollection(s.data) + grant := CollectionGrant{ViewCostumeIDs: append([]uint64(nil), costumeIDs...)} + now := uint64(time.Now().UnixMilli()) + for sortIndex, costumeID := range costumeIDs { + if costumeID == 0 { + return CollectionGrant{}, errors.New("player: preview has invalid costume") + } + characterDesign, found := character(costumeID) + if !found { + return CollectionGrant{}, fmt.Errorf("player: costume %d has no character design", costumeID) + } + maxLevel := characterDesign.CostumeMaxLevel + if maxLevel == 0 { // compatibility for small constructor-only unit fixtures + maxLevel = 5 + } + if position, found := findCostume(next.Costumes, costumeID); found { + before := next.Costumes[position].Level + if before < maxLevel { + next.Costumes[position].Level++ + grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: next.Costumes[position].InvenIndex, CostumeID: costumeID, Before: before, After: next.Costumes[position].Level, SortID: uint64(sortIndex)}) + } else { + exchange, err := costumeOverflowExchange(costumeID, next.Costumes[position].InvenIndex, uint64(sortIndex), characterDesign) + if err != nil { + return CollectionGrant{}, err + } + grant.Exchanges = append(grant.Exchanges, exchange) + grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: exchange.InvenIndex, CostumeID: costumeID, Before: maxLevel, After: maxLevel, SortID: exchange.SortID}) + } + continue + } + if base, found := findCostume(s.base, costumeID); found { + key := strconv.FormatUint(s.base[base].InvenIndex, 10) + before := next.BaseCostumeLevels[key] + if before < s.base[base].Level { + before = s.base[base].Level + } + if before < maxLevel { + next.BaseCostumeLevels[key] = before + 1 + grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: s.base[base].InvenIndex, CostumeID: costumeID, Before: before, After: before + 1, SortID: uint64(sortIndex)}) + } else { + exchange, err := costumeOverflowExchange(costumeID, s.base[base].InvenIndex, uint64(sortIndex), characterDesign) + if err != nil { + return CollectionGrant{}, err + } + grant.Exchanges = append(grant.Exchanges, exchange) + grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: exchange.InvenIndex, CostumeID: costumeID, Before: maxLevel, After: maxLevel, SortID: exchange.SortID}) + } + continue + } + characterIndex, characterExists := findCharacterByID(s.baseCharacters, next.Characters, characterDesign.ID) + costumeIndex := next.NextCostumeIndex + next.NextCostumeIndex++ + if !characterExists { + characterIndex = next.NextCharacterIndex + next.NextCharacterIndex++ + ownedCharacter := Character{InvenIndex: characterIndex, ID: characterDesign.ID, HP: characterDesign.HP, Level: 1, + CostumeID: costumeID, UseCostume: costumeIndex, TalentLevel: 1, SolidarityReward: 1, + ExpiryTime: ^uint64(0) - 32399999, ConnectPotentialCostume: costumeID} + next.Characters = append(next.Characters, ownedCharacter) + grant.CharacterIndices = append(grant.CharacterIndices, characterIndex) + } + costume := Costume{InvenIndex: costumeIndex, ID: costumeID, UseChar: characterIndex, SortID: uint64(sortIndex), TimeValue: now} + next.Costumes = append(next.Costumes, costume) + grant.CostumeIndices = append(grant.CostumeIndices, costumeIndex) + } + if mutate != nil { + if err := mutate(&next, &grant); err != nil { + return CollectionGrant{}, err + } + } + next.Grants[identity] = cloneGrant(grant) + if err := s.commit(next); err != nil { + return CollectionGrant{}, err + } + return grant, nil +} + +func costumeOverflowExchange(costumeID, invenIndex, sortID uint64, design gamedata.CharacterDesign) (CostumeExchange, error) { + if design.OverflowItemType == 0 || design.OverflowItemCount == 0 { + return CostumeExchange{}, fmt.Errorf("player: costume %d has no overflow exchange design", costumeID) + } + return CostumeExchange{ + InvenIndex: invenIndex, + OriginalItemType: 11, OriginalItemID: costumeID, OriginalCount: 1, + ExchangeItemType: design.OverflowItemType, ExchangeItemID: design.OverflowItemID, + ExchangeCount: design.OverflowItemCount, SortID: sortID, + }, nil +} + +// RepairCostumeOverflow migrates grants written by the pre-cap implementation. +// It rewrites every illegal +6/+7 upgrade into the same GameData-driven +// post-max exchange a fresh request would have produced, then caps persisted +// CostumeDBInfo levels. Returning all exchange-bearing grants lets the wallet +// idempotently recover currency even after a crash between the two commits. +func (s *CollectionStore) RepairCostumeOverflow(character func(uint64) (gamedata.CharacterDesign, bool)) (map[string][]CostumeExchange, error) { + if character == nil { + return nil, errors.New("player: nil costume design resolver") + } + s.mu.Lock() + defer s.mu.Unlock() + next := cloneCollection(s.data) + changed := false + for i := range next.Costumes { + design, ok := character(next.Costumes[i].ID) + if !ok || design.CostumeMaxLevel == 0 { + return nil, fmt.Errorf("player: costume %d has no max-level design", next.Costumes[i].ID) + } + if next.Costumes[i].Level > design.CostumeMaxLevel { + next.Costumes[i].Level = design.CostumeMaxLevel + changed = true + } + } + for i := range s.base { + key := strconv.FormatUint(s.base[i].InvenIndex, 10) + level := next.BaseCostumeLevels[key] + if level == 0 { + level = s.base[i].Level + } + design, ok := character(s.base[i].ID) + if !ok { + continue // starter costumes outside the active gacha pools are untouched + } + if design.CostumeMaxLevel == 0 { + return nil, fmt.Errorf("player: costume %d has no max-level design", s.base[i].ID) + } + if level > design.CostumeMaxLevel { + next.BaseCostumeLevels[key] = design.CostumeMaxLevel + changed = true + } + } + for identity, grant := range next.Grants { + kept := make([]CostumeUpgrade, 0, len(grant.Upgrades)) + for _, upgrade := range grant.Upgrades { + design, ok := character(upgrade.CostumeID) + if !ok || design.CostumeMaxLevel == 0 { + return nil, fmt.Errorf("player: costume %d has no overflow repair design", upgrade.CostumeID) + } + if upgrade.After > design.CostumeMaxLevel { + exchange, err := costumeOverflowExchange(upgrade.CostumeID, upgrade.InvenIndex, upgrade.SortID, design) + if err != nil { + return nil, err + } + grant.Exchanges = append(grant.Exchanges, exchange) + changed = true + continue + } + kept = append(kept, upgrade) + } + grant.Upgrades = kept + for i := range grant.Exchanges { + exchange := &grant.Exchanges[i] + if exchange.InvenIndex == 0 { + if position, found := findCostume(next.Costumes, exchange.OriginalItemID); found { + exchange.InvenIndex = next.Costumes[position].InvenIndex + } else if position, found := findCostume(s.base, exchange.OriginalItemID); found { + exchange.InvenIndex = s.base[position].InvenIndex + } else { + return nil, fmt.Errorf("player: overflow costume %d has no inventory instance", exchange.OriginalItemID) + } + changed = true + } + found := false + for _, upgrade := range grant.Upgrades { + if upgrade.CostumeID == exchange.OriginalItemID && upgrade.SortID == exchange.SortID { + found = true + break + } + } + if !found { + design, _ := character(exchange.OriginalItemID) + grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: exchange.InvenIndex, CostumeID: exchange.OriginalItemID, Before: design.CostumeMaxLevel, After: design.CostumeMaxLevel, SortID: exchange.SortID}) + changed = true + } + } + next.Grants[identity] = grant + } + if changed { + if err := s.commit(next); err != nil { + return nil, err + } + } + result := make(map[string][]CostumeExchange) + for identity, grant := range next.Grants { + if len(grant.Exchanges) != 0 { + result[identity] = append([]CostumeExchange(nil), grant.Exchanges...) + } + } + return result, nil +} + +func (s *CollectionStore) Characters() []Character { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Character(nil), s.data.Characters...) +} + +func (s *CollectionStore) FindCharacter(index uint64) (Character, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, c := range s.data.Characters { + if c.InvenIndex == index { + return c, true + } + } + return Character{}, false +} + +func (s *CollectionStore) UpdateCharacter(character Character) error { + s.mu.Lock() + defer s.mu.Unlock() + next := cloneCollection(s.data) + for i := range next.Characters { + if next.Characters[i].InvenIndex == character.InvenIndex && next.Characters[i].ID == character.ID { + next.Characters[i] = character + return s.commit(next) + } + } + return fmt.Errorf("player: collection character %d not found", character.InvenIndex) +} + +func (s *CollectionStore) Costumes() []Costume { + s.mu.Lock() + defer s.mu.Unlock() + result := append([]Costume(nil), s.base...) + for i := range result { + if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level { + result[i].Level = level + } + } + return append(result, s.data.Costumes...) +} + +func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) { + s.mu.Lock() + defer s.mu.Unlock() + grant, ok := s.data.Grants[identity] + return cloneGrant(grant), ok +} + +func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) { + for _, character := range s.Characters() { + if character.InvenIndex == index { + return character, true + } + } + return Character{}, false +} + +func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) { + for _, costume := range s.Costumes() { + if costume.InvenIndex == index { + return costume, true + } + } + return Costume{}, false +} + +func findCostume(costumes []Costume, id uint64) (int, bool) { + for i, costume := range costumes { + if costume.ID == id { + return i, true + } + } + return -1, false +} + +func findCharacterByID(base, collection []Character, id uint64) (uint64, bool) { + for _, character := range base { + if character.ID == id { + return character.InvenIndex, true + } + } + for _, character := range collection { + if character.ID == id { + return character.InvenIndex, true + } + } + return 0, false +} + +func cloneGrant(grant CollectionGrant) CollectionGrant { + grant.CharacterIndices = append([]uint64(nil), grant.CharacterIndices...) + grant.CostumeIndices = append([]uint64(nil), grant.CostumeIndices...) + grant.Upgrades = append([]CostumeUpgrade(nil), grant.Upgrades...) + grant.Exchanges = append([]CostumeExchange(nil), grant.Exchanges...) + grant.ViewCostumeIDs = append([]uint64(nil), grant.ViewCostumeIDs...) + grant.GachaFixed = append([]GachaFixedState(nil), grant.GachaFixed...) + grant.SelectionApplySortIDs = append([]uint64(nil), grant.SelectionApplySortIDs...) + return grant +} + +func cloneCollection(in collectionSnapshot) collectionSnapshot { + out := in + out.LatestPreview = append([]uint64(nil), in.LatestPreview...) + out.Characters = append([]Character(nil), in.Characters...) + out.Costumes = append([]Costume(nil), in.Costumes...) + out.BaseCostumeLevels = make(map[string]uint64, len(in.BaseCostumeLevels)) + for k, v := range in.BaseCostumeLevels { + out.BaseCostumeLevels[k] = v + } + out.GachaSelections = make(map[string][]GachaSelection, len(in.GachaSelections)) + for k, v := range in.GachaSelections { + out.GachaSelections[k] = append([]GachaSelection(nil), v...) + } + out.StepUpProgress = make(map[string]uint64, len(in.StepUpProgress)) + for k, v := range in.StepUpProgress { + out.StepUpProgress[k] = v + } + out.GachaUsers = make(map[string]GachaUserState, len(in.GachaUsers)) + for k, v := range in.GachaUsers { + out.GachaUsers[k] = v + } + out.GachaFixed = make(map[string]GachaFixedState, len(in.GachaFixed)) + for k, v := range in.GachaFixed { + out.GachaFixed[k] = v + } + out.GachaApplied = make(map[string]bool, len(in.GachaApplied)) + for k, v := range in.GachaApplied { + out.GachaApplied[k] = v + } + out.GachaPointExchange = make(map[string]GachaPointExchange, len(in.GachaPointExchange)) + for k, v := range in.GachaPointExchange { + out.GachaPointExchange[k] = v + } + out.Grants = make(map[string]CollectionGrant, len(in.Grants)) + for k, v := range in.Grants { + out.Grants[k] = cloneGrant(v) + } + return out +} + +func (s *CollectionStore) commit(next collectionSnapshot) error { + b, err := json.Marshal(next) + if err != nil { + return err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + f, err := os.CreateTemp(dir, ".collection-*.tmp") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(b); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(f.Name(), s.path) + } + if err != nil { + return fmt.Errorf("player: persist collection: %w", err) + } + s.data = next + return nil +} diff --git a/go/internal/player/collection_test.go b/go/internal/player/collection_test.go new file mode 100644 index 0000000..dde2764 --- /dev/null +++ b/go/internal/player/collection_test.go @@ -0,0 +1,215 @@ +package player + +import ( + "path/filepath" + "testing" + + "bd2server/internal/gamedata" +) + +func TestRepairCostumeOverflowCapsLegacyLevelAndIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "collection.json") + store, err := OpenCollectionStore(path, nil) + if err != nil { + t.Fatal(err) + } + store.data.Characters = []Character{{InvenIndex: 920000001, ID: 6490, Level: 1, UseCostume: 930000001, ConnectPotentialCostume: 64901}} + store.data.Costumes = []Costume{{InvenIndex: 930000001, ID: 64901, Level: 7, UseChar: 920000001}} + store.data.Grants["draw"] = CollectionGrant{ + Upgrades: []CostumeUpgrade{ + {InvenIndex: 930000001, CostumeID: 64901, Before: 4, After: 5}, + {InvenIndex: 930000001, CostumeID: 64901, Before: 5, After: 6, SortID: 1}, + {InvenIndex: 930000001, CostumeID: 64901, Before: 6, After: 7, SortID: 2}, + }, + ViewCostumeIDs: []uint64{64901, 64901, 64901}, + } + if err := store.commit(store.data); err != nil { + t.Fatal(err) + } + resolve := func(id uint64) (gamedata.CharacterDesign, bool) { + if id != 64901 { + return gamedata.CharacterDesign{}, false + } + return gamedata.CharacterDesign{ID: 6490, HP: 166, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}, true + } + repaired, err := store.RepairCostumeOverflow(resolve) + if err != nil { + t.Fatal(err) + } + if got := store.Costumes()[0].Level; got != 5 { + t.Fatalf("level=%d want=5", got) + } + grant, ok := store.Grant("draw") + if !ok || len(grant.Upgrades) != 3 || len(grant.Exchanges) != 2 || len(repaired["draw"]) != 2 { + t.Fatalf("grant=%+v repaired=%+v", grant, repaired) + } + for _, upgrade := range grant.Upgrades[1:] { + if upgrade.Before != 5 || upgrade.After != 5 || upgrade.InvenIndex != 930000001 { + t.Fatalf("overflow display upgrade=%+v", upgrade) + } + } + if _, err := store.RepairCostumeOverflow(resolve); err != nil { + t.Fatal(err) + } + grant, _ = store.Grant("draw") + if len(grant.Exchanges) != 2 || len(grant.Upgrades) != 3 { + t.Fatalf("second repair duplicated grant entries: %+v", grant) + } + restored, err := OpenCollectionStore(path, nil) + if err != nil { + t.Fatal(err) + } + if got := restored.Costumes()[0].Level; got != 5 { + t.Fatalf("restored level=%d want=5", got) + } +} + +func TestMaxCostumeDuplicatePersistsExchangeAndDisplayUpgrade(t *testing.T) { + path := filepath.Join(t.TempDir(), "collection.json") + store, err := OpenCollectionStore(path, nil) + if err != nil { + t.Fatal(err) + } + store.data.Characters = []Character{{InvenIndex: 920000001, ID: 6490, Level: 1, UseCostume: 930000001, ConnectPotentialCostume: 64901}} + store.data.Costumes = []Costume{{InvenIndex: 930000001, ID: 64901, Level: 5, UseChar: 920000001}} + if err := store.commit(store.data); err != nil { + t.Fatal(err) + } + catalog, err := gamedata.NewRegularGachaCatalog( + map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}}, + map[uint64]gamedata.CharacterDesign{64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}}, + ) + if err != nil { + t.Fatal(err) + } + grant, err := store.GrantRegular("draw", []uint64{64901}, catalog) + if err != nil { + t.Fatal(err) + } + if len(grant.Exchanges) != 1 || len(grant.Upgrades) != 1 { + t.Fatalf("grant=%+v", grant) + } + if got := grant.Upgrades[0]; got.InvenIndex != 930000001 || got.CostumeID != 64901 || got.Before != 5 || got.After != 5 || got.SortID != 0 { + t.Fatalf("display upgrade=%+v", got) + } + if got := store.Costumes()[0].Level; got != 5 { + t.Fatalf("costume level=%d want=5", got) + } + restored, err := OpenCollectionStore(path, nil) + if err != nil { + t.Fatal(err) + } + persisted, ok := restored.Grant("draw") + if !ok || len(persisted.Exchanges) != 1 || len(persisted.Upgrades) != 1 || restored.Costumes()[0].Level != 5 { + t.Fatalf("persisted=%+v costumes=%+v", persisted, restored.Costumes()) + } +} + +func TestGrantDifferentCostumesForSameCharacterReusesCharacter(t *testing.T) { + store, err := OpenCollectionStore(filepath.Join(t.TempDir(), "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + catalog, err := gamedata.NewRegularGachaCatalog( + map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}}, + map[uint64]gamedata.CharacterDesign{ + 64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5}, + 64902: {ID: 6490, HP: 166, CostumeMaxLevel: 5}, + }, + ) + if err != nil { + t.Fatal(err) + } + first, err := store.GrantRegular("first", []uint64{64901}, catalog) + if err != nil { + t.Fatal(err) + } + second, err := store.GrantRegular("second", []uint64{64902}, catalog) + if err != nil { + t.Fatal(err) + } + if len(first.CharacterIndices) != 1 || len(second.CharacterIndices) != 0 || len(store.Characters()) != 1 { + t.Fatalf("first=%+v second=%+v characters=%+v", first, second, store.Characters()) + } + costumes := store.Costumes() + if len(costumes) != 2 || costumes[0].UseChar != costumes[1].UseChar || costumes[0].UseChar != store.Characters()[0].InvenIndex { + t.Fatalf("costumes=%+v characters=%+v", costumes, store.Characters()) + } + if store.Characters()[0].CostumeID != 64901 { + t.Fatalf("initial character costume_id=%d", store.Characters()[0].CostumeID) + } +} + +func TestGrantCostumeForBaseCharacterDoesNotCreateCharacter(t *testing.T) { + store, err := OpenCollectionStore(filepath.Join(t.TempDir(), "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + base := []Character{{InvenIndex: 535604118, ID: 6490, Level: 1, UseCostume: 635604118}} + if err := store.AttachBaseCharacters(base); err != nil { + t.Fatal(err) + } + catalog, err := gamedata.NewRegularGachaCatalog( + map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64902, Weight: 1}}}}, + map[uint64]gamedata.CharacterDesign{64902: {ID: 6490, HP: 166, CostumeMaxLevel: 5}}, + ) + if err != nil { + t.Fatal(err) + } + grant, err := store.GrantRegular("base-costume", []uint64{64902}, catalog) + if err != nil { + t.Fatal(err) + } + if len(grant.CharacterIndices) != 0 || len(store.Characters()) != 0 || len(store.Costumes()) != 1 || store.Costumes()[0].UseChar != base[0].InvenIndex { + t.Fatalf("grant=%+v characters=%+v costumes=%+v", grant, store.Characters(), store.Costumes()) + } +} + +func TestAttachBaseCharactersRepairsDuplicatesAndIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "collection.json") + store, err := OpenCollectionStore(path, nil) + if err != nil { + t.Fatal(err) + } + store.data.Characters = []Character{ + {InvenIndex: 920000001, ID: 6490, Level: 1, UseCostume: 930000001}, + {InvenIndex: 920000002, ID: 6490, Level: 1, UseCostume: 930000002}, + {InvenIndex: 920000003, ID: 50, Level: 1, UseCostume: 930000003}, + {InvenIndex: 920000004, ID: 50, Level: 1, UseCostume: 930000004}, + } + store.data.Costumes = []Costume{ + {InvenIndex: 930000001, ID: 64902, Level: 2, UseChar: 920000001}, + {InvenIndex: 930000002, ID: 64903, Level: 4, UseChar: 920000002}, + {InvenIndex: 930000003, ID: 501, Level: 1, UseChar: 920000003}, + {InvenIndex: 930000004, ID: 502, Level: 3, UseChar: 920000004}, + } + store.data.Grants["old"] = CollectionGrant{CharacterIndices: []uint64{920000001, 920000002, 920000003, 920000004}, CostumeIndices: []uint64{930000001, 930000002, 930000003, 930000004}} + if err := store.commit(store.data); err != nil { + t.Fatal(err) + } + base := []Character{{InvenIndex: 535604118, ID: 6490, Level: 1}} + if err := store.AttachBaseCharacters(base); err != nil { + t.Fatal(err) + } + if err := store.AttachBaseCharacters(base); err != nil { + t.Fatal(err) + } + characters := store.Characters() + if len(characters) != 1 || characters[0].InvenIndex != 920000003 || characters[0].ID != 50 { + t.Fatalf("characters=%+v", characters) + } + costumes := store.Costumes() + wantUse := []uint64{535604118, 535604118, 920000003, 920000003} + for i := range costumes { + if costumes[i].UseChar != wantUse[i] { + t.Fatalf("costume[%d]=%+v want use_char=%d", i, costumes[i], wantUse[i]) + } + } + grant, ok := store.Grant("old") + if !ok || len(grant.CharacterIndices) != 1 || grant.CharacterIndices[0] != 920000003 || len(grant.CostumeIndices) != 4 { + t.Fatalf("grant=%+v", grant) + } + if costumes[1].Level != 4 || costumes[3].Level != 3 { + t.Fatalf("costume levels changed: %+v", costumes) + } +} diff --git a/go/internal/player/equipment.go b/go/internal/player/equipment.go new file mode 100644 index 0000000..856b960 --- /dev/null +++ b/go/internal/player/equipment.go @@ -0,0 +1,302 @@ +package player + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "bd2server/internal/wire" +) + +// Equipment is one server-owned equipment instance. The immutable definition +// (name, icon, slot and base stats) remains in the client's local GameData. +type Equipment struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level"` + UseChar uint64 `json:"use_char,omitempty"` + KeepFlag uint64 `json:"keep_flag,omitempty"` + LockFlag uint64 `json:"lock_flag,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + MainOption []EquipmentOption `json:"main_option,omitempty"` + SubOption []EquipmentOption `json:"sub_option,omitempty"` + PrivateOption *EquipmentOption `json:"private_option,omitempty"` + Rank []uint64 `json:"rank,omitempty"` +} + +// EquipmentOption is the exact EquipmentOptionTable composite key used by an +// equipment instance. The client resolves the displayed stat curve locally. +type EquipmentOption struct { + GroupID uint64 `json:"group_id"` + ID uint64 `json:"id"` +} + +type equipmentSnapshot struct { + Version string `json:"version"` + NextIndex uint64 `json:"next_index"` + Equipment []Equipment `json:"equipment"` + Granted map[string]uint64 `json:"granted"` +} + +// EquipmentInventory persists non-stackable equipment independently from +// ItemDBInfo inventory because the wire protocols are different types. +type EquipmentInventory struct { + mu sync.Mutex + path string + owned equipmentSnapshot + characters *CharacterStore +} + +func (s *EquipmentInventory) AttachCharacters(characters *CharacterStore) error { + if characters == nil { + return errors.New("player: nil character store") + } + s.mu.Lock() + defer s.mu.Unlock() + s.characters = characters + return nil +} + +func OpenEquipmentInventory(path string) (*EquipmentInventory, error) { + s := &EquipmentInventory{path: filepath.Clean(path), owned: equipmentSnapshot{ + Version: "2.34.13", NextIndex: 910000001, Granted: map[string]uint64{}, + }} + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &s.owned); err != nil { + return nil, fmt.Errorf("player: decode equipment: %w", err) + } + if s.owned.Version != "2.34.13" || s.owned.NextIndex < 910000001 || s.owned.Granted == nil { + return nil, errors.New("player: invalid saved equipment") + } + return s, nil +} + +// GrantOnce returns the same instance on a retry, allowing QuestClear response +// retries without duplicating ownership. +func (s *EquipmentInventory) GrantOnce(identity string, equipmentID uint64) (Equipment, error) { + if identity == "" || equipmentID == 0 { + return Equipment{}, errors.New("player: invalid equipment grant") + } + s.mu.Lock() + defer s.mu.Unlock() + if index := s.owned.Granted[identity]; index != 0 { + for _, current := range s.owned.Equipment { + if current.InvenIndex == index { + return current, nil + } + } + return Equipment{}, errors.New("player: equipment grant index is missing") + } + return s.grantLocked(identity, Equipment{ID: equipmentID}) +} + +// GrantGeneratedOnce saves an independently generated gacha instance. Retry +// calls return the original rolls rather than creating a second copy. +func (s *EquipmentInventory) GrantGeneratedOnce(identity string, entry Equipment) (Equipment, error) { + if identity == "" || entry.ID == 0 { + return Equipment{}, errors.New("player: invalid generated equipment") + } + s.mu.Lock() + defer s.mu.Unlock() + return s.grantLocked(identity, entry) +} + +func (s *EquipmentInventory) grantLocked(identity string, entry Equipment) (Equipment, error) { + if index := s.owned.Granted[identity]; index != 0 { + for _, current := range s.owned.Equipment { + if current.InvenIndex == index { + return current, nil + } + } + return Equipment{}, errors.New("player: equipment grant index is missing") + } + entry.InvenIndex = s.owned.NextIndex + next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex + 1, + Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted)+1)} + next.Equipment = append(next.Equipment, entry) + for k, v := range s.owned.Granted { + next.Granted[k] = v + } + next.Granted[identity] = entry.InvenIndex + data, err := json.Marshal(next) + if err != nil { + return Equipment{}, err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return Equipment{}, err + } + f, err := os.CreateTemp(dir, ".equipment-*.tmp") + if err != nil { + return Equipment{}, err + } + defer os.Remove(f.Name()) + if _, err = f.Write(data); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(f.Name(), s.path) + } + if err != nil { + return Equipment{}, fmt.Errorf("player: persist equipment: %w", err) + } + s.owned = next + return entry, nil +} + +func EquipmentWire(entry Equipment) []byte { + base := wire.AppendVarint(nil, 1, entry.ID) + if entry.Level != 0 { + base = wire.AppendVarint(base, 2, entry.Level) + } + for _, option := range entry.MainOption { + base = wire.AppendBytes(base, 3, equipmentOptionWire(option)) + } + for _, option := range entry.SubOption { + base = wire.AppendBytes(base, 4, equipmentOptionWire(option)) + } + if entry.PrivateOption != nil { + base = wire.AppendBytes(base, 5, equipmentOptionWire(*entry.PrivateOption)) + } + for _, rank := range entry.Rank { + if rank != 0 { + base = wire.AppendVarint(base, 6, rank) + } + } + var out []byte + out = wire.AppendVarint(out, 1, entry.InvenIndex) + if entry.UseChar != 0 { + out = wire.AppendVarint(out, 2, entry.UseChar) + } + if entry.KeepFlag != 0 { + out = wire.AppendVarint(out, 3, entry.KeepFlag) + } + if entry.LockFlag != 0 { + out = wire.AppendVarint(out, 4, entry.LockFlag) + } + out = wire.AppendBytes(out, 5, base) + if entry.SortID != 0 { + out = wire.AppendVarint(out, 7, entry.SortID) + } + return out +} + +func equipmentOptionWire(option EquipmentOption) []byte { + out := wire.AppendVarint(nil, 1, option.GroupID) + return wire.AppendVarint(out, 2, option.ID) +} + +func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/EquipInfo" && path != "/EquipUse" { + return 0, nil, false, nil + } + if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 { + return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path) + } + if path == "/EquipUse" { + return s.use(request) + } + s.mu.Lock() + defer s.mu.Unlock() + var response []byte + for _, entry := range s.owned.Equipment { + response = wire.AppendBytes(response, 1, EquipmentWire(entry)) + } + return 34, response, true, nil +} + +func (s *EquipmentInventory) All() []Equipment { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Equipment(nil), s.owned.Equipment...) +} + +// Granted returns a prior idempotent grant without creating an item. +func (s *EquipmentInventory) Granted(identity string) (Equipment, bool) { + s.mu.Lock() + defer s.mu.Unlock() + index := s.owned.Granted[identity] + if index == 0 { + return Equipment{}, false + } + for _, entry := range s.owned.Equipment { + if entry.InvenIndex == index { + return entry, true + } + } + return Equipment{}, false +} + +func (s *EquipmentInventory) use(request []byte) (int, []byte, bool, error) { + equipmentIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || equipmentIndex == 0 { + return 0, nil, true, errors.New("player: EquipUse missing equipment") + } + characterIndex, found, err := wire.Varint(request, 3) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: EquipUse missing character") + } + if s.characters == nil { + return 0, nil, true, errors.New("player: EquipUse character store unavailable") + } + // Find computes account buffs and may query EquipmentInventory.All(). Do + // not hold the equipment mutex while resolving the character's stats. + character, found := s.characters.Find(characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: EquipUse unknown character %d", characterIndex) + } + s.mu.Lock() + defer s.mu.Unlock() + next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex, + Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted))} + for k, v := range s.owned.Granted { + next.Granted[k] = v + } + position := -1 + for i, current := range next.Equipment { + if current.InvenIndex == equipmentIndex { + position = i + break + } + } + if position < 0 { + return 0, nil, true, fmt.Errorf("player: EquipUse unknown equipment %d", equipmentIndex) + } + next.Equipment[position].UseChar = characterIndex + data, err := json.Marshal(next) + if err != nil { + return 0, nil, true, err + } + dir := filepath.Dir(s.path) + f, err := os.CreateTemp(dir, ".equipment-*.tmp") + if err != nil { + return 0, nil, true, err + } + defer os.Remove(f.Name()) + if _, err = f.Write(data); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(f.Name(), s.path) + } + if err != nil { + return 0, nil, true, fmt.Errorf("player: persist equipment use: %w", err) + } + s.owned = next + return 35, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil +} diff --git a/go/internal/player/equipment_test.go b/go/internal/player/equipment_test.go new file mode 100644 index 0000000..76a0a2d --- /dev/null +++ b/go/internal/player/equipment_test.go @@ -0,0 +1,103 @@ +package player + +import ( + "path/filepath" + "testing" + + "bd2server/internal/wire" +) + +func TestEquipmentGrantPersistsAndIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "equipment.json") + store, err := OpenEquipmentInventory(path) + if err != nil { + t.Fatal(err) + } + first, err := store.GrantOnce("pack21:quest28:equip10010", 10010) + if err != nil { + t.Fatal(err) + } + again, err := store.GrantOnce("pack21:quest28:equip10010", 10010) + if err != nil { + t.Fatal(err) + } + if first.InvenIndex != again.InvenIndex || first.ID != again.ID || first.InvenIndex == 0 || first.ID != 10010 { + t.Fatalf("grants: %+v %+v", first, again) + } + restored, err := OpenEquipmentInventory(path) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 1) + code, response, ok, err := restored.Handle("/EquipInfo", request) + if err != nil || !ok || code != 34 { + t.Fatalf("handle: %d %v %v", code, ok, err) + } + encoded, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("equipment missing: %v", err) + } + index, _, _ := wire.Varint(encoded, 1) + base, _, _ := wire.Bytes(encoded, 5) + id, _, _ := wire.Varint(base, 1) + if index != first.InvenIndex || id != 10010 { + t.Fatalf("wire index=%d id=%d", index, id) + } +} + +func TestEquipmentUsePersistsCharacterBinding(t *testing.T) { + dir := t.TempDir() + starter := &Starter{Version: "2.34.13"} + items, err := OpenInventory(filepath.Join(dir, "items.json"), starter) + if err != nil { + t.Fatal(err) + } + characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), + []Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "") + if err != nil { + t.Fatal(err) + } + equipment, err := OpenEquipmentInventory(filepath.Join(dir, "equipment.json")) + if err != nil { + t.Fatal(err) + } + if err := equipment.AttachCharacters(characters); err != nil { + t.Fatal(err) + } + entry, err := equipment.GrantOnce("quest28", 10010) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, entry.InvenIndex) + request = wire.AppendVarint(request, 3, 535607162) + code, response, ok, err := equipment.Handle("/EquipUse", request) + if err != nil || !ok || code != 35 { + t.Fatalf("use: code=%d ok=%v err=%v", code, ok, err) + } + character, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("response character: %v", err) + } + index, _, _ := wire.Varint(character, 1) + if index != 535607162 { + t.Fatalf("character index=%d", index) + } + restored, err := OpenEquipmentInventory(filepath.Join(dir, "equipment.json")) + if err != nil { + t.Fatal(err) + } + infoRequest := wire.AppendVarint(nil, 1, 2) + _, info, _, err := restored.Handle("/EquipInfo", infoRequest) + if err != nil { + t.Fatal(err) + } + encoded, found, err := wire.Bytes(info, 1) + if err != nil || !found { + t.Fatalf("restored equipment: %v", err) + } + useChar, found, err := wire.Varint(encoded, 2) + if err != nil || !found || useChar != 535607162 { + t.Fatalf("useChar=%d found=%v err=%v", useChar, found, err) + } +} diff --git a/go/internal/player/gacha_state.go b/go/internal/player/gacha_state.go new file mode 100644 index 0000000..108b8a2 --- /dev/null +++ b/go/internal/player/gacha_state.go @@ -0,0 +1,248 @@ +package player + +import ( + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" + + "bd2server/internal/gamedata" +) + +func gachaFixedKey(fixedID, fixedType uint64) string { + return fmt.Sprintf("%d:%d", fixedID, fixedType) +} + +func applyGachaPurchase(next *collectionSnapshot, identity string, costumeIDs []uint64, purchase GachaPurchase, grant *CollectionGrant) { + if next.GachaApplied[identity] { + return + } + groupKey := strconv.FormatUint(purchase.Group.ID, 10) + user := next.GachaUsers[groupKey] + user.GroupID = purchase.Group.ID + point := purchase.Group.PointCount * uint64(len(costumeIDs)) + user.Point += point + switch purchase.BuyType { + case 1: // GB_NORMAL + user.TotalBuyCount += uint64(len(costumeIDs)) + case 2: // GB_CASH + if len(costumeIDs) == 1 { + user.OneCashPickCount++ + } else { + user.TenCashPickCount++ + } + } + next.GachaUsers[groupKey] = user + grant.GachaGroupID = purchase.Group.ID + grant.GachaPoint = point + grant.GachaFixed = append([]GachaFixedState(nil), purchase.Fixed...) + grant.SelectionApplySortIDs = append([]uint64(nil), purchase.SelectionApplySortIDs...) + for _, fixed := range purchase.Fixed { + stored := fixed + stored.ApplySort = -1 + next.GachaFixed[gachaFixedKey(fixed.FixedID, fixed.Type)] = stored + } + next.GachaApplied[identity] = true +} + +func (s *CollectionStore) GachaUsers() []GachaUserState { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]GachaUserState, 0, len(s.data.GachaUsers)) + for _, value := range s.data.GachaUsers { + result = append(result, value) + } + sort.Slice(result, func(i, j int) bool { return result[i].GroupID < result[j].GroupID }) + return result +} + +func (s *CollectionStore) GachaUser(groupID uint64) GachaUserState { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.GachaUsers[strconv.FormatUint(groupID, 10)] +} + +func (s *CollectionStore) GachaFixedStates() []GachaFixedState { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]GachaFixedState, 0, len(s.data.GachaFixed)) + for _, value := range s.data.GachaFixed { + value.ApplySort = -1 + result = append(result, value) + } + sort.Slice(result, func(i, j int) bool { + if result[i].FixedID != result[j].FixedID { + return result[i].FixedID < result[j].FixedID + } + return result[i].Type < result[j].Type + }) + return result +} + +func (s *CollectionStore) GachaFixedCount(fixedID, fixedType uint64) uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.GachaFixed[gachaFixedKey(fixedID, fixedType)].Count +} + +// ExchangeGachaPoint performs the authoritative point debit. The exchange is +// saved before the wallet credit; a retry can therefore safely recover a +// wallet write using the same identity without spending the points twice. +func (s *CollectionStore) ExchangeGachaPoint(identity string, groupID, count uint64) (GachaPointExchange, error) { + if identity == "" || groupID == 0 || count == 0 { + return GachaPointExchange{}, errors.New("player: invalid gacha point exchange") + } + s.mu.Lock() + defer s.mu.Unlock() + if exchange, ok := s.data.GachaPointExchange[identity]; ok { + if exchange.GroupID != groupID || exchange.Count != count { + return GachaPointExchange{}, errors.New("player: gacha point exchange identity conflict") + } + return exchange, nil + } + key := strconv.FormatUint(groupID, 10) + user, ok := s.data.GachaUsers[key] + if !ok || user.Point < count { + return GachaPointExchange{}, errors.New("player: insufficient gacha point") + } + next := cloneCollection(s.data) + user.Point -= count + if math.MaxUint64-user.ExchangeMileageCount < count { + return GachaPointExchange{}, errors.New("player: gacha point exchange count overflow") + } + user.ExchangeMileageCount += count + next.GachaUsers[key] = user + exchange := GachaPointExchange{GroupID: groupID, Count: count} + next.GachaPointExchange[identity] = exchange + if err := s.commit(next); err != nil { + return GachaPointExchange{}, err + } + return exchange, nil +} + +type legacyGachaDraw struct { + identity string + session string + seq uint64 + gachaID uint64 + grant CollectionGrant +} + +// RepairGachaProgress is a one-time migration for grants written before the +// server persisted GachaUserDBInfo and GachaFixedDBInfo. Existing results are +// replayed in request order to recover points and resettable rarity counters. +func (s *CollectionStore) RepairGachaProgress(catalog *gamedata.RegularGachaCatalog) error { + if catalog == nil { + return errors.New("player: nil gacha catalog") + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.data.GachaApplied) != 0 || len(s.data.GachaUsers) != 0 || len(s.data.GachaFixed) != 0 { + if s.data.GachaCountCorrected { + return nil + } + next := cloneCollection(s.data) + counts := map[uint64]uint64{} + for identity, grant := range next.Grants { + parts := strings.Split(identity, ":") + if len(parts) < 4 || parts[0] != "regular-gacha" { + continue + } + gachaID, err := strconv.ParseUint(parts[1], 10, 64) + if err != nil { + continue + } + group, ok := catalog.GroupForGacha(gachaID) + if ok && next.GachaApplied[identity] { + counts[group.ID] += uint64(len(grant.ViewCostumeIDs)) + } + } + for groupID, count := range counts { + key := strconv.FormatUint(groupID, 10) + user := next.GachaUsers[key] + user.TotalBuyCount = count + next.GachaUsers[key] = user + } + next.GachaCountCorrected = true + return s.commit(next) + } + var draws []legacyGachaDraw + for identity, grant := range s.data.Grants { + parts := strings.Split(identity, ":") + if len(parts) < 4 || parts[0] != "regular-gacha" { + continue + } + gachaID, err := strconv.ParseUint(parts[1], 10, 64) + if err != nil { + continue + } + if _, ok := catalog.GroupForGacha(gachaID); !ok { + continue + } + seq, err := strconv.ParseUint(parts[len(parts)-1], 10, 64) + if err != nil { + continue + } + session := "" + if len(parts) >= 6 && parts[2] == "session" && parts[4] == "seq" { + session = parts[3] + } + draws = append(draws, legacyGachaDraw{identity: identity, session: session, seq: seq, gachaID: gachaID, grant: grant}) + } + if len(draws) == 0 { + return nil + } + sort.SliceStable(draws, func(i, j int) bool { + if draws[i].session != draws[j].session { + if draws[i].session == "" { + return true + } + if draws[j].session == "" { + return false + } + return draws[i].session < draws[j].session + } + return draws[i].seq < draws[j].seq + }) + next := cloneCollection(s.data) + for _, draw := range draws { + group, _ := catalog.GroupForGacha(draw.gachaID) + key := strconv.FormatUint(group.ID, 10) + user := next.GachaUsers[key] + user.GroupID = group.ID + user.TotalBuyCount += uint64(len(draw.grant.ViewCostumeIDs)) + user.Point += group.PointCount * uint64(len(draw.grant.ViewCostumeIDs)) + next.GachaUsers[key] = user + if group.FixedID != 0 { + fourKey := gachaFixedKey(group.FixedID, 0) + fiveKey := gachaFixedKey(group.FixedID, 1) + four := next.GachaFixed[fourKey] + five := next.GachaFixed[fiveKey] + four.FixedID, four.Type, four.ApplySort = group.FixedID, 0, -1 + five.FixedID, five.Type, five.ApplySort = group.FixedID, 1, -1 + for _, costumeID := range draw.grant.ViewCostumeIDs { + grade, ok := catalog.CostumeGrade(costumeID) + if !ok { + return fmt.Errorf("player: gacha costume %d has no grade", costumeID) + } + switch grade { + case 5: + four.Count, five.Count = 0, 0 + case 4: + four.Count = 0 + five.Count++ + default: + four.Count++ + five.Count++ + } + } + next.GachaFixed[fourKey] = four + next.GachaFixed[fiveKey] = five + } + next.GachaApplied[draw.identity] = true + } + next.GachaCountCorrected = true + return s.commit(next) +} diff --git a/go/internal/player/growth.go b/go/internal/player/growth.go new file mode 100644 index 0000000..70b7e1b --- /dev/null +++ b/go/internal/player/growth.go @@ -0,0 +1,363 @@ +package player + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "bd2server/internal/gamedata" + "bd2server/internal/wire" +) + +type characterSnapshot struct { + Version string `json:"version"` + Characters []Character `json:"characters"` +} + +// CharacterStore owns mutable character growth separately from immutable +// character design data. The seed supplies the initially owned instances. +type CharacterStore struct { + mu sync.Mutex + path string + characters []Character + inventory *Inventory + gameDataRoot string + gameDataVersion string + grow func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) + collection *CollectionStore + maxHealth func(Character) (uint64, error) +} + +// AttachMaxHealth makes growth and post-battle revival consume the same +// GameData/collection calculator as AllCharRefresh and BattleEnter. +func (s *CharacterStore) AttachMaxHealth(maxHealth func(Character) (uint64, error)) error { + if maxHealth == nil { + return errors.New("player: nil maximum health calculator") + } + s.mu.Lock() + defer s.mu.Unlock() + s.maxHealth = maxHealth + return nil +} + +func (s *CharacterStore) AttachCollection(collection *CollectionStore) error { + if collection == nil { + return errors.New("player: nil collection store") + } + s.mu.Lock() + defer s.mu.Unlock() + s.collection = collection + return nil +} + +func OpenCharacterStore(path string, seed []Character, inventory *Inventory, gameDataRoot, gameDataVersion string) (*CharacterStore, error) { + if path == "" || inventory == nil { + return nil, errors.New("player: invalid character store configuration") + } + s := &CharacterStore{path: filepath.Clean(path), inventory: inventory, characters: append([]Character(nil), seed...), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion} + s.grow = func(character Character, materials []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) { + return gamedata.CharacterGrowth(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, materials) + } + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, validateCharacters(s.characters) + } + if err != nil { + return nil, err + } + var saved characterSnapshot + if err := json.Unmarshal(data, &saved); err != nil { + return nil, fmt.Errorf("player: decode characters: %w", err) + } + if saved.Version != "2.34.13" { + return nil, errors.New("player: invalid character save version") + } + if len(saved.Characters) == 0 { + s.characters = append([]Character(nil), seed...) + return s, validateCharacters(s.characters) + } + if err := validateCharacters(saved.Characters); err != nil { + return nil, err + } + s.characters = append([]Character(nil), saved.Characters...) + seen := make(map[uint64]bool, len(s.characters)) + for _, character := range s.characters { + seen[character.InvenIndex] = true + } + for _, character := range seed { + if !seen[character.InvenIndex] { + s.characters = append(s.characters, character) + seen[character.InvenIndex] = true + } + } + if err := validateCharacters(s.characters); err != nil { + return nil, err + } + return s, nil +} + +func validateCharacters(characters []Character) error { + seen := make(map[uint64]bool, len(characters)) + for _, character := range characters { + if character.InvenIndex == 0 || character.ID == 0 || character.Level == 0 || seen[character.InvenIndex] { + return errors.New("player: invalid saved character") + } + seen[character.InvenIndex] = true + } + return nil +} + +func (s *CharacterStore) All() []Character { + characters := s.RawAll() + s.mu.Lock() + maxHealth := s.maxHealth + s.mu.Unlock() + if maxHealth != nil { + for i := range characters { + if hp, err := maxHealth(characters[i]); err == nil { + characters[i].HP = hp + } + } + } + return characters +} + +// RawAll is for account-ownership queries used by the stat calculator itself. +// Calling All() from that calculator would recursively calculate its inputs. +func (s *CharacterStore) RawAll() []Character { + s.mu.Lock() + characters := append([]Character(nil), s.characters...) + collection := s.collection + s.mu.Unlock() + if collection != nil { + characters = append(characters, collection.Characters()...) + } + return characters +} + +func (s *CharacterStore) Find(inventoryIndex uint64) (Character, bool) { + s.mu.Lock() + for _, character := range s.characters { + if character.InvenIndex == inventoryIndex { + maxHealth := s.maxHealth + s.mu.Unlock() + if maxHealth != nil { + if hp, err := maxHealth(character); err == nil { + character.HP = hp + } + } + return character, true + } + } + collection := s.collection + s.mu.Unlock() + if collection != nil { + character, found := collection.FindCharacter(inventoryIndex) + if found && s.maxHealth != nil { + if hp, err := s.maxHealth(character); err == nil { + character.HP = hp + } + } + return character, found + } + return Character{}, false +} + +func (s *CharacterStore) persist(next []Character) error { + data, err := json.Marshal(characterSnapshot{Version: "2.34.13", Characters: next}) + if err != nil { + return err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + f, err := os.CreateTemp(dir, ".characters-*.tmp") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(data); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(f.Name(), s.path) + } + return err +} + +func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool, error) { + if path == "/CharImmortal" { + return s.charImmortal(request) + } + if path != "/CharGrowth" { + return 0, nil, false, nil + } + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: CharGrowth missing character") + } + var materials []Item + if err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 3 { + return nil + } + if field.Type != 2 { + return errors.New("player: invalid growth material") + } + var item Item + if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { + return err + } + if item.InvenIndex == 0 || item.ID == 0 || item.Type == 0 || item.Count == 0 { + return errors.New("player: incomplete growth material") + } + materials = append(materials, item) + return nil + }); err != nil { + return 0, nil, true, err + } + if len(materials) == 0 { + return 0, nil, true, errors.New("player: CharGrowth has no materials") + } + + s.mu.Lock() + defer s.mu.Unlock() + position := -1 + for i, current := range s.characters { + if current.InvenIndex == index { + position = i + break + } + } + var current Character + fromCollection := false + if position >= 0 { + current = s.characters[position] + } else if s.collection != nil { + current, fromCollection = s.collection.FindCharacter(index) + } + if position < 0 && !fromCollection { + return 0, nil, true, fmt.Errorf("player: unknown character inventory index %d", index) + } + growthMaterials := make([]gamedata.GrowthMaterial, len(materials)) + for i, material := range materials { + if material.Type != 8 { + return 0, nil, true, fmt.Errorf("player: unsupported growth material type %d", material.Type) + } + growthMaterials[i] = gamedata.GrowthMaterial{ID: material.ID, Count: material.Count} + } + newLevel, newExp, refunds, err := s.grow(current, growthMaterials) + if err != nil { + return 0, nil, true, fmt.Errorf("player: calculate character growth: %w", err) + } + current.Level = newLevel + current.Exp = newExp + if s.maxHealth != nil { + maxHealth := s.maxHealth + s.mu.Unlock() + hp, healthErr := maxHealth(current) + s.mu.Lock() + current.HP, err = hp, healthErr + if err != nil { + return 0, nil, true, fmt.Errorf("player: calculate grown character maximum health: %w", err) + } + } + returned, err := s.inventory.ConsumeAndRefund(materials, refunds) + if err != nil { + return 0, nil, true, fmt.Errorf("player: consume growth material: %w", err) + } + if fromCollection { + if err := s.collection.UpdateCharacter(current); err != nil { + return 0, nil, true, fmt.Errorf("player: persist collection character growth: %w", err) + } + } else { + next := append([]Character(nil), s.characters...) + next[position] = current + if err := s.persist(next); err != nil { + return 0, nil, true, fmt.Errorf("player: persist character growth: %w", err) + } + s.characters = next + } + response := wire.AppendBytes(nil, 1, CharacterWire(current)) + var bundle []byte + for _, item := range returned { + bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) + } + response = wire.AppendBytes(response, 2, bundle) + return 433, response, true, nil +} + +// charImmortal completes the automatic post-battle revival for characters +// whose TalentSkillTable.ClassType is 14. The story character 6010 has +// ValueList[0]=10000 at every talent level (100%). The authoritative maximum +// HP is recomputed by Find from the same calculator as character growth. +func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error) { + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, errors.New("player: CharImmortal missing sequence") + } + var indices []uint64 + err = wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + if field.Type != 0 && field.Type != 2 { + return errors.New("player: CharImmortal invalid inventory index field") + } + for data := field.Value; len(data) != 0; { + index, count := binary.Uvarint(data) + if count <= 0 || index == 0 { + return errors.New("player: CharImmortal invalid inventory index") + } + indices = append(indices, index) + data = data[count:] + } + return nil + }) + if err != nil { + return 0, nil, true, err + } + if len(indices) == 0 || len(indices) > 5 { + return 0, nil, true, fmt.Errorf("player: CharImmortal invalid character count %d", len(indices)) + } + seen := make(map[uint64]bool, len(indices)) + var response []byte + for _, index := range indices { + if seen[index] { + return 0, nil, true, fmt.Errorf("player: CharImmortal duplicate character %d", index) + } + seen[index] = true + character, found := s.Find(index) + if !found { + return 0, nil, true, fmt.Errorf("player: CharImmortal unknown character %d", index) + } + response = wire.AppendBytes(response, 1, CharacterWire(character)) + } + return 96, response, true, nil +} + +func CharacterWire(c Character) []byte { + fields := []struct { + n int + v uint64 + }{{1, c.InvenIndex}, {2, c.ID}, {3, c.HP}, {4, c.Level}, {5, c.CostumeID}, {6, c.Exp}, {7, c.UseCostume}, {8, c.TalentLevel}, {9, c.TalentExp}, {10, c.SolidarityReward}, {11, c.ExpiryTime}, {13, c.ConnectPotentialCostume}} + var out []byte + for _, field := range fields { + if field.v != 0 { + out = wire.AppendVarint(out, field.n, field.v) + } + } + for _, p := range c.Pictorialbook { + book := wire.AppendVarint(wire.AppendVarint(nil, 1, p.ID), 2, p.GroupID) + out = wire.AppendBytes(out, 12, book) + } + return out +} diff --git a/go/internal/player/growth_test.go b/go/internal/player/growth_test.go new file mode 100644 index 0000000..9c5c724 --- /dev/null +++ b/go/internal/player/growth_test.go @@ -0,0 +1,208 @@ +package player + +import ( + "bytes" + "encoding/binary" + "os" + "path/filepath" + "testing" + + "bd2server/internal/gamedata" + "bd2server/internal/wire" +) + +func TestCharImmortalReturnsFullOwnedSnapshot(t *testing.T) { + dir := t.TempDir() + starter := &Starter{Version: "2.34.13"} + inventory, err := OpenInventory(filepath.Join(dir, "items.json"), starter) + if err != nil { + t.Fatal(err) + } + owned := []Character{ + {InvenIndex: 535604120, ID: 6010, HP: 7, Level: 1, TalentLevel: 1}, + {InvenIndex: 535607162, ID: 350, HP: 512, Level: 20, TalentLevel: 1}, + } + characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), owned, inventory, "", "") + if err != nil { + t.Fatal(err) + } + packed := binary.AppendUvarint(nil, owned[0].InvenIndex) + request := wire.AppendBytes(wire.AppendVarint(nil, 1, 23), 2, packed) + code, response, ok, err := characters.Handle("/CharImmortal", request) + if err != nil || !ok || code != 96 { + t.Fatalf("immortal: code=%d handled=%v err=%v", code, ok, err) + } + encoded, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("missing revived character: %v", err) + } + index, _, _ := wire.Varint(encoded, 1) + hp, _, _ := wire.Varint(encoded, 3) + if index != owned[0].InvenIndex || hp != 7 { + t.Fatalf("revived character index=%d hp=%d", index, hp) + } + for _, invalid := range [][]byte{ + wire.AppendVarint(wire.AppendVarint(nil, 1, 24), 2, 999), + wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 25), 2, owned[0].InvenIndex), 2, owned[0].InvenIndex), + wire.AppendVarint(nil, 1, 26), + } { + if _, _, handled, err := characters.Handle("/CharImmortal", invalid); err == nil || !handled { + t.Fatalf("invalid immortal request accepted: handled=%v err=%v", handled, err) + } + } +} + +func TestCharacterGrowthConsumesMaterialAndPersists(t *testing.T) { + dir := t.TempDir() + starter := &Starter{Version: "2.34.13"} + inventory, err := OpenInventory(filepath.Join(dir, "items.json"), starter) + if err != nil { + t.Fatal(err) + } + items, err := inventory.GrantOnce("battle", []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}) + if err != nil { + t.Fatal(err) + } + characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 77, ID: 350, Level: 1}}, inventory, "", "") + if err != nil { + t.Fatal(err) + } + characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) { + return 20, 0, []gamedata.GrowthMaterial{{ID: 7, Count: 6}}, nil + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, 77) + material := ItemWire(items[0]) + request = wire.AppendBytes(request, 3, material) + code, response, ok, err := characters.Handle("/CharGrowth", request) + if err != nil || !ok || code != 433 { + t.Fatalf("growth: code=%d ok=%v err=%v", code, ok, err) + } + encoded, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("character response: %v", err) + } + level, found, err := wire.Varint(encoded, 4) + if err != nil || !found || level != 20 { + t.Fatalf("level=%d found=%v err=%v", level, found, err) + } + restored, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), nil, inventory, "", "") + if err != nil { + t.Fatal(err) + } + if got := restored.All(); len(got) != 1 || got[0].Level != 20 { + t.Fatalf("restored=%+v", got) + } + _, itemResponse, _, err := inventory.Handle("/ItemInfo", wire.AppendVarint(nil, 1, 2)) + if err != nil { + t.Fatal(err) + } + owned, found, err := wire.Bytes(itemResponse, 1) + if err != nil || !found { + t.Fatalf("refund absent: found=%v err=%v", found, err) + } + id, _, _ := wire.Varint(owned, 2) + count, _, _ := wire.Varint(owned, 4) + if id != 7 || count != 6 { + t.Fatalf("wrong refund id=%d count=%d", id, count) + } + bundle, found, err := wire.Bytes(response, 2) + if err != nil || !found { + t.Fatalf("missing growth reward bundle: %v", err) + } + reward, found, err := wire.Bytes(bundle, 1) + if err != nil || !found { + t.Fatalf("missing reward item: %v", err) + } + rewardID, _, _ := wire.Varint(reward, 2) + if rewardID != 7 { + t.Fatalf("reward id=%d", rewardID) + } + if err := inventory.Consume(items); err == nil { + t.Fatal("already spent growth material accepted again") + } +} + +func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) { + dir := t.TempDir() + inventory, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"}) + if err != nil { + t.Fatal(err) + } + items, err := inventory.GrantOnce("growth", []gamedata.BattleReward{{Type: 8, ID: 8, Count: 2}}) + if err != nil { + t.Fatal(err) + } + characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 77, ID: 350, HP: 122, Level: 1}}, inventory, "", "") + if err != nil { + t.Fatal(err) + } + characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) { + return 20, 0, nil, nil + } + if err := characters.AttachMaxHealth(func(character Character) (uint64, error) { + // The callback must be safe to query account ownership while growth + // changes the same CharacterStore. + if len(characters.RawAll()) != 1 { + t.Fatal("owned character disappeared during health calculation") + } + if character.Level == 20 { + return 513, nil + } + return 122, nil + }); err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 11), 2, 77) + request = wire.AppendBytes(request, 3, ItemWire(items[0])) + _, response, _, err := characters.Handle("/CharGrowth", request) + if err != nil { + t.Fatal(err) + } + grown, _, err := wire.Bytes(response, 1) + if err != nil { + t.Fatal(err) + } + if hp, _, _ := wire.Varint(grown, 3); hp != 513 { + t.Fatalf("grown HP=%d want 513", hp) + } + immortal := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77) + _, response, _, err = characters.Handle("/CharImmortal", immortal) + if err != nil { + t.Fatal(err) + } + revived, _, err := wire.Bytes(response, 1) + if err != nil { + t.Fatal(err) + } + if hp, _, _ := wire.Varint(revived, 3); hp != 513 { + t.Fatalf("revived HP=%d want 513", hp) + } + data, err := os.ReadFile(filepath.Join(dir, "characters.json")) + if err != nil || !bytes.Contains(data, []byte(`"hp":513`)) { + t.Fatalf("growth HP was not persisted: %s err=%v", data, err) + } +} + +func TestCharacterStoreMergesNewSeedCharacters(t *testing.T) { + dir := t.TempDir() + starter := &Starter{Version: "2.34.13"} + inventory, err := OpenInventory(filepath.Join(dir, "items.json"), starter) + if err != nil { + t.Fatal(err) + } + first, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 1, ID: 10, Level: 1}}, inventory, "", "") + if err != nil { + t.Fatal(err) + } + if err := first.persist(first.All()); err != nil { + t.Fatal(err) + } + restored, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 1, ID: 10, Level: 1}, {InvenIndex: 2, ID: 20, Level: 15}}, inventory, "", "") + if err != nil { + t.Fatal(err) + } + if got := restored.All(); len(got) != 2 || got[1].InvenIndex != 2 { + t.Fatalf("merged=%+v", got) + } +} diff --git a/go/internal/player/owned.go b/go/internal/player/owned.go new file mode 100644 index 0000000..dfcc93a --- /dev/null +++ b/go/internal/player/owned.go @@ -0,0 +1,259 @@ +package player + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "bd2server/internal/gamedata" + "bd2server/internal/wire" +) + +// Inventory holds player-owned rewards separately from the immutable starter +// seed. GrantOnce uses a battle identity to prevent double-credit on retries. +type Inventory struct { + mu sync.Mutex + path string + starter *Starter + owned ownedSnapshot +} + +type ownedSnapshot struct { + Version string `json:"version"` + NextIndex uint64 `json:"next_index"` + Items []Item `json:"items"` + Granted map[string]bool `json:"granted"` + GrantItems map[string][]uint64 `json:"grant_items,omitempty"` +} + +func OpenInventory(path string, starter *Starter) (*Inventory, error) { + if starter == nil || starter.Validate() != nil || path == "" { + return nil, errors.New("player: invalid inventory configuration") + } + s := &Inventory{path: filepath.Clean(path), starter: starter, owned: ownedSnapshot{Version: "2.34.13", NextIndex: 900000001, Granted: map[string]bool{}, GrantItems: map[string][]uint64{}}} + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &s.owned); err != nil { + return nil, fmt.Errorf("player: decode inventory: %w", err) + } + if s.owned.Version != "2.34.13" || s.owned.NextIndex < 900000001 || s.owned.Granted == nil { + return nil, errors.New("player: invalid saved inventory") + } + if s.owned.GrantItems == nil { + s.owned.GrantItems = map[string][]uint64{} + } + return s, nil +} + +func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/ItemInfo" { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items)) + items = append(items, s.starter.Items...) + items = append(items, s.owned.Items...) + return (&Starter{Version: "2.34.13", Items: items}).Handle(path, request) +} + +func (s *Inventory) All() []Item { + s.mu.Lock() + defer s.mu.Unlock() + items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items)) + items = append(items, s.starter.Items...) + items = append(items, s.owned.Items...) + return items +} + +func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) ([]Item, error) { + if identity == "" { + return nil, errors.New("player: missing reward identity") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.owned.Granted[identity] { + return nil, nil + } + next := ownedSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex, + Items: append([]Item(nil), s.owned.Items...), Granted: make(map[string]bool, len(s.owned.Granted)+1), GrantItems: make(map[string][]uint64, len(s.owned.GrantItems)+1)} + for k, v := range s.owned.Granted { + next.Granted[k] = v + } + for k, v := range s.owned.GrantItems { + next.GrantItems[k] = append([]uint64(nil), v...) + } + newItems := make([]Item, 0, len(rewards)) + for _, r := range rewards { + if r.ID == 0 || r.Type == 0 || r.Count == 0 { + return nil, errors.New("player: invalid battle reward") + } + item := Item{InvenIndex: next.NextIndex, ID: r.ID, Type: r.Type, Count: r.Count, TimeValue: uint64(time.Now().UnixMilli())} + next.NextIndex++ + next.Items = append(next.Items, item) + newItems = append(newItems, item) + next.GrantItems[identity] = append(next.GrantItems[identity], item.InvenIndex) + } + next.Granted[identity] = true + data, err := json.Marshal(next) + if err != nil { + return nil, err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + f, err := os.CreateTemp(dir, ".inventory-*.tmp") + if err != nil { + return nil, err + } + defer os.Remove(f.Name()) + if _, err = f.Write(data); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err != nil { + return nil, err + } + if err := os.Rename(f.Name(), s.path); err != nil { + return nil, err + } + s.owned = next + return newItems, nil +} + +// GrantedItems returns the stable instances created by a previous GrantOnce. +// Legacy grants made before instance tracking return an empty slice. +func (s *Inventory) GrantedItems(identity string) []Item { + s.mu.Lock() + defer s.mu.Unlock() + indexes := s.owned.GrantItems[identity] + result := make([]Item, 0, len(indexes)) + for _, index := range indexes { + for _, item := range s.owned.Items { + if item.InvenIndex == index { + result = append(result, item) + break + } + } + } + return result +} + +// Consume atomically removes the requested counts from mutable owned items. +// Starter seed entries are immutable and are deliberately not accepted here. +func (s *Inventory) Consume(requested []Item) error { + _, err := s.ConsumeAndRefund(requested, nil) + return err +} + +// ConsumeAndRefund changes the spent stack and returned growth resources in a +// single inventory save, keeping ItemInfo and RewardInfoBundle consistent. +func (s *Inventory) ConsumeAndRefund(requested []Item, refunds []gamedata.GrowthMaterial) ([]Item, error) { + if len(requested) == 0 { + return nil, errors.New("player: no items to consume") + } + s.mu.Lock() + defer s.mu.Unlock() + next := ownedSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex, + Items: append([]Item(nil), s.owned.Items...), Granted: make(map[string]bool, len(s.owned.Granted)), GrantItems: make(map[string][]uint64, len(s.owned.GrantItems))} + for k, v := range s.owned.Granted { + next.Granted[k] = v + } + for k, v := range s.owned.GrantItems { + next.GrantItems[k] = append([]uint64(nil), v...) + } + for _, want := range requested { + if want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 { + return nil, errors.New("player: invalid item consumption") + } + found := -1 + for i, current := range next.Items { + if current.InvenIndex == want.InvenIndex { + found = i + break + } + } + if found < 0 { + return nil, fmt.Errorf("player: item %d is not mutable-owned", want.InvenIndex) + } + current := next.Items[found] + if current.ID != want.ID || current.Type != want.Type || current.Count < want.Count { + return nil, fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex) + } + current.Count -= want.Count + if current.Count == 0 { + next.Items = append(next.Items[:found], next.Items[found+1:]...) + } else { + next.Items[found] = current + } + } + granted := make([]Item, 0, len(refunds)) + for _, refund := range refunds { + if refund.ID == 0 || refund.Count == 0 { + return nil, errors.New("player: invalid growth refund") + } + item := Item{InvenIndex: next.NextIndex, ID: refund.ID, Type: 8, Count: refund.Count, TimeValue: uint64(time.Now().UnixMilli())} + next.NextIndex++ + next.Items = append(next.Items, item) + granted = append(granted, item) + } + data, err := json.Marshal(next) + if err != nil { + return nil, err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + f, err := os.CreateTemp(dir, ".inventory-*.tmp") + if err != nil { + return nil, err + } + defer os.Remove(f.Name()) + if _, err = f.Write(data); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(f.Name(), s.path) + } + if err != nil { + return nil, err + } + s.owned = next + return granted, nil +} + +// ItemWire builds ItemDBInfo for RewardDBInfoBundle and ItemInfoResponse. +func ItemWire(item Item) []byte { + var b []byte + for _, f := range []struct { + n int + v uint64 + }{{1, item.InvenIndex}, {2, item.ID}, {3, item.Type}, {4, item.Count}, {5, item.KeepFlag}, {6, item.TimeValue}} { + if f.v != 0 { + b = wire.AppendVarint(b, f.n, f.v) + } + } + if item.SortID != 0 { + b = wire.AppendVarint(b, 9, item.SortID) + } + if item.UseCount != 0 { + b = wire.AppendVarint(b, 10, item.UseCount) + } + return b +} diff --git a/go/internal/player/owned_test.go b/go/internal/player/owned_test.go new file mode 100644 index 0000000..52ce92e --- /dev/null +++ b/go/internal/player/owned_test.go @@ -0,0 +1,43 @@ +package player + +import ( + "path/filepath" + "testing" + + "bd2server/internal/gamedata" + "bd2server/internal/wire" +) + +func TestBattleRewardPersistsWithoutDuplicate(t *testing.T) { + path := filepath.Join(t.TempDir(), "owned.json") + starter := &Starter{Version: "2.34.13"} + inv, err := OpenInventory(path, starter) + if err != nil { + t.Fatal(err) + } + reward := []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}} + items, err := inv.GrantOnce("pack21:monster1", reward) + if err != nil || len(items) != 1 || items[0].Count != 3 { + t.Fatalf("items=%+v err=%v", items, err) + } + inv, err = OpenInventory(path, starter) + if err != nil { + t.Fatal(err) + } + items, err = inv.GrantOnce("pack21:monster1", reward) + if err != nil || len(items) != 0 { + t.Fatalf("duplicate grant=%+v err=%v", items, err) + } + code, response, ok, err := inv.Handle("/ItemInfo", wire.AppendVarint(nil, 1, 77)) + if err != nil || !ok || code != 21 { + t.Fatalf("response=%d ok=%v err=%v", code, ok, err) + } + item, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatal("reward missing from inventory") + } + got, _, _ := wire.Varint(item, 2) + if got != 8 { + t.Fatalf("item id=%d", got) + } +} diff --git a/go/internal/player/starter.go b/go/internal/player/starter.go new file mode 100644 index 0000000..146ba06 --- /dev/null +++ b/go/internal/player/starter.go @@ -0,0 +1,199 @@ +// Package player owns the initial, mutable inventory/character snapshot used +// by the local account. GameData defines what each ID means; this file holds +// only the new player's ownership and progress. +package player + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + + "bd2server/internal/wire" +) + +type Item struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Type uint64 `json:"type"` + Count uint64 `json:"count"` + KeepFlag uint64 `json:"keep_flag,omitempty"` + TimeValue uint64 `json:"time_value,omitempty"` + Pictorialbook *Pictorial `json:"pictorialbook,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + UseCount uint64 `json:"use_count,omitempty"` +} + +type Costume struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level,omitempty"` + UseChar uint64 `json:"use_char,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + PotentialID uint64 `json:"potential_id,omitempty"` + DesignID uint64 `json:"design_id,omitempty"` + BurstLevel uint64 `json:"burst_level,omitempty"` + TimeValue uint64 `json:"time_value,omitempty"` + Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` +} + +type Pictorial struct { + ID uint64 `json:"id"` + GroupID uint64 `json:"group_id"` +} + +type Character struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + HP uint64 `json:"hp,omitempty"` + Level uint64 `json:"level,omitempty"` + CostumeID uint64 `json:"costume_id,omitempty"` + Exp uint64 `json:"exp,omitempty"` + UseCostume uint64 `json:"use_costume,omitempty"` + TalentLevel uint64 `json:"talent_level,omitempty"` + TalentExp uint64 `json:"talent_exp,omitempty"` + SolidarityReward uint64 `json:"solidarity_reward,omitempty"` + ExpiryTime uint64 `json:"expiry_time,omitempty"` + ConnectPotentialCostume uint64 `json:"connect_potential_costume,omitempty"` + Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` +} + +type Starter struct { + Version string `json:"version"` + Items []Item `json:"items"` + Costumes []Costume `json:"costumes"` + Characters []Character `json:"characters"` + Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` + FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` +} + +func Load(path string) (*Starter, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("player: read starter: %w", err) + } + var starter Starter + if err := json.Unmarshal(data, &starter); err != nil { + return nil, fmt.Errorf("player: decode starter: %w", err) + } + if err := starter.Validate(); err != nil { + return nil, err + } + return &starter, nil +} + +func (s *Starter) Validate() error { + if s == nil || s.Version != "2.34.13" { + return errors.New("player: wrong starter version") + } + for _, item := range s.Items { + if item.ID == 0 || item.Count == 0 { + return errors.New("player: invalid item") + } + } + for _, costume := range s.Costumes { + if costume.ID == 0 || costume.InvenIndex == 0 { + return errors.New("player: invalid costume") + } + } + for _, character := range s.Characters { + if character.ID == 0 || character.InvenIndex == 0 { + return errors.New("player: invalid character") + } + } + return nil +} + +func (s *Starter) Write(path string) error { + if err := s.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func add(dst []byte, field int, value uint64) []byte { + if value != 0 { + return wire.AppendVarint(dst, field, value) + } + return dst +} + +func (s *Starter) Handle(path string, request []byte) (int, []byte, bool, error) { + var code int + var response []byte + switch path { + case "/ItemInfo": + code = 21 + for _, entry := range s.Items { + response = wire.AppendBytes(response, 1, ItemWire(entry)) + } + case "/CostumeInfo": + code = 40 + for _, entry := range s.Costumes { + response = wire.AppendBytes(response, 1, CostumeWire(entry)) + } + case "/CharInfo": + code = 9 + for _, entry := range s.Characters { + var character []byte + character = add(character, 1, entry.InvenIndex) + character = add(character, 2, entry.ID) + character = add(character, 3, entry.HP) + character = add(character, 4, entry.Level) + character = add(character, 5, entry.CostumeID) + character = add(character, 6, entry.Exp) + character = add(character, 7, entry.UseCostume) + character = add(character, 8, entry.TalentLevel) + character = add(character, 9, entry.TalentExp) + character = add(character, 10, entry.SolidarityReward) + character = add(character, 11, entry.ExpiryTime) + character = add(character, 13, entry.ConnectPotentialCostume) + response = wire.AppendBytes(response, 1, character) + } + response = add(response, 2, s.FieldCharControlDeckType) + default: + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path) + } + return code, response, true, nil +} + +func CostumeWire(entry Costume) []byte { + var costume []byte + costume = add(costume, 1, entry.InvenIndex) + costume = add(costume, 2, entry.ID) + costume = add(costume, 3, entry.Level) + costume = add(costume, 4, entry.UseChar) + for _, p := range entry.Pictorialbook { + book := add(add(nil, 1, p.ID), 2, p.GroupID) + costume = wire.AppendBytes(costume, 5, book) + } + costume = add(costume, 6, entry.SortID) + costume = add(costume, 8, entry.PotentialID) + costume = add(costume, 9, entry.DesignID) + costume = add(costume, 10, entry.BurstLevel) + costume = add(costume, 12, entry.TimeValue) + return costume +} + +func decodeVarints(proto []byte, fields map[int]*uint64) error { + return wire.Walk(proto, func(field wire.Field) error { + value, known := fields[field.Number] + if !known { + return fmt.Errorf("player: unsupported starter field %d", field.Number) + } + if field.Type != 0 { + return fmt.Errorf("player: starter field %d wire type %d", field.Number, field.Type) + } + *value, _ = binary.Uvarint(field.Value) + return nil + }) +} diff --git a/go/internal/player/starter_test.go b/go/internal/player/starter_test.go new file mode 100644 index 0000000..ada0bad --- /dev/null +++ b/go/internal/player/starter_test.go @@ -0,0 +1,56 @@ +package player + +import ( + "bytes" + "path/filepath" + "testing" + + "bd2server/internal/fixture" + "bd2server/internal/wire" +) + +func TestStarterCanAnswerWithoutCapture(t *testing.T) { + seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "starter_player.json")) + if err != nil { + t.Fatal(err) + } + for path, code := range map[string]int{"/ItemInfo": 21, "/CostumeInfo": 40, "/CharInfo": 9} { + actual, proto, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 900001)) + if err != nil || !ok || actual != code || len(proto) == 0 { + t.Fatalf("%s response: code=%d len=%d ok=%v err=%v", path, actual, len(proto), ok, err) + } + } + if _, _, ok, err := seed.Handle("/NotImplemented", nil); ok || err != nil { + t.Fatal("accepted unknown endpoint") + } +} + +func TestStarterMatchesInitialAccountSample(t *testing.T) { + root := filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") + set, err := fixture.Load(root) + if err != nil { + t.Skipf("optional capture unavailable: %v", err) + } + key, err := set.SessionKey() + if err != nil { + t.Fatal(err) + } + batch, err := set.BatchResponse(1) + if err != nil { + t.Fatal(err) + } + _, recorded, err := fixture.DecodeBatch(batch, []byte(key)) + if err != nil { + t.Fatal(err) + } + seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "starter_player.json")) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/ItemInfo", "/CostumeInfo", "/CharInfo"} { + _, proto, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 900001)) + if err != nil || !ok || !bytes.Equal(proto, recorded[path]) { + t.Errorf("%s parsed player starter differs from source: got=%x want=%x err=%v", path, proto, recorded[path], err) + } + } +} diff --git a/go/internal/player/wallet.go b/go/internal/player/wallet.go new file mode 100644 index 0000000..ba59c75 --- /dev/null +++ b/go/internal/player/wallet.go @@ -0,0 +1,248 @@ +package player + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "sync" + + "bd2server/internal/gamedata" +) + +// Currency uses the UserDBInfo currency fields: type 3 is free jewelry and +// type 4 is gold. Paid jewelry is persisted as well, although quest rewards +// in the audited tutorial range do not grant it. +type Currency struct { + Gold uint64 `json:"gold"` + FreeJewelry uint64 `json:"free_jewelry"` + Jewelry uint64 `json:"jewelry"` + Mileage uint64 `json:"mileage,omitempty"` + HopePowder uint64 `json:"hope_powder,omitempty"` +} + +type walletSnapshot struct { + Version string `json:"version"` + Currency + Granted map[string]bool `json:"granted"` + Spent map[string]bool `json:"spent,omitempty"` +} + +type Wallet struct { + mu sync.Mutex + path string + state walletSnapshot +} + +func OpenWallet(path string, initial Currency) (*Wallet, error) { + if path == "" { + return nil, errors.New("player: wallet path is empty") + } + s := &Wallet{path: filepath.Clean(path), state: walletSnapshot{ + Version: "2.34.13", Currency: initial, Granted: map[string]bool{}, Spent: map[string]bool{}, + }} + b, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("player: read wallet: %w", err) + } + if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != "2.34.13" || s.state.Granted == nil { + return nil, errors.New("player: malformed wallet state") + } + if s.state.Spent == nil { + s.state.Spent = map[string]bool{} + } + return s, nil +} + +func (s *Wallet) Snapshot() Currency { + s.mu.Lock() + defer s.mu.Unlock() + return s.state.Currency +} + +func (s *Wallet) WasGranted(identity string) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.state.Granted[identity] +} + +func (s *Wallet) CanSpendFreeJewelry(amount uint64) bool { + s.mu.Lock() + defer s.mu.Unlock() + return amount > 0 && s.state.FreeJewelry >= amount +} + +func (s *Wallet) WasSpent(identity string) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.state.Spent[identity] +} + +func (s *Wallet) SpendFreeJewelryOnce(identity string, amount uint64) (Currency, error) { + return s.spendJewelryOnce(identity, amount, false) +} + +func (s *Wallet) SpendJewelryOnce(identity string, amount uint64) (Currency, error) { + return s.spendJewelryOnce(identity, amount, true) +} + +func (s *Wallet) spendJewelryOnce(identity string, amount uint64, paid bool) (Currency, error) { + if identity == "" || amount == 0 { + return Currency{}, errors.New("player: invalid wallet spend") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.state.Spent[identity] { + return s.state.Currency, nil + } + next := cloneWallet(s.state) + if paid { + if next.Jewelry < amount { + return Currency{}, errors.New("player: insufficient paid jewelry") + } + next.Jewelry -= amount + } else { + if next.FreeJewelry < amount { + return Currency{}, errors.New("player: insufficient free jewelry") + } + next.FreeJewelry -= amount + } + next.Spent[identity] = true + if err := s.commit(next); err != nil { + return Currency{}, err + } + return next.Currency, nil +} + +// Currencies implements account.CurrencyProvider. +func (s *Wallet) Currencies() (gold, freeJewelry, jewelry, mileage uint64) { + c := s.Snapshot() + return c.Gold, c.FreeJewelry, c.Jewelry, c.Mileage +} + +func (s *Wallet) HopePowderBalance() uint64 { return s.Snapshot().HopePowder } + +// GrantMileageOnce persists the type-20 currency produced when a duplicate +// costume is drawn after +5. The gacha grant identity makes recovery after a +// collection/wallet split commit safe and idempotent. +func (s *Wallet) GrantMileageOnce(identity string, amount uint64) (Currency, error) { + if identity == "" || amount == 0 { + return Currency{}, errors.New("player: invalid mileage grant") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.state.Granted[identity] { + return s.state.Currency, nil + } + next := cloneWallet(s.state) + if math.MaxUint64-next.Mileage < amount { + return Currency{}, errors.New("player: mileage overflow") + } + next.Mileage += amount + next.Granted[identity] = true + if err := s.commit(next); err != nil { + return Currency{}, err + } + return next.Currency, nil +} + +func (s *Wallet) GrantHopePowderOnce(identity string, amount uint64) (Currency, error) { + if identity == "" || amount == 0 { + return Currency{}, errors.New("player: invalid hope powder grant") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.state.Granted[identity] { + return s.state.Currency, nil + } + next := cloneWallet(s.state) + if math.MaxUint64-next.HopePowder < amount { + return Currency{}, errors.New("player: hope powder overflow") + } + next.HopePowder += amount + next.Granted[identity] = true + if err := s.commit(next); err != nil { + return Currency{}, err + } + return next.Currency, nil +} + +// GrantQuestOnce applies only currency rewards and is idempotent by the same +// quest identity used by the entity stores. Unknown reward types are ignored; +// callers dispatch those to their owning inventory domain. +func (s *Wallet) GrantQuestOnce(identity string, rewards []gamedata.Reward) (Currency, error) { + if identity == "" { + return Currency{}, errors.New("player: missing wallet grant identity") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.state.Granted[identity] { + return s.state.Currency, nil + } + next := cloneWallet(s.state) + for _, reward := range rewards { + switch reward.Type { + case 3: + if math.MaxUint64-next.FreeJewelry < reward.Count { + return Currency{}, errors.New("player: free jewelry overflow") + } + next.FreeJewelry += reward.Count + case 4: + if math.MaxUint64-next.Gold < reward.Count { + return Currency{}, errors.New("player: gold overflow") + } + next.Gold += reward.Count + } + } + next.Granted[identity] = true + if err := s.commit(next); err != nil { + return Currency{}, err + } + return next.Currency, nil +} + +func cloneWallet(in walletSnapshot) walletSnapshot { + out := walletSnapshot{Version: in.Version, Currency: in.Currency, Granted: make(map[string]bool, len(in.Granted)), Spent: make(map[string]bool, len(in.Spent))} + for key, value := range in.Granted { + out.Granted[key] = value + } + for key, value := range in.Spent { + out.Spent[key] = value + } + return out +} + +func (s *Wallet) commit(next walletSnapshot) error { + b, err := json.Marshal(next) + if err != nil { + return err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + f, err := os.CreateTemp(dir, ".wallet-*.tmp") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(b); err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(f.Name(), s.path) + } + if err != nil { + return fmt.Errorf("player: persist wallet: %w", err) + } + s.state = next + return nil +} diff --git a/go/internal/player/wallet_test.go b/go/internal/player/wallet_test.go new file mode 100644 index 0000000..1df05e5 --- /dev/null +++ b/go/internal/player/wallet_test.go @@ -0,0 +1,31 @@ +package player + +import ( + "path/filepath" + "testing" + + "bd2server/internal/gamedata" +) + +func TestWalletGrantPersistsAndIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "wallet.json") + wallet, err := OpenWallet(path, Currency{Gold: 100}) + if err != nil { + t.Fatal(err) + } + rewards := []gamedata.Reward{{Type: 4, Count: 1500}, {Type: 3, Count: 70}, {Type: 25, ID: 210, Count: 1}} + if _, err := wallet.GrantQuestOnce("quest:1", rewards); err != nil { + t.Fatal(err) + } + wallet, err = OpenWallet(path, Currency{}) + if err != nil { + t.Fatal(err) + } + if _, err := wallet.GrantQuestOnce("quest:1", rewards); err != nil { + t.Fatal(err) + } + got := wallet.Snapshot() + if got.Gold != 1600 || got.FreeJewelry != 70 { + t.Fatalf("wallet=%+v", got) + } +} diff --git a/go/internal/progress/persistence_test.go b/go/internal/progress/persistence_test.go new file mode 100644 index 0000000..b42c4d7 --- /dev/null +++ b/go/internal/progress/persistence_test.go @@ -0,0 +1,107 @@ +package progress + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "bd2server/internal/wire" +) + +func TestStorePersistsProgressAcrossRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "player", "progress.json") + store, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("unexpected initial save: %v", err) + } + position := wire.AppendVarint(nil, 2, 21) + position = wire.AppendString(position, 3, `{"MapId":211,"PlayerPosition":{"x":1,"y":2,"z":3}}`) + if err := store.SaveUserPosition(position); err != nil { + t.Fatal(err) + } + if err := store.ClearTutorial(wire.AppendVarint(nil, 2, 2001)); err != nil { + t.Fatal(err) + } + quest := wire.AppendVarint(nil, 2, 12) + quest = wire.AppendVarint(quest, 3, 21) + quest = wire.AppendBytes(quest, 4, []byte{121}) + if _, err := store.UpdateQuest(quest); err != nil { + t.Fatal(err) + } + reopened, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + pos, found := reopened.Position() + if !found || pos.Position.MapID != 211 || pos.PackID != 21 || !reopened.TutorialCleared(2001) { + t.Fatalf("lost persisted progress: %+v", pos) + } + if saved, found := reopened.Quest(12); !found || saved.PackID != 21 || len(saved.Values) != 1 || saved.Values[0] != 121 { + t.Fatalf("lost quest progress: %+v/%v", saved, found) + } +} + +func TestStoreMigratesLegacyQuestKeysAndKeepsPackOverlap(t *testing.T) { + path := filepath.Join(t.TempDir(), "progress.json") + legacy := `{"quests":{"1":{"QuestID":1,"PackID":21,"Values":[7]}},"cleared_quests":{"1":21}}` + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + store, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + if !store.QuestCleared(1, 21) || store.QuestCleared(1, 22) { + t.Fatal("legacy pack21 clear was not migrated with its pack identity") + } + request := wire.AppendVarint(nil, 2, 1) + request = wire.AppendVarint(request, 3, 22) + request = wire.AppendVarint(request, 4, 9) + if _, err := store.UpdateQuest(request); err != nil { + t.Fatal(err) + } + if err := store.ClearQuest(1, 22); err != nil { + t.Fatal(err) + } + if !store.QuestCleared(1, 21) || !store.QuestCleared(1, 22) { + t.Fatal("same quest id did not remain independently cleared in both packs") + } + if _, found := store.Quest(1); found { + t.Fatal("pack-less lookup accepted an ambiguous quest id") + } + for _, packID := range []int{21, 22} { + if quest, found := store.QuestInPack(1, packID); !found || quest.PackID != packID { + t.Fatalf("pack%d quest missing: %+v found=%v", packID, quest, found) + } + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var saved map[string]json.RawMessage + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatal(err) + } + var version int + if err := json.Unmarshal(saved["version"], &version); err != nil || version != snapshotVersion { + t.Fatalf("migrated version=%d err=%v", version, err) + } + var cleared map[string]bool + if err := json.Unmarshal(saved["cleared_quests"], &cleared); err != nil || !cleared["21:1"] || !cleared["22:1"] { + t.Fatalf("migrated clears=%v err=%v", cleared, err) + } +} + +func TestStoreRejectsBrokenSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "broken.json") + if err := os.WriteFile(path, []byte(`{"quests":{"12":{"QuestID":5,"PackID":21}}}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := OpenStore(path); err == nil { + t.Fatal("accepted inconsistent save") + } +} diff --git a/go/internal/progress/progress.go b/go/internal/progress/progress.go new file mode 100644 index 0000000..627d985 --- /dev/null +++ b/go/internal/progress/progress.go @@ -0,0 +1,410 @@ +// Package progress owns mutable player progress that is independent of the +// HTTP, encryption, and captured-fixture compatibility layers. +package progress + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "bd2server/internal/wire" +) + +var ( + ErrInvalidPosition = errors.New("progress: invalid saved position") + ErrInvalidTutorial = errors.New("progress: invalid tutorial id") + ErrInvalidQuest = errors.New("progress: invalid quest update") +) + +type Vector3 struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` +} + +type Position struct { + MapID int `json:"MapId"` + PlayerPosition Vector3 `json:"PlayerPosition"` + ColleaguePositions json.RawMessage `json:"ColleaguePositions"` +} + +type SavedPosition struct { + PackID int + Position Position + RawJSON string +} + +type QuestProgress struct { + QuestID int + PackID int + Values []int +} + +type Store struct { + mu sync.RWMutex + path string + position SavedPosition + tutorials map[int]struct{} + quests map[string]QuestProgress + cleared map[string]struct{} +} + +func NewStore() *Store { + return &Store{tutorials: make(map[int]struct{}), quests: make(map[string]QuestProgress), cleared: make(map[string]struct{})} +} + +type snapshot struct { + Version int `json:"version,omitempty"` + Position SavedPosition `json:"position"` + Tutorials []int `json:"tutorials"` + Quests map[string]QuestProgress `json:"quests"` + Cleared map[string]json.RawMessage `json:"cleared_quests"` +} + +const snapshotVersion = 2 + +func questKey(packID, questID int) string { + return strconv.Itoa(packID) + ":" + strconv.Itoa(questID) +} + +func parseQuestKey(key string) (int, int, bool) { + left, right, found := strings.Cut(key, ":") + if !found { + return 0, 0, false + } + packID, packErr := strconv.Atoi(left) + questID, questErr := strconv.Atoi(right) + return packID, questID, packErr == nil && questErr == nil && packID > 0 && questID > 0 +} + +// OpenStore recovers player progress from one local JSON save. Nothing is +// created on disk until a validated state change is committed. +func OpenStore(path string) (*Store, error) { + s := NewStore() + if path == "" { + return nil, errors.New("progress: empty save path") + } + s.path = filepath.Clean(path) + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("progress: read save: %w", err) + } + var state snapshot + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("progress: malformed save: %w", err) + } + if state.Version != 0 && state.Version != snapshotVersion { + return nil, fmt.Errorf("progress: unsupported save version %d", state.Version) + } + legacy := state.Version == 0 + s.position = state.Position + for _, id := range state.Tutorials { + if id <= 0 { + return nil, errors.New("progress: invalid saved tutorial") + } + s.tutorials[id] = struct{}{} + } + for key, quest := range state.Quests { + if quest.QuestID <= 0 || quest.PackID <= 0 { + return nil, errors.New("progress: invalid saved quest") + } + if legacy { + id, err := strconv.Atoi(key) + if err != nil || id != quest.QuestID { + return nil, errors.New("progress: invalid saved quest") + } + } else { + packID, questID, ok := parseQuestKey(key) + if !ok || packID != quest.PackID || questID != quest.QuestID { + return nil, errors.New("progress: invalid saved quest key") + } + } + s.quests[questKey(quest.PackID, quest.QuestID)] = quest + } + for key, raw := range state.Cleared { + if legacy { + questID, err := strconv.Atoi(key) + var packID int + if err != nil || json.Unmarshal(raw, &packID) != nil || questID <= 0 || packID <= 0 { + return nil, errors.New("progress: invalid cleared quest") + } + s.cleared[questKey(packID, questID)] = struct{}{} + continue + } + packID, questID, ok := parseQuestKey(key) + var cleared bool + if !ok || json.Unmarshal(raw, &cleared) != nil || !cleared { + return nil, errors.New("progress: invalid cleared quest key") + } + s.cleared[questKey(packID, questID)] = struct{}{} + } + if legacy { + // Install the unambiguous pack+quest schema immediately. Pack 21 and 22 + // both number quests from one, so waiting until the first pack22 update + // would risk overwriting valid pack21 progress. + if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil { + return nil, fmt.Errorf("progress: migrate legacy save: %w", err) + } + } + return s, nil +} + +// commit writes a complete snapshot before exposing the new state. Caller +// holds mu; failure leaves the in-memory player state unchanged. +func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error { + if s.path != "" { + state := snapshot{Version: snapshotVersion, Position: position, Quests: quests, Cleared: make(map[string]json.RawMessage, len(cleared))} + for id := range tutorials { + state.Tutorials = append(state.Tutorials, id) + } + sort.Ints(state.Tutorials) + for key := range cleared { + state.Cleared[key] = json.RawMessage("true") + } + data, err := json.Marshal(state) + if err != nil { + return err + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("progress: create save dir: %w", err) + } + file, err := os.CreateTemp(dir, ".progress-*.tmp") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return fmt.Errorf("progress: write save: %w", err) + } + if err := os.Rename(file.Name(), s.path); err != nil { + return fmt.Errorf("progress: install save: %w", err) + } + } + s.position, s.tutorials, s.quests, s.cleared = position, tutorials, quests, cleared + return nil +} + +// UpdateQuest consumes QuestUpdateRequest: field 1 seq, field 2 quest_id, +// field 3 pack_id, and repeated packed/unpacked int32 field 4 quest_value. +// It returns the quest id that the response must echo as update_quest_id. +func (s *Store) UpdateQuest(request []byte) (int, error) { + questID, found, err := wire.Varint(request, 2) + if err != nil || !found || questID == 0 || questID > uint64(^uint32(0)>>1) { + return 0, fmt.Errorf("%w: quest id", ErrInvalidQuest) + } + packID, found, err := wire.Varint(request, 3) + if err != nil || !found || packID == 0 || packID > uint64(^uint32(0)>>1) { + return 0, fmt.Errorf("%w: pack id", ErrInvalidQuest) + } + values := make([]int, 0, 4) + if err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 4 { + return nil + } + switch field.Type { + case 0: + value, count := binary.Uvarint(field.Value) + if count <= 0 || value > uint64(^uint32(0)>>1) { + return ErrInvalidQuest + } + values = append(values, int(value)) + case 2: + for remaining := field.Value; len(remaining) > 0; { + value, count := binary.Uvarint(remaining) + if count <= 0 || value > uint64(^uint32(0)>>1) { + return ErrInvalidQuest + } + values = append(values, int(value)) + remaining = remaining[count:] + } + default: + return ErrInvalidQuest + } + return nil + }); err != nil { + return 0, fmt.Errorf("%w: quest values", ErrInvalidQuest) + } + + progress := QuestProgress{QuestID: int(questID), PackID: int(packID), Values: append([]int(nil), values...)} + s.mu.Lock() + defer s.mu.Unlock() + quests := make(map[string]QuestProgress, len(s.quests)+1) + for key, current := range s.quests { + quests[key] = current + } + quests[questKey(progress.PackID, progress.QuestID)] = progress + if err := s.commit(s.position, s.tutorials, quests, s.cleared); err != nil { + return 0, err + } + return progress.QuestID, nil +} + +// SaveUserPosition consumes SaveUserPositionRequest: +// field 1 seq, field 2 pack_id, field 3 pack_position JSON. +func (s *Store) SaveUserPosition(request []byte) error { + packID, found, err := wire.Varint(request, 2) + if err != nil || !found || packID == 0 || packID > uint64(^uint32(0)>>1) { + return fmt.Errorf("%w: pack id", ErrInvalidPosition) + } + raw, found, err := wire.Bytes(request, 3) + if err != nil || !found || len(raw) == 0 || len(raw) > 64<<10 { + return fmt.Errorf("%w: position JSON", ErrInvalidPosition) + } + var position Position + if err := json.Unmarshal(raw, &position); err != nil || position.MapID <= 0 { + return fmt.Errorf("%w: decode JSON", ErrInvalidPosition) + } + + s.mu.Lock() + defer s.mu.Unlock() + return s.commit(SavedPosition{PackID: int(packID), Position: position, RawJSON: string(raw)}, s.tutorials, s.quests, s.cleared) +} + +// ClearTutorial consumes TutorialClearRequest: field 1 seq, field 2 id. +// Repeated requests are idempotent. +func (s *Store) ClearTutorial(request []byte) error { + id, found, err := wire.Varint(request, 2) + if err != nil || !found || id == 0 || id > uint64(^uint32(0)>>1) { + return ErrInvalidTutorial + } + s.mu.Lock() + defer s.mu.Unlock() + tutorials := make(map[int]struct{}, len(s.tutorials)+1) + for cleared := range s.tutorials { + tutorials[cleared] = struct{}{} + } + tutorials[int(id)] = struct{}{} + return s.commit(s.position, tutorials, s.quests, s.cleared) +} + +func (s *Store) Position() (SavedPosition, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.position, s.position.PackID != 0 +} + +func (s *Store) TutorialCleared(id int) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, found := s.tutorials[id] + return found +} + +func (s *Store) Tutorials() []int { + s.mu.RLock() + defer s.mu.RUnlock() + ids := make([]int, 0, len(s.tutorials)) + for id := range s.tutorials { + ids = append(ids, id) + } + sort.Ints(ids) + return ids +} + +// Handle exposes the persisted tutorial set through TutorialInfoResponse. +// TutorialClear itself remains in session so both routes share this store. +func (s *Store) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/TutorialInfo" { + return 0, nil, false, nil + } + if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 { + return 0, nil, true, ErrInvalidTutorial + } + var packed []byte + for _, id := range s.Tutorials() { + packed = binary.AppendUvarint(packed, uint64(id)) + } + var response []byte + if len(packed) > 0 { + response = wire.AppendBytes(response, 1, packed) + } + return 101, response, true, nil +} + +func (s *Store) Quest(id int) (QuestProgress, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + var quest QuestProgress + found := false + for _, candidate := range s.quests { + if candidate.QuestID != id { + continue + } + if found { + // A quest ID alone becomes ambiguous once more than one pack has + // progress. Callers that know the pack must use QuestInPack. + return QuestProgress{}, false + } + quest, found = candidate, true + } + quest.Values = append([]int(nil), quest.Values...) + return quest, found +} + +func (s *Store) QuestInPack(questID, packID int) (QuestProgress, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + quest, found := s.quests[questKey(packID, questID)] + quest.Values = append([]int(nil), quest.Values...) + return quest, found +} + +// ClearQuest atomically records a completed quest. Quest identity is the +// (pack, quest) pair because every story pack starts numbering from one. +func (s *Store) ClearQuest(questID, packID int) error { + if questID <= 0 || packID <= 0 { + return ErrInvalidQuest + } + s.mu.Lock() + defer s.mu.Unlock() + key := questKey(packID, questID) + if _, ok := s.cleared[key]; ok { + return nil + } + cleared := make(map[string]struct{}, len(s.cleared)+1) + for current := range s.cleared { + cleared[current] = struct{}{} + } + cleared[key] = struct{}{} + return s.commit(s.position, s.tutorials, s.quests, cleared) +} + +func (s *Store) QuestCleared(questID, packID int) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, found := s.cleared[questKey(packID, questID)] + return found +} + +// ClearedQuests returns sorted quest IDs for one pack. The detached slice is +// safe for response construction without holding the store lock. +func (s *Store) ClearedQuests(packID int) []int { + s.mu.RLock() + defer s.mu.RUnlock() + ids := make([]int, 0, len(s.cleared)) + for key := range s.cleared { + currentPack, questID, ok := parseQuestKey(key) + if ok && currentPack == packID { + ids = append(ids, questID) + } + } + sort.Ints(ids) + return ids +} diff --git a/go/internal/progress/progress_test.go b/go/internal/progress/progress_test.go new file mode 100644 index 0000000..c261236 --- /dev/null +++ b/go/internal/progress/progress_test.go @@ -0,0 +1,95 @@ +package progress + +import ( + "encoding/binary" + "errors" + "reflect" + "testing" + + "bd2server/internal/wire" +) + +func TestSaveUserPosition(t *testing.T) { + request := wire.AppendVarint(nil, 1, 119) + request = wire.AppendVarint(request, 2, 21) + request = wire.AppendString(request, 3, `{"MapId":211,"PlayerPosition":{"x":1.5,"y":0.0,"z":-2.5},"ColleaguePositions":null}`) + store := NewStore() + if err := store.SaveUserPosition(request); err != nil { + t.Fatal(err) + } + position, found := store.Position() + if !found || position.PackID != 21 || position.Position.MapID != 211 || position.Position.PlayerPosition.Z != -2.5 { + t.Fatalf("unexpected saved position: %+v found=%v", position, found) + } +} + +func TestSaveUserPositionRejectsBadJSON(t *testing.T) { + request := wire.AppendVarint(nil, 2, 21) + request = wire.AppendString(request, 3, `{bad`) + if err := NewStore().SaveUserPosition(request); !errors.Is(err, ErrInvalidPosition) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClearTutorialIsIdempotent(t *testing.T) { + request := wire.AppendVarint(nil, 1, 116) + request = wire.AppendVarint(request, 2, 2001) + store := NewStore() + if err := store.ClearTutorial(request); err != nil { + t.Fatal(err) + } + if err := store.ClearTutorial(request); err != nil { + t.Fatal(err) + } + if !store.TutorialCleared(2001) { + t.Fatal("tutorial completion was not retained") + } +} + +func TestTutorialInfoReturnsPersistedClears(t *testing.T) { + store := NewStore() + for seq, id := range []uint64{2001, 10030, 15} { + request := wire.AppendVarint(nil, 1, uint64(seq+1)) + request = wire.AppendVarint(request, 2, id) + if err := store.ClearTutorial(request); err != nil { + t.Fatal(err) + } + } + code, response, ok, err := store.Handle("/TutorialInfo", wire.AppendVarint(nil, 1, 99)) + if err != nil || !ok || code != 101 { + t.Fatalf("info: code=%d ok=%v err=%v", code, ok, err) + } + packed, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("clears missing: %v", err) + } + var got []uint64 + for len(packed) > 0 { + value, n := binary.Uvarint(packed) + if n <= 0 { + t.Fatal("bad packed tutorials") + } + got = append(got, value) + packed = packed[n:] + } + want := []uint64{15, 2001, 10030} + if !reflect.DeepEqual(got, want) { + t.Fatalf("tutorials=%v want=%v", got, want) + } +} + +func TestUpdateQuestPackedValues(t *testing.T) { + request := wire.AppendVarint(nil, 1, 120) + request = wire.AppendVarint(request, 2, 12) + request = wire.AppendVarint(request, 3, 21) + request = wire.AppendBytes(request, 4, []byte{121, 2}) // packed 121, 2 + store := NewStore() + updated, err := store.UpdateQuest(request) + if err != nil { + t.Fatal(err) + } + quest, found := store.Quest(12) + if updated != 12 || !found || quest.PackID != 21 || len(quest.Values) != 2 || quest.Values[0] != 121 || quest.Values[1] != 2 { + t.Fatalf("unexpected quest: updated=%d found=%v quest=%+v", updated, found, quest) + } +} diff --git a/go/internal/protocol/protocol.go b/go/internal/protocol/protocol.go new file mode 100644 index 0000000..2310381 --- /dev/null +++ b/go/internal/protocol/protocol.go @@ -0,0 +1,76 @@ +// Package protocol implements the game's authenticated HTTP wire framing. +// It has no dependency on captured traffic or account state. +package protocol + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + + "bd2server/internal/cryptox" +) + +type Envelope struct { + ErrorType int `json:"errorType"` + PacketCode int `json:"packetCode"` + ErrorMessage string `json:"errorMessage"` + Length int `json:"length"` + Data string `json:"data"` + ServerNowTime int64 `json:"serverNowTime"` + Notify string `json:"notify,omitempty"` +} + +// Encode encrypts protobuf bytes using the active key and wraps them in the +// JSON envelope expected by game API responses. +func Encode(code int, proto, key []byte, now int64) ([]byte, error) { + data, err := cryptox.EncryptBase64Payload(proto, key) + if err != nil { + return nil, err + } + return json.Marshal(Envelope{ + PacketCode: code, Length: base64.StdEncoding.EncodedLen(len(proto)), + Data: data, ServerNowTime: now, + }) +} + +type BatchRequest struct { + Path string `json:"path"` + RequestData string `json:"requestData"` +} + +type BatchResponse struct { + Path string `json:"path"` + ResponseData Envelope `json:"responseData"` +} + +// DecodeBatchRequest decrypts the JSON array, preserving request order. +// Each member's requestData is base64(protobuf), not encrypted a second time. +func DecodeBatchRequest(body, key []byte) ([]BatchRequest, [][]byte, error) { + plain, err := cryptox.DecryptBase64(strings.TrimSpace(string(body)), key) + if err != nil { + return nil, nil, fmt.Errorf("decrypt batch: %w", err) + } + var requests []BatchRequest + if err := json.Unmarshal(plain, &requests); err != nil { + return nil, nil, fmt.Errorf("decode batch JSON: %w", err) + } + if len(requests) == 0 || len(requests) > 256 { + return nil, nil, errors.New("batch request has invalid item count") + } + decoded := make([][]byte, 0, len(requests)) + seen := make(map[string]bool, len(requests)) + for _, req := range requests { + if len(req.Path) < 2 || req.Path[0] != '/' || strings.Contains(req.Path[1:], "/") || seen[req.Path] || req.RequestData == "" { + return nil, nil, fmt.Errorf("invalid or duplicate batch path %q", req.Path) + } + seen[req.Path] = true + proto, err := base64.StdEncoding.DecodeString(req.RequestData) + if err != nil { + return nil, nil, fmt.Errorf("decode %s request: %w", req.Path, err) + } + decoded = append(decoded, proto) + } + return requests, decoded, nil +} diff --git a/go/internal/protocol/protocol_test.go b/go/internal/protocol/protocol_test.go new file mode 100644 index 0000000..f13090e --- /dev/null +++ b/go/internal/protocol/protocol_test.go @@ -0,0 +1,46 @@ +package protocol + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "bd2server/internal/cryptox" +) + +func TestEncode(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + proto := []byte{8, 42} + raw, err := Encode(19, proto, key, 123) + if err != nil { + t.Fatal(err) + } + var envelope Envelope + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatal(err) + } + decoded, err := cryptox.DecryptBase64Payload(envelope.Data, key) + if err != nil || string(decoded) != string(proto) || envelope.PacketCode != 19 || envelope.Length != 4 || envelope.ServerNowTime != 123 { + t.Fatalf("unexpected encoded response: %+v data=%v err=%v", envelope, decoded, err) + } +} + +func TestBatchPreservesOrderAndRejectsDuplicates(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + input := []BatchRequest{{Path: "/B", RequestData: base64.StdEncoding.EncodeToString([]byte{8, 1})}, {Path: "/A", RequestData: base64.StdEncoding.EncodeToString([]byte{8, 2})}} + plain, _ := json.Marshal(input) + cipher, err := cryptox.EncryptBase64(plain, key) + if err != nil { + t.Fatal(err) + } + got, proto, err := DecodeBatchRequest([]byte(cipher), key) + if err != nil || got[0].Path != "/B" || got[1].Path != "/A" || proto[1][1] != 2 { + t.Fatalf("batch decode: %v %v %v", got, proto, err) + } + input[1].Path = "/B" + plain, _ = json.Marshal(input) + cipher, _ = cryptox.EncryptBase64(plain, key) + if _, _, err := DecodeBatchRequest([]byte(cipher), key); err == nil { + t.Fatal("accepted duplicate path") + } +} diff --git a/go/internal/readonly/readonly.go b/go/internal/readonly/readonly.go new file mode 100644 index 0000000..62774cd --- /dev/null +++ b/go/internal/readonly/readonly.go @@ -0,0 +1,161 @@ +// Package readonly serves versioned, immutable new-account protocol data. +// +// The seed is a JSON representation of protobuf fields, rather than an HTTP +// response or an encoded protobuf blob. This makes the runtime independent +// of development captures while preserving unknown nested message layouts. +package readonly + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + + "bd2server/internal/wire" +) + +const Version23413 = "2.34.13" + +var ErrInvalidSeed = errors.New("readonly: invalid seed") + +// Field is one protobuf field. Bytes is used only when a length-delimited +// value is not itself a protobuf message; Fields retains nested structure. +// Fixed values are little-endian integers, exactly as protobuf specifies. +type Field struct { + Number int `json:"number"` + Type int `json:"type"` + Varint uint64 `json:"varint,omitempty"` + Fixed32 uint32 `json:"fixed32_le,omitempty"` + Fixed64 uint64 `json:"fixed64_le,omitempty"` + Bytes []uint8 `json:"bytes,omitempty"` + Fields []Field `json:"fields,omitempty"` +} + +type Response struct { + PacketCode int `json:"packet_code"` + Fields []Field `json:"fields"` +} + +type Seed struct { + Version string `json:"version"` + Responses map[string]Response `json:"responses"` +} + +func Load(path string) (*Seed, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("readonly: read seed: %w", err) + } + var s Seed + if err := json.Unmarshal(b, &s); err != nil { + return nil, fmt.Errorf("readonly: decode seed JSON: %w", err) + } + if err := s.Validate(); err != nil { + return nil, err + } + return &s, nil +} + +func (s *Seed) Write(path string) error { + if err := s.Validate(); err != nil { + return err + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o600) +} + +func (s *Seed) Validate() error { + if s == nil || s.Version != Version23413 || len(s.Responses) == 0 { + return ErrInvalidSeed + } + for path, r := range s.Responses { + if len(path) < 2 || path[0] != '/' || r.PacketCode < 0 { + return fmt.Errorf("%w: response %q", ErrInvalidSeed, path) + } + if err := validateFields(r.Fields); err != nil { + return fmt.Errorf("%w: %s: %v", ErrInvalidSeed, path, err) + } + } + return nil +} + +func validateFields(fields []Field) error { + for _, f := range fields { + if f.Number <= 0 || f.Type < 0 || f.Type > 5 || (f.Type != 0 && f.Type != 1 && f.Type != 2 && f.Type != 5) { + return fmt.Errorf("bad wire field %d/%d", f.Number, f.Type) + } + if f.Type != 2 && (len(f.Bytes) != 0 || len(f.Fields) != 0) { + return fmt.Errorf("non-length field %d has payload", f.Number) + } + if f.Type == 2 && len(f.Bytes) != 0 && len(f.Fields) != 0 { + return fmt.Errorf("field %d has both bytes and fields", f.Number) + } + if err := validateFields(f.Fields); err != nil { + return err + } + } + return nil +} + +// Handle re-encodes a response every time. It intentionally accepts any +// structurally valid sequenced request, matching other initialization routes. +func (s *Seed) Handle(path string, request []byte) (int, []byte, bool, error) { + r, ok := s.Responses[path] + if !ok { + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("readonly: %s invalid sequence", path) + } + b, err := encodeFields(r.Fields) + if err != nil { + return 0, nil, true, err + } + return r.PacketCode, b, true, nil +} + +func encodeFields(fields []Field) ([]byte, error) { + var out []byte + for _, f := range fields { + out = binary.AppendUvarint(out, uint64(f.Number<<3|f.Type)) + switch f.Type { + case 0: + out = binary.AppendUvarint(out, f.Varint) + case 1: + var b [8]byte + binary.LittleEndian.PutUint64(b[:], f.Fixed64) + out = append(out, b[:]...) + case 5: + var b [4]byte + binary.LittleEndian.PutUint32(b[:], f.Fixed32) + out = append(out, b[:]...) + case 2: + payload := f.Bytes + if len(f.Fields) != 0 { + var err error + payload, err = encodeFields(f.Fields) + if err != nil { + return nil, err + } + } + out = binary.AppendUvarint(out, uint64(len(payload))) + out = append(out, payload...) + } + } + return out, nil +} + +// Service adapts a loaded seed to the session handler interface. +type Service struct{ Seed *Seed } + +func (s Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if s.Seed == nil { + return 0, nil, false, nil + } + return s.Seed.Handle(path, request) +} diff --git a/go/internal/readonly/readonly_test.go b/go/internal/readonly/readonly_test.go new file mode 100644 index 0000000..df360df --- /dev/null +++ b/go/internal/readonly/readonly_test.go @@ -0,0 +1,57 @@ +package readonly + +import ( + "bytes" + "path/filepath" + "testing" + + "bd2server/internal/fixture" + "bd2server/internal/wire" +) + +func TestSeedByteCompatibility(t *testing.T) { + root := filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") + set, err := fixture.Load(root) + if err != nil { + t.Skipf("optional capture unavailable: %v", err) + } + key, err := set.SessionKey() + if err != nil { + t.Fatal(err) + } + seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "readonly.json")) + if err != nil { + t.Fatal(err) + } + want := map[string]struct { + PacketCode int + Proto []byte + }{} + for batch := 1; batch <= 2; batch++ { + body, err := set.BatchResponse(batch) + if err != nil { + t.Fatal(err) + } + items, decoded, err := fixture.DecodeBatch(body, []byte(key)) + if err != nil { + t.Fatal(err) + } + for _, item := range items { + if _, ok := seed.Responses[item.Path]; ok { + want[item.Path] = struct { + PacketCode int + Proto []byte + }{item.Response.PacketCode, decoded[item.Path]} + } + } + } + if len(want) != len(seed.Responses) { + t.Fatalf("capture has %d seed responses, want %d", len(want), len(seed.Responses)) + } + for path, expected := range want { + code, got, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 999)) + if err != nil || !ok || code != expected.PacketCode || !bytes.Equal(got, expected.Proto) { + t.Errorf("%s: code=%d want=%d equal=%v err=%v", path, code, expected.PacketCode, bytes.Equal(got, expected.Proto), err) + } + } +} diff --git a/go/internal/schedule/schedule.go b/go/internal/schedule/schedule.go new file mode 100644 index 0000000..416bf99 --- /dev/null +++ b/go/internal/schedule/schedule.go @@ -0,0 +1,93 @@ +// Package schedule serves the versioned regular-content calendar used by the +// client to decide season, calculation, and off-season UI states. +package schedule + +import ( + "errors" + + "bd2server/internal/wire" +) + +// Season is the named semantic form of Proto.Net.SeasonInfo. +type Season struct { + ID uint64 + StartMilliseconds uint64 + EndMilliseconds uint64 + RankRewardGroupID uint64 +} + +type Content struct { + ID uint64 + Current Season + Next Season +} + +type RegularSeason struct { + ContentID uint64 + Season uint64 +} + +// Service contains the official 2.34.13 calendar active for this fixed +// client/GameData version. It is server configuration, not a captured packet: +// Handle encodes every protobuf field from these named values. +type Service struct { + CalculateMilliseconds uint64 + Contents []Content + Regular []RegularSeason +} + +func Version23413() *Service { + return &Service{ + CalculateMilliseconds: 32400000, + Contents: []Content{ + {ID: 1, Current: Season{160, 1789344000000, 1789916399000, 1}, Next: Season{161, 1789948800000, 1790521199000, 1}}, + {ID: 2, Current: Season{58, 1755129600000, 253370678399000, 1}, Next: Season{58, 1755129600000, 253370678399000, 1}}, + {ID: 3, Current: Season{129, 1789657200000, 1790262000000, 1}, Next: Season{999999, 253370764800000, 253370764800000, 1}}, + {ID: 4, Current: Season{862, 1789830000000, 1789916400000, 1}, Next: Season{999999, 253370764800000, 253370764800000, 1}}, + {ID: 5, Current: Season{29, 1787788800000, 1790089199000, 1}, Next: Season{30, 1790121600000, 1791385199000, 1}}, + {ID: 6, Current: Season{26, 1787788800000, 1788361199000, 1}, Next: Season{27, 1790121600000, 1790693999000, 1}}, + {ID: 7, Current: Season{14, 1788220800000, 1790812800000, 1}, Next: Season{999999, 253370764800000, 253370764800000, 1}}, + {ID: 8, Current: Season{42, 1789603200000, 1790089199000, 1}, Next: Season{43, 1790121600000, 1790780399000, 1}}, + {ID: 9, Current: Season{2, 1787788800000, 1792594799000, 1}, Next: Season{999999, 253370764800000, 253370764800000, 1}}, + }, + Regular: []RegularSeason{{1, 5}, {2, 6}, {5, 5}, {8, 0}, {9, 0}}, + } +} + +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/ScheduleInfo" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, errors.New("schedule: invalid request sequence") + } + if s == nil || s.CalculateMilliseconds == 0 || len(s.Contents) == 0 { + return 0, nil, true, errors.New("schedule: unavailable calendar") + } + response := wire.AppendVarint(nil, 1, s.CalculateMilliseconds) + for _, content := range s.Contents { + entry := wire.AppendVarint(nil, 1, content.ID) + entry = wire.AppendBytes(entry, 2, encodeSeason(content.Current)) + entry = wire.AppendBytes(entry, 3, encodeSeason(content.Next)) + response = wire.AppendBytes(response, 2, entry) + } + for _, regular := range s.Regular { + entry := wire.AppendVarint(nil, 1, regular.ContentID) + if regular.Season != 0 { + entry = wire.AppendVarint(entry, 2, regular.Season) + } + response = wire.AppendBytes(response, 3, entry) + } + return 117, response, true, nil +} + +func encodeSeason(season Season) []byte { + result := wire.AppendVarint(nil, 1, season.ID) + result = wire.AppendVarint(result, 2, season.StartMilliseconds) + result = wire.AppendVarint(result, 3, season.EndMilliseconds) + if season.RankRewardGroupID != 0 { + result = wire.AppendVarint(result, 6, season.RankRewardGroupID) + } + return result +} diff --git a/go/internal/schedule/schedule_test.go b/go/internal/schedule/schedule_test.go new file mode 100644 index 0000000..2a262d8 --- /dev/null +++ b/go/internal/schedule/schedule_test.go @@ -0,0 +1,55 @@ +package schedule + +import ( + "bytes" + "os" + "testing" + + "bd2server/internal/wire" +) + +func TestVersion23413ContainsRequiredContentSix(t *testing.T) { + service := Version23413() + 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 TestVersion23413MatchesOptionalOfficialResponse(t *testing.T) { + path := os.Getenv("BD2_TEST_SCHEDULE_CAPTURE") + if path == "" { + t.Skip("set BD2_TEST_SCHEDULE_CAPTURE to compare an official response") + } + want, err := os.ReadFile(path) + if err != nil { + t.Skipf("optional official schedule sample unavailable: %v", err) + } + _, got, _, err := Version23413().Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 2)) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("schedule differs from official 2.34.13 response: equal=%v err=%v\ngot=%x\nwant=%x", bytes.Equal(got, want), err, got, want) + } +} + +func TestScheduleRejectsMissingSequence(t *testing.T) { + if _, _, handled, err := Version23413().Handle("/ScheduleInfo", nil); !handled || err == nil { + t.Fatalf("missing sequence handled=%v err=%v", handled, err) + } +} diff --git a/go/internal/session/server.go b/go/internal/session/server.go new file mode 100644 index 0000000..60ec65f --- /dev/null +++ b/go/internal/session/server.go @@ -0,0 +1,193 @@ +// Package session owns authentication, encryption, and request routing for a +// local account. It deliberately has no capture/fixture dependency. +package session + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "bd2server/internal/cryptox" + "bd2server/internal/progress" + "bd2server/internal/protocol" + "bd2server/internal/transport" + "bd2server/internal/wire" +) + +type LoginService interface { + Login(request, sessionKey []byte) ([]byte, error) +} + +// Handler is implemented by domain services. ok=false means the endpoint is +// not owned by that service; unknown endpoints fail closed. +type Handler interface { + Handle(path string, request []byte) (packetCode int, response []byte, ok bool, err error) +} + +// SessionAware handlers use a login-scoped opaque ID when protobuf request +// sequences participate in durable idempotency keys. +type SessionAware interface { + BeginSession(id string) +} + +type Server struct { + mu sync.Mutex + key []byte + token string + loggedIn bool + login LoginService + handlers []Handler + progress *progress.Store +} + +func NewServer(login LoginService, handlers ...Handler) (*Server, error) { + return NewServerWithProgress(login, progress.NewStore(), handlers...) +} + +// NewServerWithProgress uses a caller-owned player progress store; the serve +// command supplies a file-backed store so changes survive server restarts. +func NewServerWithProgress(login LoginService, player *progress.Store, handlers ...Handler) (*Server, error) { + if login == nil { + return nil, errors.New("session login service is nil") + } + if player == nil { + return nil, errors.New("session progress store is nil") + } + key := make([]byte, 16) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("create local session key: %w", err) + } + tokenBytes := make([]byte, 24) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, fmt.Errorf("create local session token: %w", err) + } + return &Server{ + key: []byte(hex.EncodeToString(key)), token: hex.EncodeToString(tokenBytes) + "|1", + login: login, handlers: append([]Handler(nil), handlers...), progress: player, + }, nil +} + +func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport.RawReply, error) { + s.mu.Lock() + defer s.mu.Unlock() + if path == "/LoginUser" { + request, err := cryptox.DecryptBase64Payload(string(body), cryptox.Key()) + if err != nil { + return transport.RawReply{}, fmt.Errorf("LoginUser decrypt: %w", err) + } + proto, err := s.login.Login(request, s.key) + if err != nil { + return transport.RawReply{}, fmt.Errorf("LoginUser: %w", err) + } + sessionBytes := make([]byte, 12) + if _, err := rand.Read(sessionBytes); err != nil { + return transport.RawReply{}, fmt.Errorf("create login request identity: %w", err) + } + sessionID := hex.EncodeToString(sessionBytes) + for _, handler := range s.handlers { + if aware, ok := handler.(SessionAware); ok { + aware.BeginSession(sessionID) + } + } + s.loggedIn = true + encoded, err := protocol.Encode(3, proto, cryptox.Key(), time.Now().UnixMilli()) + return transport.RawReply{Body: encoded, Cookie: s.token}, err + } + if err := s.authorize(cookie); err != nil { + return transport.RawReply{}, err + } + if path == "/BatchRequest" { + return s.handleBatch(body) + } + request, err := cryptox.DecryptBase64Payload(string(body), s.key) + if err != nil { + return transport.RawReply{}, fmt.Errorf("%s decrypt: %w", path, err) + } + code, response, err := s.dispatch(path, request) + if err != nil { + return transport.RawReply{}, err + } + encoded, err := protocol.Encode(code, response, s.key, time.Now().UnixMilli()) + return transport.RawReply{Body: encoded}, err +} + +func (s *Server) handleBatch(body []byte) (transport.RawReply, error) { + requests, decoded, err := protocol.DecodeBatchRequest(body, s.key) + if err != nil { + return transport.RawReply{}, err + } + items := make([]protocol.BatchResponse, 0, len(requests)) + for i, request := range requests { + code, response, err := s.dispatch(request.Path, decoded[i]) + if err != nil { + return transport.RawReply{}, fmt.Errorf("batch %s: %w", request.Path, err) + } + raw, err := protocol.Encode(code, response, s.key, time.Now().UnixMilli()) + if err != nil { + return transport.RawReply{}, err + } + var envelope protocol.Envelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return transport.RawReply{}, err + } + items = append(items, protocol.BatchResponse{Path: request.Path, ResponseData: envelope}) + } + encoded, err := json.Marshal(items) + return transport.RawReply{Body: encoded}, err +} + +func (s *Server) dispatch(path string, request []byte) (int, []byte, error) { + if _, found, err := wire.Varint(request, 1); err != nil || !found { + return 0, nil, fmt.Errorf("%s has no request sequence", path) + } + switch path { + case "/SaveUserPosition": + if err := s.progress.SaveUserPosition(request); err != nil { + return 0, nil, fmt.Errorf("%s: %w", path, err) + } + return 7, nil, nil + case "/TutorialClear": + if err := s.progress.ClearTutorial(request); err != nil { + return 0, nil, fmt.Errorf("%s: %w", path, err) + } + return 102, nil, nil + case "/QuestUpdate": + questID, err := s.progress.UpdateQuest(request) + if err != nil { + return 0, nil, fmt.Errorf("%s: %w", path, err) + } + response := wire.AppendVarint(nil, 1, uint64(questID)) + return 19, wire.AppendBytes(response, 2, nil), nil + } + for _, handler := range s.handlers { + code, response, ok, err := handler.Handle(path, request) + if err != nil { + return 0, nil, err + } + if ok { + return code, response, nil + } + } + return 0, nil, fmt.Errorf("%w: %s", transport.ErrNotImplemented, path) +} + +func (s *Server) authorize(cookie string) error { + if !s.loggedIn { + return errors.New("session login required") + } + want := "s=" + s.token + for _, value := range strings.Split(cookie, ";") { + if strings.TrimSpace(value) == want { + return nil + } + } + return errors.New("invalid local session cookie") +} + +func (s *Server) KeyForTest() []byte { return append([]byte(nil), s.key...) } +func (s *Server) ProgressForTest() *progress.Store { return s.progress } diff --git a/go/internal/session/server_test.go b/go/internal/session/server_test.go new file mode 100644 index 0000000..7fe405d --- /dev/null +++ b/go/internal/session/server_test.go @@ -0,0 +1,114 @@ +package session + +import ( + "encoding/base64" + "encoding/json" + "errors" + "testing" + + "bd2server/internal/cryptox" + "bd2server/internal/protocol" + "bd2server/internal/transport" + "bd2server/internal/wire" +) + +type fakeLogin struct{} + +func (fakeLogin) Login(request, key []byte) ([]byte, error) { + if _, ok, err := wire.Varint(request, 1); err != nil || !ok { + return nil, errors.New("missing seq") + } + user := wire.AppendBytes(nil, 3, key) + return wire.AppendBytes(nil, 1, user), nil +} + +type fakeDomain struct{} + +func (fakeDomain) Handle(path string, request []byte) (int, []byte, bool, error) { + if path == "/EmptyInfo" { + return 77, nil, true, nil + } + return 0, nil, false, nil +} + +func login(t *testing.T, server *Server) transport.RawReply { + t.Helper() + request := wire.AppendVarint(nil, 1, 1) + body, err := cryptox.EncryptBase64Payload(request, cryptox.Key()) + if err != nil { + t.Fatal(err) + } + reply, err := server.DispatchRaw("/LoginUser", []byte(body), "") + if err != nil { + t.Fatal(err) + } + var envelope protocol.Envelope + if err := json.Unmarshal(reply.Body, &envelope); err != nil { + t.Fatal(err) + } + proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key()) + if err != nil || envelope.PacketCode != 3 || len(proto) == 0 || reply.Cookie == "" { + t.Fatalf("login response: %+v proto=%d err=%v", envelope, len(proto), err) + } + return reply +} + +func TestNativeLoginAndBatch(t *testing.T) { + server, err := NewServer(fakeLogin{}, fakeDomain{}) + if err != nil { + t.Fatal(err) + } + reply := login(t, server) + requests := []protocol.BatchRequest{{Path: "/EmptyInfo", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}} + plain, _ := json.Marshal(requests) + body, _ := cryptox.EncryptBase64(plain, server.KeyForTest()) + batch, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+reply.Cookie) + if err != nil { + t.Fatal(err) + } + var items []protocol.BatchResponse + if err := json.Unmarshal(batch.Body, &items); err != nil || len(items) != 1 || items[0].Path != "/EmptyInfo" || items[0].ResponseData.PacketCode != 77 { + t.Fatalf("batch response: %+v err=%v", items, err) + } +} + +func TestSessionRejectsMissingCookieAndUnknownPath(t *testing.T) { + server, _ := NewServer(fakeLogin{}, fakeDomain{}) + if _, err := server.DispatchRaw("/EmptyInfo", nil, ""); err == nil { + t.Fatal("authenticated endpoint accepted missing cookie") + } + reply := login(t, server) + request := wire.AppendVarint(nil, 1, 99) + body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest()) + if _, err := server.DispatchRaw("/InventedPacket", []byte(body), "s="+reply.Cookie); !errors.Is(err, transport.ErrNotImplemented) { + t.Fatalf("unknown endpoint did not fail closed: %v", err) + } +} + +func TestNativeProgress(t *testing.T) { + server, _ := NewServer(fakeLogin{}) + reply := login(t, server) + cookie := "s=" + reply.Cookie + position := wire.AppendVarint(nil, 1, 999001) + position = wire.AppendVarint(position, 2, 21) + position = wire.AppendString(position, 3, `{"MapId":211,"PlayerPosition":{"x":1,"y":2,"z":3},"ColleaguePositions":null}`) + body, _ := cryptox.EncryptBase64Payload(position, server.KeyForTest()) + if _, err := server.DispatchRaw("/SaveUserPosition", []byte(body), cookie); err != nil { + t.Fatal(err) + } + saved, found := server.ProgressForTest().Position() + if !found || saved.PackID != 21 || saved.Position.MapID != 211 { + t.Fatalf("position not stored: %+v", saved) + } + quest := wire.AppendVarint(nil, 1, 999002) + quest = wire.AppendVarint(quest, 2, 12) + quest = wire.AppendVarint(quest, 3, 21) + quest = wire.AppendBytes(quest, 4, []byte{121}) + body, _ = cryptox.EncryptBase64Payload(quest, server.KeyForTest()) + if _, err := server.DispatchRaw("/QuestUpdate", []byte(body), cookie); err != nil { + t.Fatal(err) + } + if stored, ok := server.ProgressForTest().Quest(12); !ok || stored.PackID != 21 { + t.Fatalf("quest not stored: %+v/%v", stored, ok) + } +} diff --git a/go/internal/statebridge/check.go b/go/internal/statebridge/check.go new file mode 100644 index 0000000..a976cb3 --- /dev/null +++ b/go/internal/statebridge/check.go @@ -0,0 +1,145 @@ +package statebridge + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "os/exec" + "time" + + controlv1 "bd2server/gen/state/control/v1" + "google.golang.org/protobuf/proto" +) + +const ( + BridgeAPIVersion = 1 + MaxFrameBytes = 16 << 20 +) + +type CheckResult struct { + SourceSHA256 [32]byte + Violations []*controlv1.Violation +} + +type boundedOutput struct { + bytes.Buffer + limit int + overflow bool +} + +func (b *boundedOutput) Write(data []byte) (int, error) { + if len(data) > b.limit-b.Len() { + b.overflow = true + return 0, errors.New("statebridge: tool output exceeded limit") + } + return b.Buffer.Write(data) +} + +// Check loads the complete state snapshot and invokes the pure Haskell +// validator. The tool receives no state path and therefore cannot modify it. +func Check(ctx context.Context, stateDir, toolPath string) (CheckResult, error) { + snapshot, sourceHash, err := LoadSnapshot(stateDir) + if err != nil { + return CheckResult{}, err + } + request := &controlv1.CheckRequest{BridgeApiVersion: BridgeAPIVersion, SourceSha256: sourceHash[:], Snapshot: snapshot} + payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(request) + if err != nil { + return CheckResult{}, fmt.Errorf("statebridge: encode request: %w", err) + } + responsePayload, err := runTool(ctx, toolPath, nil, payload) + if err != nil { + return CheckResult{}, err + } + var response controlv1.CheckResponse + if err := proto.Unmarshal(responsePayload, &response); err != nil { + return CheckResult{}, fmt.Errorf("statebridge: decode response: %w", err) + } + if response.BridgeApiVersion != BridgeAPIVersion { + return CheckResult{}, fmt.Errorf("statebridge: validator API version %d", response.BridgeApiVersion) + } + if !bytes.Equal(response.SourceSha256, sourceHash[:]) { + return CheckResult{}, errors.New("statebridge: validator returned the wrong source hash") + } + if err := validateViolations(response.Violations); err != nil { + return CheckResult{}, err + } + return CheckResult{SourceSHA256: sourceHash, Violations: response.Violations}, nil +} + +func runTool(ctx context.Context, toolPath string, args []string, payload []byte) ([]byte, error) { + frame, err := encodeFrame(payload) + if err != nil { + return nil, err + } + if toolPath == "" { + return nil, errors.New("statebridge: empty validator tool path") + } + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 30*time.Second) + defer cancel() + } + command := exec.CommandContext(ctx, toolPath, args...) + command.Stdin = bytes.NewReader(frame) + stdout := &boundedOutput{limit: MaxFrameBytes + binary.MaxVarintLen64} + stderr := &boundedOutput{limit: 64 << 10} + command.Stdout, command.Stderr = stdout, stderr + runErr := command.Run() + if stdout.overflow || stderr.overflow { + return nil, errors.New("statebridge: validator output exceeded limit") + } + if runErr != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("statebridge: validator timeout: %w", ctx.Err()) + } + return nil, fmt.Errorf("statebridge: validator failed: %w: %s", runErr, stderr.String()) + } + responsePayload, err := decodeFrame(stdout.Bytes()) + if err != nil { + return nil, fmt.Errorf("statebridge: decode response frame: %w", err) + } + return responsePayload, nil +} + +func validateViolations(violations []*controlv1.Violation) error { + for _, problem := range violations { + if problem == nil || problem.Code == "" || (problem.Severity != controlv1.Severity_SEVERITY_WARNING && problem.Severity != controlv1.Severity_SEVERITY_ERROR) { + return errors.New("statebridge: validator returned an invalid violation") + } + } + return nil +} + +func encodeFrame(payload []byte) ([]byte, error) { + if len(payload) > MaxFrameBytes { + return nil, fmt.Errorf("statebridge: frame length %d exceeds %d", len(payload), MaxFrameBytes) + } + frame := binary.AppendUvarint(nil, uint64(len(payload))) + return append(frame, payload...), nil +} + +func decodeFrame(frame []byte) ([]byte, error) { + reader := bufio.NewReader(bytes.NewReader(frame)) + size, err := binary.ReadUvarint(reader) + if err != nil { + return nil, err + } + if size > MaxFrameBytes { + return nil, fmt.Errorf("frame length %d exceeds %d", size, MaxFrameBytes) + } + payload := make([]byte, int(size)) + if _, err := io.ReadFull(reader, payload); err != nil { + return nil, err + } + if extra, err := reader.ReadByte(); err == nil { + return nil, fmt.Errorf("trailing response byte 0x%02x", extra) + } else if !errors.Is(err, io.EOF) { + return nil, err + } + return payload, nil +} diff --git a/go/internal/statebridge/check_test.go b/go/internal/statebridge/check_test.go new file mode 100644 index 0000000..bbd00c4 --- /dev/null +++ b/go/internal/statebridge/check_test.go @@ -0,0 +1,193 @@ +package statebridge + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + statev1 "bd2server/gen/state/v1" + statev2 "bd2server/gen/state/v2" +) + +func TestFrameRoundTrip(t *testing.T) { + for _, payload := range [][]byte{nil, {1}, bytes.Repeat([]byte{0x7f}, 4096)} { + frame, err := encodeFrame(payload) + if err != nil { + t.Fatal(err) + } + got, err := decodeFrame(frame) + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("round trip len=%d got=%d err=%v", len(payload), len(got), err) + } + } +} + +func TestLoadCurrentWorkspaceSnapshot(t *testing.T) { + stateDir := filepath.Join("..", "..", "..", "data", "state") + if _, err := os.Stat(stateDir); err != nil { + t.Skipf("workspace state unavailable: %v", err) + } + snapshot, hash, err := LoadSnapshot(stateDir) + if err != nil { + t.Fatal(err) + } + if snapshot.FormatVersion != 1 || snapshot.ClientVersion != ClientVersion || snapshot.Progress == nil { + t.Fatalf("unexpected snapshot header: %+v", snapshot) + } + if snapshot.Progress.SourceFormatVersion != 2 || snapshot.Inventory == nil || snapshot.Collection == nil || snapshot.Wallet == nil { + t.Fatalf("missing or unsupported persisted state: progress=%+v inventory=%v collection=%v wallet=%v", snapshot.Progress, snapshot.Inventory, snapshot.Collection, snapshot.Wallet) + } + if hash == ([32]byte{}) { + t.Fatal("source hash is empty") + } +} + +func TestFrameRejectsTrailingAndOversize(t *testing.T) { + frame, _ := encodeFrame([]byte{1, 2, 3}) + if _, err := decodeFrame(append(frame, 4)); err == nil { + t.Fatal("accepted trailing response data") + } + if _, err := encodeFrame(make([]byte, MaxFrameBytes+1)); err == nil { + t.Fatal("accepted oversized request") + } +} + +func TestToolOutputLimit(t *testing.T) { + output := &boundedOutput{limit: 3} + if _, err := output.Write([]byte{1, 2, 3}); err != nil { + t.Fatal(err) + } + if _, err := output.Write([]byte{4}); err == nil || !output.overflow || output.Len() != 3 { + t.Fatalf("unbounded output: err=%v overflow=%v len=%d", err, output.overflow, output.Len()) + } +} + +func TestQuestKeysCannotDiscardDamagedSourceIdentity(t *testing.T) { + for _, test := range []struct { + name string + progress progressDisk + want string + }{ + {"wrong version", progressDisk{Version: 1}, "expected progress format 2"}, + {"mismatched key", progressDisk{Version: 2, Quests: map[string]struct { + QuestID uint32 `json:"QuestID"` + PackID uint32 `json:"PackID"` + Values []uint32 `json:"Values"` + }{"21:1": {QuestID: 1, PackID: 22}}}, "does not match"}, + {"false cleared marker", progressDisk{Version: 2, Cleared: map[string]bool{"21:1": false}}, "must be true"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := progressProto(test.progress); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("progressProto error=%v, want substring %q", err, test.want) + } + }) + } +} + +func TestVerifyV2RejectsRewrittenWalletAndCharacter(t *testing.T) { + source := &statev1.Snapshot{ + FormatVersion: 1, ClientVersion: ClientVersion, GameDataVersion: GameDataVersion, + Wallet: &statev1.Wallet{Gold: 100}, + Characters: &statev1.CharacterState{Characters: []*statev1.Character{{InventoryIndex: 123, Id: 1}}}, + Collection: &statev1.Collection{NextCharacterIndex: 1000}, + } + valid := func() *statev2.Snapshot { + return &statev2.Snapshot{ + FormatVersion: 2, ClientVersion: ClientVersion, GameDataVersion: GameDataVersion, + Wallet: &statev1.Wallet{Gold: 100}, + Roster: &statev2.CharacterRoster{NextAcquiredCharacterIndex: 1000, Characters: []*statev2.CharacterRecord{ + {Origin: statev2.CharacterOrigin_CHARACTER_ORIGIN_BASE, Character: &statev1.Character{InventoryIndex: 123, Id: 1}}, + }}, + Collection: &statev2.CollectionLedger{}, + } + } + if err := verifyV2(source, valid()); err != nil { + t.Fatalf("valid projection rejected: %v", err) + } + alteredWallet := valid() + alteredWallet.Wallet.Gold++ + if err := verifyV2(source, alteredWallet); err == nil { + t.Fatal("accepted migrated wallet rewrite") + } + alteredCharacter := valid() + alteredCharacter.Roster.Characters[0].Character.InventoryIndex++ + if err := verifyV2(source, alteredCharacter); err == nil { + t.Fatal("accepted migrated character index rewrite") + } +} + +func TestRecoverInterruptedInstallRestoresWholeGeneration(t *testing.T) { + source := filepath.Join("..", "..", "..", "data", "state") + if complete, err := CompleteStateAvailable(source); err != nil || !complete { + t.Skipf("workspace state unavailable: complete=%v err=%v", complete, err) + } + dir := t.TempDir() + tx := filepath.Join(dir, repairTransactionDir) + backup := filepath.Join(tx, "backup") + if err := os.MkdirAll(backup, 0o700); err != nil { + t.Fatal(err) + } + original := make(map[string][]byte, len(stateFiles)) + for _, name := range stateFiles { + data, err := os.ReadFile(filepath.Join(source, name)) + if err != nil { + t.Fatal(err) + } + original[name] = data + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backup, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + _, backupHash, err := LoadSnapshot(backup) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tx, "prepared"), backupHash[:], 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, stateFiles[0]), []byte("partial generation"), 0o600); err != nil { + t.Fatal(err) + } + recovered, err := RecoverInterruptedInstall(dir) + if err != nil || !recovered { + t.Fatalf("recover: recovered=%v err=%v", recovered, err) + } + for _, name := range stateFiles { + got, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil || !bytes.Equal(got, original[name]) { + t.Fatalf("restored %s: equal=%v err=%v", name, bytes.Equal(got, original[name]), err) + } + } + if _, err := os.Stat(tx); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("transaction directory remains: %v", err) + } +} + +func TestCompleteStateRejectsPartiallyInitializedAccount(t *testing.T) { + dir := t.TempDir() + complete, err := CompleteStateAvailable(dir) + if err != nil || complete { + t.Fatalf("empty account: complete=%v err=%v", complete, err) + } + if err := os.WriteFile(filepath.Join(dir, stateFiles[0]), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := CompleteStateAvailable(dir); err == nil { + t.Fatal("partially initialized account was treated as fresh") + } +} + +func TestProjectionRefusesUnknownJSONFields(t *testing.T) { + if err := originalKeysRepresented([]byte(`{"wallet":{"gold":2,"future_field":7}}`), []byte(`{"wallet":{"gold":2}}`)); err == nil { + t.Fatal("projection silently dropped an unknown persisted field") + } + if err := originalKeysRepresented([]byte(`{"wallet":{"gold":2}}`), []byte(`{"wallet":{"gold":3,"mileage":0}}`)); err != nil { + t.Fatalf("valid projection rejected: %v", err) + } +} diff --git a/go/internal/statebridge/install.go b/go/internal/statebridge/install.go new file mode 100644 index 0000000..00298e7 --- /dev/null +++ b/go/internal/statebridge/install.go @@ -0,0 +1,454 @@ +package statebridge + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + statev1 "bd2server/gen/state/v1" + statev2 "bd2server/gen/state/v2" +) + +const repairTransactionDir = ".statebridge-repair" + +// RecoverInterruptedInstall restores all nine files from a prepared backup. +// It is safe to call before every load. A successful install removes the +// transaction directory, so the common path is read-only. +func RecoverInterruptedInstall(stateDir string) (bool, error) { + stateDir = filepath.Clean(stateDir) + tx := filepath.Join(stateDir, repairTransactionDir) + if _, err := os.Stat(tx); errors.Is(err, os.ErrNotExist) { + return false, nil + } else if err != nil { + return false, err + } + backup := filepath.Join(tx, "backup") + expectedHash, err := os.ReadFile(filepath.Join(tx, "prepared")) + if err != nil { + return false, fmt.Errorf("statebridge: incomplete repair transaction without prepared marker: %w", err) + } + _, backupHash, err := LoadSnapshot(backup) + if err != nil { + return false, fmt.Errorf("statebridge: repair backup unreadable: %w", err) + } + if !bytes.Equal(expectedHash, backupHash[:]) { + return false, errors.New("statebridge: repair backup hash mismatch; refusing restore") + } + for _, name := range stateFiles { + data, err := os.ReadFile(filepath.Join(backup, name)) + if err != nil { + return false, fmt.Errorf("statebridge: recover backup %s: %w", name, err) + } + if err := writeAtomic(filepath.Join(stateDir, name), data); err != nil { + return false, fmt.Errorf("statebridge: restore %s: %w", name, err) + } + } + if err := os.RemoveAll(tx); err != nil { + return false, err + } + return true, nil +} + +// InstallRepair is the only write boundary for Haskell output. It prepares +// and fsyncs a complete generation before replacing any live file. If the +// process dies during replacement, RecoverInterruptedInstall rolls all files +// back on the next start. +func InstallRepair(stateDir string, result RepairResult) error { + if !result.Changed { + return nil + } + if result.Target == nil { + return errors.New("statebridge: cannot install an empty repair target") + } + stateDir = filepath.Clean(stateDir) + if recovered, err := RecoverInterruptedInstall(stateDir); err != nil { + return err + } else if recovered { + return errors.New("statebridge: recovered an interrupted repair; rerun validation before installing") + } + _, currentHash, err := LoadSnapshot(stateDir) + if err != nil { + return err + } + if currentHash != result.SourceSHA256 { + return errors.New("statebridge: state changed after repair calculation; refusing stale install") + } + files, err := projectV2JSON(result.Target) + if err != nil { + return err + } + tx := filepath.Join(stateDir, repairTransactionDir) + backup, candidate := filepath.Join(tx, "backup"), filepath.Join(tx, "candidate") + if err := os.MkdirAll(backup, 0o700); err != nil { + return err + } + if err := os.MkdirAll(candidate, 0o700); err != nil { + return err + } + cleanup := true + defer func() { + if cleanup { + _ = os.RemoveAll(tx) + } + }() + for _, name := range stateFiles { + original, err := os.ReadFile(filepath.Join(stateDir, name)) + if err != nil { + return err + } + if err := originalKeysRepresented(original, files[name]); err != nil { + return fmt.Errorf("statebridge: cannot safely project %s: %w", name, err) + } + if err := writeSynced(filepath.Join(backup, name), original); err != nil { + return err + } + if err := writeSynced(filepath.Join(candidate, name), files[name]); err != nil { + return err + } + } + if _, _, err := LoadSnapshot(candidate); err != nil { + return fmt.Errorf("statebridge: generated candidate is not loadable: %w", err) + } + if err := writeSynced(filepath.Join(tx, "prepared"), []byte(result.SourceSHA256[:])); err != nil { + return err + } + cleanup = false // from this point recovery must retain the backups on error + for _, name := range stateFiles { + data, err := os.ReadFile(filepath.Join(candidate, name)) + if err != nil { + return err + } + if err := writeAtomic(filepath.Join(stateDir, name), data); err != nil { + return err + } + } + if err := os.RemoveAll(tx); err != nil { + return err + } + return nil +} + +// Reject an install if the JSON compatibility adapter would silently drop +// an unrecognized field. Arrays can intentionally shrink during repair; the +// overlapping entries still must preserve their object field structure. +func originalKeysRepresented(original, projected []byte) error { + var oldValue, newValue any + if err := json.Unmarshal(original, &oldValue); err != nil { + return err + } + if err := json.Unmarshal(projected, &newValue); err != nil { + return err + } + return requireObjectKeys(oldValue, newValue, "$") +} + +func requireObjectKeys(oldValue, newValue any, path string) error { + switch old := oldValue.(type) { + case map[string]any: + current, ok := newValue.(map[string]any) + if !ok { + return fmt.Errorf("%s changed object type", path) + } + for key, prior := range old { + next, found := current[key] + if !found { + return fmt.Errorf("%s.%s is missing from projection", path, key) + } + if err := requireObjectKeys(prior, next, path+"."+key); err != nil { + return err + } + } + case []any: + current, ok := newValue.([]any) + if !ok { + return fmt.Errorf("%s changed array type", path) + } + for index := 0; index < len(old) && index < len(current); index++ { + if err := requireObjectKeys(old[index], current[index], fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + } + return nil +} + +func writeSynced(path string, data []byte) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + return err +} + +func writeAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + file, err := os.CreateTemp(dir, ".statebridge-*.tmp") + if err != nil { + return err + } + temp := file.Name() + defer os.Remove(temp) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return replaceFile(temp, path) +} + +func projectV2JSON(snapshot *statev2.Snapshot) (map[string][]byte, error) { + if snapshot == nil || snapshot.Roster == nil || snapshot.Collection == nil { + return nil, errors.New("statebridge: incomplete V2 snapshot") + } + base, acquired := splitCharacters(snapshot.Roster.Characters) + files := map[string]any{ + "progress.json": progressDiskFromProto(snapshot.Progress), + "deck.json": deckDiskFromProto(snapshot.Deck), + "items.json": inventoryDiskFromProto(snapshot.Inventory), + "equipment.json": equipmentDiskFromProto(snapshot.Equipment), + "characters.json": charactersDisk{Version: snapshot.ClientVersion, Characters: base}, + "collection.json": collectionDiskFromProto(snapshot, acquired), + "wallet.json": walletDiskFromProto(snapshot.Wallet), + "mail.json": mailDisk{Version: snapshot.ClientVersion, Opened: append([]uint64(nil), snapshot.Mail.OpenedMailIds...)}, + "missions.json": missionsDiskFromProto(snapshot.Missions), + } + encoded := make(map[string][]byte, len(files)) + for _, name := range stateFiles { + data, err := json.MarshalIndent(files[name], "", " ") + if err != nil { + return nil, fmt.Errorf("statebridge: encode %s: %w", name, err) + } + encoded[name] = append(data, '\n') + } + return encoded, nil +} + +func progressDiskFromProto(in *statev1.Progress) progressDisk { + var out progressDisk + out.Version = in.SourceFormatVersion + if in.Position != nil { + out.Position.PackID, out.Position.Position.MapID = in.Position.PackId, in.Position.MapId + if in.Position.Player != nil { + out.Position.Position.PlayerPosition.X, out.Position.Position.PlayerPosition.Y, out.Position.Position.PlayerPosition.Z = in.Position.Player.X, in.Position.Player.Y, in.Position.Player.Z + } + out.Position.RawJSON = string(in.Position.OriginalJson) + out.Position.Position.ColleaguePositions = append(json.RawMessage(nil), in.Position.ColleaguePositionsJson...) + } + out.Tutorials = append([]uint32(nil), in.ClearedTutorialIds...) + out.Quests = make(map[string]struct { + QuestID uint32 `json:"QuestID"` + PackID uint32 `json:"PackID"` + Values []uint32 `json:"Values"` + }, len(in.Quests)) + for _, quest := range in.Quests { + key := fmt.Sprintf("%d:%d", quest.Key.PackId, quest.Key.QuestId) + out.Quests[key] = struct { + QuestID uint32 `json:"QuestID"` + PackID uint32 `json:"PackID"` + Values []uint32 `json:"Values"` + }{quest.Key.QuestId, quest.Key.PackId, append([]uint32(nil), quest.Values...)} + } + out.Cleared = make(map[string]bool, len(in.ClearedQuests)) + for _, key := range in.ClearedQuests { + out.Cleared[fmt.Sprintf("%d:%d", key.PackId, key.QuestId)] = true + } + return out +} + +func deckDiskFromProto(in *statev1.Deck) deckDisk { + return deckDisk{Version: in.ClientVersion, Deck: slotsFromProto(in.Battle), FieldDeck: slotsFromProto(in.Field), FieldControl: in.FieldControlType, + Waypoints: pairsFromProto(in.Waypoints), Costumes: pairsFromProto(in.SelectedCostumes), Packs: pairsFromProto(in.BoughtPacks), HighestPower: in.HighestBattlePower, + PortraitCostume: in.PortraitCostumeId, AutoRevive: in.AutoReviveCatalyst} +} + +func slotsFromProto(in []*statev1.DeckSlot) []deckSlot { + out := make([]deckSlot, 0, len(in)) + for _, slot := range in { + out = append(out, deckSlot{Character: slot.CharacterIndex, Costume: slot.CostumeIndex, Slot: slot.Slot}) + } + return out +} + +func pairsFromProto(in []*statev1.Pair) map[uint64]uint64 { + out := make(map[uint64]uint64, len(in)) + for _, pair := range in { + out[pair.Key] = pair.Value + } + return out +} + +func inventoryDiskFromProto(in *statev1.Inventory) inventoryDisk { + out := inventoryDisk{Version: in.ClientVersion, NextIndex: in.NextIndex, Granted: make(map[string]bool), GrantItems: make(map[string][]uint64)} + for _, entry := range in.Items { + out.Items = append(out.Items, itemFromProto(entry)) + } + for _, identity := range in.GrantedIdentities { + out.Granted[identity] = true + } + for _, grant := range in.GrantItems { + out.GrantItems[grant.Identity] = append([]uint64(nil), grant.InventoryIndices...) + } + return out +} + +func itemFromProto(in *statev1.Item) item { + out := item{InvenIndex: in.InventoryIndex, ID: in.Id, Type: in.Type, Count: in.Count, KeepFlag: in.KeepFlag, TimeValue: in.TimeValue, SortID: in.SortId, UseCount: in.UseCount} + if in.Pictorial != nil { + out.Pictorial = &pictorial{ID: in.Pictorial.Id, GroupID: in.Pictorial.GroupId} + } + return out +} + +func equipmentDiskFromProto(in *statev1.EquipmentInventory) equipmentDisk { + out := equipmentDisk{Version: in.ClientVersion, NextIndex: in.NextIndex, Granted: make(map[string]uint64)} + for _, entry := range in.Equipment { + out.Equipment = append(out.Equipment, equipmentFromProto(entry)) + } + for _, grant := range in.Grants { + out.Granted[grant.Identity] = grant.InventoryIndex + } + return out +} + +func equipmentFromProto(in *statev1.Equipment) equipment { + out := equipment{InvenIndex: in.InventoryIndex, ID: in.Id, Level: in.Level, UseChar: in.UseChar, KeepFlag: in.KeepFlag, LockFlag: in.LockFlag, SortID: in.SortId, Rank: append([]uint64(nil), in.Ranks...)} + for _, option := range in.MainOptions { + out.MainOption = append(out.MainOption, equipmentOption{GroupID: option.GroupId, ID: option.Id}) + } + for _, option := range in.SubOptions { + out.SubOption = append(out.SubOption, equipmentOption{GroupID: option.GroupId, ID: option.Id}) + } + if in.PrivateOption != nil { + out.PrivateOption = &equipmentOption{GroupID: in.PrivateOption.GroupId, ID: in.PrivateOption.Id} + } + return out +} + +func splitCharacters(records []*statev2.CharacterRecord) ([]character, []character) { + var base, acquired []character + for _, record := range records { + entry := characterFromProto(record.Character) + if record.Origin == statev2.CharacterOrigin_CHARACTER_ORIGIN_BASE { + base = append(base, entry) + } else { + acquired = append(acquired, entry) + } + } + return base, acquired +} + +func characterFromProto(in *statev1.Character) character { + out := character{InvenIndex: in.InventoryIndex, ID: in.Id, HP: in.Hp, Level: in.Level, CostumeID: in.CostumeId, Exp: in.Exp, UseCostume: in.UseCostume, + TalentLevel: in.TalentLevel, TalentExp: in.TalentExp, SolidarityReward: in.SolidarityReward, ExpiryTime: in.ExpiryTime, ConnectPotentialCostume: in.ConnectPotentialCostume} + for _, book := range in.Pictorial { + out.Pictorial = append(out.Pictorial, pictorial{ID: book.Id, GroupID: book.GroupId}) + } + return out +} + +func costumeFromProto(in *statev1.Costume) costume { + out := costume{InvenIndex: in.InventoryIndex, ID: in.Id, Level: in.Level, UseChar: in.UseChar, SortID: in.SortId, PotentialID: in.PotentialId, DesignID: in.DesignId, BurstLevel: in.BurstLevel, TimeValue: in.TimeValue} + for _, book := range in.Pictorial { + out.Pictorial = append(out.Pictorial, pictorial{ID: book.Id, GroupID: book.GroupId}) + } + return out +} + +func collectionDiskFromProto(snapshot *statev2.Snapshot, acquired []character) collectionDisk { + in, roster := snapshot.Collection, snapshot.Roster + out := collectionDisk{Version: snapshot.ClientVersion, NextCharacterIndex: roster.NextAcquiredCharacterIndex, NextCostumeIndex: roster.NextCostumeIndex, + LatestPreview: append([]uint64(nil), in.LatestPreview...), PreviewEventIndex: in.PreviewEventIndex, PreviewLocked: in.PreviewLocked, Characters: acquired, + BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]gachaSelection{}, StepUpProgress: map[string]uint64{}, GachaUsers: map[string]gachaUser{}, + GachaFixed: map[string]gachaFixed{}, GachaApplied: map[string]bool{}, GachaPointExchanges: map[string]pointExchange{}, Grants: map[string]collectionGrant{}, GachaCountCorrected: in.GachaCountCorrected} + for _, entry := range roster.Costumes { + out.Costumes = append(out.Costumes, costumeFromProto(entry)) + } + for _, entry := range in.BaseCostumeLevels { + out.BaseCostumeLevels[entry.Identity] = entry.Value + } + for _, entry := range in.StepUpProgress { + out.StepUpProgress[entry.Identity] = entry.Value + } + for _, entry := range in.GachaApplied { + out.GachaApplied[entry] = true + } + for _, entry := range in.GachaSelections { + for _, selection := range entry.Selections { + out.GachaSelections[entry.Identity] = append(out.GachaSelections[entry.Identity], gachaSelection{GroupID: selection.GroupId, Slot: selection.Slot, ItemID: selection.ItemId}) + } + } + for _, entry := range in.GachaUsers { + user := entry.User + out.GachaUsers[entry.Identity] = gachaUser{GroupID: user.GroupId, Point: user.Point, TotalBuyCount: user.TotalBuyCount, OneFreePickCount: user.OneFreePickCount, OneCashPickCount: user.OneCashPickCount, TenFreePickCount: user.TenFreePickCount, TenCashPickCount: user.TenCashPickCount, ExchangeItemCount: user.ExchangeItemCount, ExchangeMileageCount: user.ExchangeMileageCount} + } + for _, entry := range in.GachaFixed { + fixed := entry.Fixed + out.GachaFixed[entry.Identity] = gachaFixed{FixedID: fixed.FixedId, Type: fixed.Type, Count: fixed.Count, ApplySort: fixed.ApplySortId} + } + for _, entry := range in.GachaPointExchanges { + exchange := entry.Exchange + out.GachaPointExchanges[entry.Identity] = pointExchange{GroupID: exchange.GroupId, Count: exchange.Count} + } + for _, entry := range in.Grants { + out.Grants[entry.Identity] = collectionGrantFromProto(entry) + } + return out +} + +func collectionGrantFromProto(in *statev1.CollectionGrant) collectionGrant { + out := collectionGrant{CharacterIndices: append([]uint64(nil), in.CharacterIndices...), CostumeIndices: append([]uint64(nil), in.CostumeIndices...), ViewCostumeIDs: append([]uint64(nil), in.ViewCostumeIds...), GachaGroupID: in.GachaGroupId, GachaPoint: in.GachaPoint, SelectionApplySortIDs: append([]uint64(nil), in.SelectionApplySortIds...)} + for _, entry := range in.Upgrades { + out.Upgrades = append(out.Upgrades, costumeUpgrade{InvenIndex: entry.InventoryIndex, CostumeID: entry.CostumeId, Before: entry.Before, After: entry.After, SortID: entry.SortId}) + } + for _, entry := range in.Exchanges { + out.Exchanges = append(out.Exchanges, costumeExchange{InvenIndex: entry.InventoryIndex, OriginalItemType: entry.OriginalItemType, OriginalItemID: entry.OriginalItemId, OriginalCount: entry.OriginalCount, ExchangeItemType: entry.ExchangeItemType, ExchangeItemID: entry.ExchangeItemId, ExchangeCount: entry.ExchangeCount, SortID: entry.SortId}) + } + for _, entry := range in.GachaFixed { + out.GachaFixed = append(out.GachaFixed, gachaFixed{FixedID: entry.FixedId, Type: entry.Type, Count: entry.Count, ApplySort: entry.ApplySortId}) + } + return out +} + +func walletDiskFromProto(in *statev1.Wallet) walletDisk { + out := walletDisk{Version: in.ClientVersion, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, Granted: map[string]bool{}, Spent: map[string]bool{}} + for _, value := range in.GrantedIdentities { + out.Granted[value] = true + } + for _, value := range in.SpentIdentities { + out.Spent[value] = true + } + return out +} + +func missionsDiskFromProto(in *statev1.MissionState) missionsDisk { + out := missionsDisk{Version: in.ClientVersion, Completed: append([]string(nil), in.Completed...), Claimed: append([]string(nil), in.Claimed...), Progress: map[string]uint64{}} + for _, entry := range in.Progress { + out.Progress[entry.Identity] = entry.Value + } + return out +} + +// stableJSONKeys is retained as an assertion helper for tests and documents +// that all map-backed JSON domains are emitted deterministically by encoding/json. +func stableJSONKeys(values map[string][]byte) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/go/internal/statebridge/integration_test.go b/go/internal/statebridge/integration_test.go new file mode 100644 index 0000000..c314008 --- /dev/null +++ b/go/internal/statebridge/integration_test.go @@ -0,0 +1,354 @@ +package statebridge + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + controlv1 "bd2server/gen/state/control/v1" + controlv2 "bd2server/gen/state/control/v2" + statev1 "bd2server/gen/state/v1" + statev2 "bd2server/gen/state/v2" + "bd2server/internal/gacha" + "bd2server/internal/gamedata" + "bd2server/internal/player" + "google.golang.org/protobuf/proto" +) + +// BD2_STATE_TOOL enables an explicit cross-language test without making +// ordinary Go tests depend on a locally compiled Haskell executable. +func TestHaskellCheckRejectsCorruptCopyWithoutEditingSource(t *testing.T) { + tool := os.Getenv("BD2_STATE_TOOL") + if tool == "" { + t.Skip("set BD2_STATE_TOOL to the cabal-built bd2-state.exe") + } + source := filepath.Join("..", "..", "..", "data", "state") + _, originalHash, err := LoadSnapshot(source) + if err != nil { + t.Skipf("workspace save not available: %v", err) + } + copyDir := t.TempDir() + for _, name := range stateFiles { + data, err := os.ReadFile(filepath.Join(source, name)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(copyDir, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + check, err := Check(context.Background(), copyDir, tool) + if err != nil || len(check.Violations) != 0 || check.SourceSHA256 != originalHash { + t.Fatalf("valid cross-language snapshot: violations=%+v err=%v hash=%x", check.Violations, err, check.SourceSHA256) + } + path := filepath.Join(copyDir, "items.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var inventory map[string]json.RawMessage + if err := json.Unmarshal(data, &inventory); err != nil { + t.Fatal(err) + } + inventory["next_index"] = json.RawMessage("1") + corrupt, err := json.Marshal(inventory) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, corrupt, 0o600); err != nil { + t.Fatal(err) + } + check, err = Check(context.Background(), copyDir, tool) + if err != nil { + t.Fatal(err) + } + found := false + for _, problem := range check.Violations { + if problem.Code == "inventory.next_index" && problem.Severity == controlv1.Severity_SEVERITY_ERROR { + found = true + } + } + if !found { + t.Fatalf("corrupt copy did not report inventory.next_index: %+v", check.Violations) + } + // Equipment grants have stronger retry semantics than consumed item grants: + // a recorded equipment index must still resolve to an owned instance. + if err := os.WriteFile(path, mustReadStateFile(t, source, "items.json"), 0o600); err != nil { + t.Fatal(err) + } + equipmentPath := filepath.Join(copyDir, "equipment.json") + var equipment map[string]json.RawMessage + if err := json.Unmarshal(mustReadStateFile(t, equipmentPath, ""), &equipment); err != nil { + t.Fatal(err) + } + equipment["granted"] = json.RawMessage(`{"test:missing":999999999}`) + corrupt, err = json.Marshal(equipment) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(equipmentPath, corrupt, 0o600); err != nil { + t.Fatal(err) + } + check, err = Check(context.Background(), copyDir, tool) + if err != nil { + t.Fatal(err) + } + found = false + for _, problem := range check.Violations { + if problem.Code == "equipment.grant_missing_equipment" && problem.Severity == controlv1.Severity_SEVERITY_ERROR { + found = true + } + } + if !found { + t.Fatalf("corrupt copy did not report missing equipment grant: %+v", check.Violations) + } + _, afterHash, err := LoadSnapshot(source) + if err != nil || afterHash != originalHash { + t.Fatalf("check modified the actual state: before=%x after=%x err=%v", originalHash, afterHash, err) + } +} + +func TestHaskellMigrationPreservesSourceAndRejectsDuplicateCharacter(t *testing.T) { + tool := os.Getenv("BD2_STATE_TOOL") + if tool == "" { + t.Skip("set BD2_STATE_TOOL to the cabal-built bd2-state.exe") + } + source := filepath.Join("..", "..", "..", "data", "state") + originalSnapshot, originalHash, err := LoadSnapshot(source) + if err != nil { + t.Skipf("workspace save not available: %v", err) + } + copyDir := t.TempDir() + for _, name := range stateFiles { + if err := os.WriteFile(filepath.Join(copyDir, name), mustReadStateFile(t, source, name), 0o600); err != nil { + t.Fatal(err) + } + } + result, err := MigrateV1ToV2(context.Background(), copyDir, tool) + if err != nil || result.Target == nil || hasErrorViolation(result.Violations) { + t.Fatalf("valid V1 migration: target=%v violations=%+v err=%v", result.Target != nil, result.Violations, err) + } + if result.SourceSHA256 != originalHash || result.Target.FormatVersion != 2 || len(result.Target.Roster.Characters) == 0 { + t.Fatalf("unexpected migrated snapshot: source=%x target=%+v", result.SourceSHA256, result.Target) + } + projected, err := projectV2JSON(result.Target) + if err != nil { + t.Fatal(err) + } + projectionDir := t.TempDir() + for _, name := range stateFiles { + if err := os.WriteFile(filepath.Join(projectionDir, name), projected[name], 0o600); err != nil { + t.Fatal(err) + } + } + projectedSnapshot, _, err := LoadSnapshot(projectionDir) + if err != nil || !proto.Equal(originalSnapshot, projectedSnapshot) { + t.Fatalf("V2 JSON projection changed V1 semantics: err=%v", err) + } + repeated, err := MigrateV1ToV2(context.Background(), copyDir, tool) + if err != nil || repeated.Target == nil || repeated.TargetSHA256 != result.TargetSHA256 { + t.Fatalf("migration is not deterministic: first=%x repeated=%x err=%v", result.TargetSHA256, repeated.TargetSHA256, err) + } + _, copyHash, err := LoadSnapshot(copyDir) + if err != nil || copyHash != originalHash { + t.Fatalf("successful migration changed its input: before=%x after=%x err=%v", originalHash, copyHash, err) + } + + charactersPath := filepath.Join(copyDir, "characters.json") + var characters struct { + Version string `json:"version"` + Characters []json.RawMessage `json:"characters"` + } + if err := json.Unmarshal(mustReadStateFile(t, charactersPath, ""), &characters); err != nil { + t.Fatal(err) + } + if len(characters.Characters) == 0 { + t.Fatal("workspace fixture has no base character") + } + characters.Characters = append(characters.Characters, characters.Characters[0]) + damaged, err := json.Marshal(characters) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(charactersPath, damaged, 0o600); err != nil { + t.Fatal(err) + } + result, err = MigrateV1ToV2(context.Background(), copyDir, tool) + if err != nil { + t.Fatal(err) + } + if result.Target != nil || !hasViolation(result.Violations, "characters.duplicate_index") { + t.Fatalf("duplicate character migration was not rejected: target=%v violations=%+v", result.Target != nil, result.Violations) + } + _, afterHash, err := LoadSnapshot(source) + if err != nil || afterHash != originalHash { + t.Fatalf("migration modified actual state: before=%x after=%x err=%v", originalHash, afterHash, err) + } +} + +func hasViolation(violations []*controlv1.Violation, code string) bool { + for _, violation := range violations { + if violation.Code == code && violation.Severity == controlv1.Severity_SEVERITY_ERROR { + return true + } + } + return false +} + +// A small, hand-checked cross-language golden fixture independent of the +// workspace account. Go encodes V1 and compares the decoded Haskell V2 result +// against a separately constructed expected semantic snapshot. +func TestV1ToV2GoldenSemanticCorpus(t *testing.T) { + tool := os.Getenv("BD2_STATE_TOOL") + if tool == "" { + t.Skip("set BD2_STATE_TOOL to the cabal-built bd2-state.exe") + } + base := &statev1.Character{InventoryIndex: 535604118, Id: 6490, Level: 1} + acquired := &statev1.Character{InventoryIndex: 920000001, Id: 50, Level: 20} + costume := &statev1.Costume{InventoryIndex: 930000001, Id: 501, Level: 2, UseChar: acquired.InventoryIndex} + grant := &statev1.CollectionGrant{Identity: "golden:gacha", CharacterIndices: []uint64{acquired.InventoryIndex}, CostumeIndices: []uint64{costume.InventoryIndex}} + source := &statev1.Snapshot{ + FormatVersion: 1, ClientVersion: ClientVersion, GameDataVersion: GameDataVersion, + Progress: &statev1.Progress{SourceFormatVersion: 2}, Deck: &statev1.Deck{ClientVersion: ClientVersion}, + Inventory: &statev1.Inventory{ClientVersion: ClientVersion, NextIndex: 900000001}, + Equipment: &statev1.EquipmentInventory{ClientVersion: ClientVersion, NextIndex: 910000001}, + Characters: &statev1.CharacterState{ClientVersion: ClientVersion, Characters: []*statev1.Character{base}}, + Collection: &statev1.Collection{ClientVersion: ClientVersion, NextCharacterIndex: acquired.InventoryIndex + 1, + NextCostumeIndex: costume.InventoryIndex + 1, Characters: []*statev1.Character{acquired}, + Costumes: []*statev1.Costume{costume}, Grants: []*statev1.CollectionGrant{grant}}, + Wallet: &statev1.Wallet{ClientVersion: ClientVersion, Gold: 120, FreeJewelry: 42}, + Mail: &statev1.MailState{ClientVersion: ClientVersion}, Missions: &statev1.MissionState{ClientVersion: ClientVersion}, + } + var hash [32]byte + for i := range hash { + hash[i] = byte(i) + } + requestBytes, err := proto.Marshal(&controlv2.MigrateV1ToV2Request{BridgeApiVersion: MigrationBridgeAPIVersion, SourceSha256: hash[:], Source: source}) + if err != nil { + t.Fatal(err) + } + responseBytes, err := runTool(context.Background(), tool, []string{"migrate-v1-v2"}, requestBytes) + if err != nil { + t.Fatal(err) + } + var response controlv2.MigrateV1ToV2Response + if err := proto.Unmarshal(responseBytes, &response); err != nil { + t.Fatal(err) + } + if !response.Migrated || !bytes.Equal(response.SourceSha256, hash[:]) || len(response.Violations) != 0 { + t.Fatalf("golden migration rejected: %+v", &response) + } + expected := &statev2.Snapshot{ + FormatVersion: 2, ClientVersion: ClientVersion, GameDataVersion: GameDataVersion, + Progress: source.Progress, Deck: source.Deck, Inventory: source.Inventory, Equipment: source.Equipment, + Roster: &statev2.CharacterRoster{NextAcquiredCharacterIndex: acquired.InventoryIndex + 1, + NextCostumeIndex: costume.InventoryIndex + 1, + Characters: []*statev2.CharacterRecord{ + {Character: base, Origin: statev2.CharacterOrigin_CHARACTER_ORIGIN_BASE}, + {Character: acquired, Origin: statev2.CharacterOrigin_CHARACTER_ORIGIN_ACQUIRED}, + }, Costumes: []*statev1.Costume{costume}}, + Collection: &statev2.CollectionLedger{Grants: []*statev1.CollectionGrant{grant}}, + Wallet: source.Wallet, Mail: source.Mail, Missions: source.Missions, + } + if !proto.Equal(response.Target, expected) { + t.Fatalf("golden V2 semantic mismatch:\nreceived=%v\nexpected=%v", response.Target, expected) + } + if err := verifyV2(source, response.Target); err != nil { + t.Fatalf("golden target failed Go safety check: %v", err) + } +} + +func TestHaskellRepairInstallsStaleCursorAndBecomesIdempotent(t *testing.T) { + tool := os.Getenv("BD2_STATE_TOOL") + if tool == "" { + t.Skip("set BD2_STATE_TOOL to the cabal-built bd2-state.exe") + } + source := filepath.Join("..", "..", "..", "data", "state") + _, originalHash, err := LoadSnapshot(source) + if err != nil { + t.Skipf("workspace save unavailable: %v", err) + } + gameDataRoot := os.Getenv("BD2_TEST_GAMEDATA_DIR") + if gameDataRoot == "" { + t.Skip("set BD2_TEST_GAMEDATA_DIR to enable GameData-aware repair integration test") + } + const gameDataVersion = "20260910162539" + catalog, err := gamedata.LoadRegularCostumeGacha(gameDataRoot, gameDataVersion) + if err != nil { + t.Skipf("GameData unavailable: %v", err) + } + starter, err := player.Load(filepath.Join("..", "..", "seed", "v2_34_13", "starter_player.json")) + if err != nil { + t.Fatal(err) + } + groupID, gachaIDs := gacha.StepUpMigrationFact() + validation, err := BuildValidationContext(gameDataVersion, catalog, starter, groupID, gachaIDs) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + for _, name := range stateFiles { + if err := os.WriteFile(filepath.Join(dir, name), mustReadStateFile(t, source, name), 0o600); err != nil { + t.Fatal(err) + } + } + itemsPath := filepath.Join(dir, "items.json") + var inventory map[string]json.RawMessage + if err := json.Unmarshal(mustReadStateFile(t, itemsPath, ""), &inventory); err != nil { + t.Fatal(err) + } + inventory["next_index"] = json.RawMessage("1") + damaged, err := json.Marshal(inventory) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(itemsPath, damaged, 0o600); err != nil { + t.Fatal(err) + } + result, err := Repair(context.Background(), dir, tool, validation) + if err != nil || result.Target == nil || !result.Changed { + t.Fatalf("stale cursor repair: changed=%v target=%v violations=%+v err=%v", result.Changed, result.Target != nil, result.Violations, err) + } + // A concurrent request could write a newer save after the Haskell snapshot. + // Reject that stale result without changing any of the nine files. + if err := os.WriteFile(itemsPath, append(append([]byte(nil), damaged...), '\n'), 0o600); err != nil { + t.Fatal(err) + } + if err := InstallRepair(dir, result); err == nil { + t.Fatal("accepted a repair calculated from stale source bytes") + } + if err := os.WriteFile(itemsPath, damaged, 0o600); err != nil { + t.Fatal(err) + } + if err := InstallRepair(dir, result); err != nil { + t.Fatal(err) + } + verified, err := Repair(context.Background(), dir, tool, validation) + if err != nil || verified.Target == nil || verified.Changed { + t.Fatalf("installed repair not idempotent: changed=%v target=%v violations=%+v err=%v", verified.Changed, verified.Target != nil, verified.Violations, err) + } + if _, err := os.Stat(filepath.Join(dir, repairTransactionDir)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("repair transaction remains: %v", err) + } + _, afterHash, err := LoadSnapshot(source) + if err != nil || afterHash != originalHash { + t.Fatalf("repair test changed actual state: before=%x after=%x err=%v", originalHash, afterHash, err) + } +} + +func mustReadStateFile(t *testing.T, dir, name string) []byte { + t.Helper() + path := dir + if name != "" { + path = filepath.Join(dir, name) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/go/internal/statebridge/migrate.go b/go/internal/statebridge/migrate.go new file mode 100644 index 0000000..f882794 --- /dev/null +++ b/go/internal/statebridge/migrate.go @@ -0,0 +1,182 @@ +package statebridge + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "slices" + + controlv1 "bd2server/gen/state/control/v1" + controlv2 "bd2server/gen/state/control/v2" + statev1 "bd2server/gen/state/v1" + statev2 "bd2server/gen/state/v2" + "google.golang.org/protobuf/proto" +) + +const MigrationBridgeAPIVersion = 2 + +type MigrationResult struct { + SourceSHA256 [32]byte + TargetSHA256 [32]byte + Target *statev2.Snapshot + Violations []*controlv1.Violation +} + +// MigrateV1ToV2 performs an in-memory, read-only migration. The Haskell tool +// receives only protobuf bytes and cannot discover or write the state path. +func MigrateV1ToV2(ctx context.Context, stateDir, toolPath string) (MigrationResult, error) { + source, sourceHash, err := LoadSnapshot(stateDir) + if err != nil { + return MigrationResult{}, err + } + request := &controlv2.MigrateV1ToV2Request{ + BridgeApiVersion: MigrationBridgeAPIVersion, + SourceSha256: sourceHash[:], + Source: source, + } + payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(request) + if err != nil { + return MigrationResult{}, fmt.Errorf("statebridge: encode migration request: %w", err) + } + responsePayload, err := runTool(ctx, toolPath, []string{"migrate-v1-v2"}, payload) + if err != nil { + return MigrationResult{}, err + } + var response controlv2.MigrateV1ToV2Response + if err := proto.Unmarshal(responsePayload, &response); err != nil { + return MigrationResult{}, fmt.Errorf("statebridge: decode migration response: %w", err) + } + if response.BridgeApiVersion != MigrationBridgeAPIVersion { + return MigrationResult{}, fmt.Errorf("statebridge: migrator API version %d", response.BridgeApiVersion) + } + if !bytes.Equal(response.SourceSha256, sourceHash[:]) { + return MigrationResult{}, errors.New("statebridge: migrator returned the wrong source hash") + } + if err := validateViolations(response.Violations); err != nil { + return MigrationResult{}, err + } + result := MigrationResult{SourceSHA256: sourceHash, Violations: response.Violations} + if !response.Migrated { + if response.Target != nil { + return MigrationResult{}, errors.New("statebridge: rejected migration returned a target") + } + if !hasErrorViolation(response.Violations) { + return MigrationResult{}, errors.New("statebridge: rejected migration omitted an error violation") + } + return result, nil + } + if response.Target == nil { + return MigrationResult{}, errors.New("statebridge: accepted migration omitted target") + } + if hasErrorViolation(response.Violations) { + return MigrationResult{}, errors.New("statebridge: accepted migration returned errors") + } + if err := verifyV2(source, response.Target); err != nil { + return MigrationResult{}, err + } + targetBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(response.Target) + if err != nil { + return MigrationResult{}, fmt.Errorf("statebridge: encode migrated target: %w", err) + } + result.Target = response.Target + result.TargetSHA256 = sha256.Sum256(targetBytes) + return result, nil +} + +func hasErrorViolation(violations []*controlv1.Violation) bool { + for _, violation := range violations { + if violation.Severity == controlv1.Severity_SEVERITY_ERROR { + return true + } + } + return false +} + +func verifyV2(source *statev1.Snapshot, target *statev2.Snapshot) error { + if target.FormatVersion != 2 || target.ClientVersion != source.ClientVersion || target.GameDataVersion != source.GameDataVersion { + return errors.New("statebridge: migrated target metadata mismatch") + } + if target.Roster == nil || target.Collection == nil || source.Characters == nil || source.Collection == nil { + return errors.New("statebridge: migrated target omitted roster or collection") + } + unchanged := [][2]proto.Message{ + {source.Progress, target.Progress}, {source.Deck, target.Deck}, {source.Inventory, target.Inventory}, + {source.Equipment, target.Equipment}, {source.Wallet, target.Wallet}, {source.Mail, target.Mail}, + {source.Missions, target.Missions}, + } + for _, pair := range unchanged { + if !proto.Equal(pair[0], pair[1]) { + return errors.New("statebridge: migration changed an unchanged V1 domain") + } + } + baseCount := len(source.Characters.Characters) + acquiredCount := len(source.Collection.Characters) + if len(target.Roster.Characters) != baseCount+acquiredCount { + return errors.New("statebridge: migration changed character count") + } + for i, record := range target.Roster.Characters { + if record == nil || record.Character == nil { + return errors.New("statebridge: migration emitted an empty character record") + } + if i < baseCount { + if record.Origin != statev2.CharacterOrigin_CHARACTER_ORIGIN_BASE || !proto.Equal(record.Character, source.Characters.Characters[i]) { + return errors.New("statebridge: migration changed a base character") + } + } else if record.Origin != statev2.CharacterOrigin_CHARACTER_ORIGIN_ACQUIRED || !proto.Equal(record.Character, source.Collection.Characters[i-baseCount]) { + return errors.New("statebridge: migration changed an acquired character") + } + } + if target.Roster.NextAcquiredCharacterIndex != source.Collection.NextCharacterIndex || + target.Roster.NextCostumeIndex != source.Collection.NextCostumeIndex || + len(target.Roster.Costumes) != len(source.Collection.Costumes) { + return errors.New("statebridge: migration changed roster allocation state") + } + for i := range target.Roster.Costumes { + if !proto.Equal(target.Roster.Costumes[i], source.Collection.Costumes[i]) { + return errors.New("statebridge: migration changed a costume") + } + } + if !collectionLedgerEqual(source.Collection, target.Collection) { + return errors.New("statebridge: migration changed collection ledger state") + } + return nil +} + +func collectionLedgerEqual(source *statev1.Collection, target *statev2.CollectionLedger) bool { + return equalU64(source.LatestPreview, target.LatestPreview) && source.PreviewEventIndex == target.PreviewEventIndex && + source.PreviewLocked == target.PreviewLocked && protoSlicesEqual(source.BaseCostumeLevels, target.BaseCostumeLevels) && + protoSlicesEqual(source.GachaSelections, target.GachaSelections) && protoSlicesEqual(source.StepUpProgress, target.StepUpProgress) && + protoSlicesEqual(source.GachaUsers, target.GachaUsers) && protoSlicesEqual(source.GachaFixed, target.GachaFixed) && + equalStrings(source.GachaApplied, target.GachaApplied) && protoSlicesEqual(source.GachaPointExchanges, target.GachaPointExchanges) && + source.GachaCountCorrected == target.GachaCountCorrected && protoSlicesEqual(source.Grants, target.Grants) +} + +func protoSlicesEqual[T proto.Message](left, right []T) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if !proto.Equal(left[i], right[i]) { + return false + } + } + return true +} + +func equalU64(left, right []uint64) bool { + return slices.Equal(left, right) +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} diff --git a/go/internal/statebridge/repair.go b/go/internal/statebridge/repair.go new file mode 100644 index 0000000..b636685 --- /dev/null +++ b/go/internal/statebridge/repair.go @@ -0,0 +1,104 @@ +package statebridge + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + + controlv1 "bd2server/gen/state/control/v1" + controlv2 "bd2server/gen/state/control/v2" + statev2 "bd2server/gen/state/v2" + "bd2server/internal/gamedata" + "bd2server/internal/player" + "google.golang.org/protobuf/proto" +) + +type RepairResult struct { + SourceSHA256 [32]byte + TargetSHA256 [32]byte + Target *statev2.Snapshot + Changed bool + Violations []*controlv1.Violation +} + +func BuildValidationContext(gameDataVersion string, catalog *gamedata.RegularGachaCatalog, starter *player.Starter, stepGroupID uint64, stepGachaIDs []uint64) (*controlv2.ValidationContext, error) { + if gameDataVersion == "" || catalog == nil || starter == nil || stepGroupID == 0 || len(stepGachaIDs) == 0 { + return nil, errors.New("statebridge: incomplete validation context") + } + gachaFacts, costumeFacts := catalog.MigrationFacts() + context := &controlv2.ValidationContext{GameDataVersion: gameDataVersion} + for _, fact := range gachaFacts { + context.GachaGroups = append(context.GachaGroups, &controlv2.GachaGroupFact{ + GachaId: fact.GachaID, GroupId: fact.GroupID, FixedId: fact.FixedID, PointCount: fact.PointCount, + }) + } + for _, fact := range costumeFacts { + context.Costumes = append(context.Costumes, &controlv2.CostumeFact{ + CostumeId: fact.CostumeID, Grade: fact.Grade, MaxLevel: fact.MaxLevel, + OverflowItemType: fact.OverflowItemType, OverflowItemId: fact.OverflowItemID, OverflowItemCount: fact.OverflowCount, + }) + } + for _, costume := range starter.Costumes { + context.BaseCostumes = append(context.BaseCostumes, &controlv2.BaseCostumeFact{ + InventoryIndex: costume.InvenIndex, CostumeId: costume.ID, Level: costume.Level, + }) + } + context.StepUps = []*controlv2.StepUpFact{{GroupId: stepGroupID, OrderedGachaIds: append([]uint64(nil), stepGachaIDs...)}} + return context, nil +} + +func Repair(ctx context.Context, stateDir, toolPath string, validation *controlv2.ValidationContext) (RepairResult, error) { + source, sourceHash, err := LoadSnapshot(stateDir) + if err != nil { + return RepairResult{}, err + } + if validation == nil || validation.GameDataVersion != source.GameDataVersion { + return RepairResult{}, errors.New("statebridge: validation context version mismatch") + } + request := &controlv2.RepairRequest{BridgeApiVersion: MigrationBridgeAPIVersion, SourceSha256: sourceHash[:], Source: source, Context: validation} + payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(request) + if err != nil { + return RepairResult{}, fmt.Errorf("statebridge: encode repair request: %w", err) + } + responsePayload, err := runTool(ctx, toolPath, []string{"repair"}, payload) + if err != nil { + return RepairResult{}, err + } + var response controlv2.RepairResponse + if err := proto.Unmarshal(responsePayload, &response); err != nil { + return RepairResult{}, fmt.Errorf("statebridge: decode repair response: %w", err) + } + if response.BridgeApiVersion != MigrationBridgeAPIVersion || !bytes.Equal(response.SourceSha256, sourceHash[:]) { + return RepairResult{}, errors.New("statebridge: repair response identity mismatch") + } + if err := validateViolations(response.Violations); err != nil { + return RepairResult{}, err + } + result := RepairResult{SourceSHA256: sourceHash, Changed: response.Changed, Violations: response.Violations} + if !response.Repaired { + if response.Target != nil || !hasErrorViolation(response.Violations) { + return RepairResult{}, errors.New("statebridge: invalid rejected repair response") + } + return result, nil + } + if response.Target == nil || hasErrorViolation(response.Violations) { + return RepairResult{}, errors.New("statebridge: invalid accepted repair response") + } + if response.Target.FormatVersion != 2 || response.Target.ClientVersion != source.ClientVersion || response.Target.GameDataVersion != source.GameDataVersion { + return RepairResult{}, errors.New("statebridge: repaired target metadata mismatch") + } + if !response.Changed { + if err := verifyV2(source, response.Target); err != nil { + return RepairResult{}, fmt.Errorf("statebridge: unchanged repair altered source semantics: %w", err) + } + } + targetBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(response.Target) + if err != nil { + return RepairResult{}, err + } + result.Target = response.Target + result.TargetSHA256 = sha256.Sum256(targetBytes) + return result, nil +} diff --git a/go/internal/statebridge/replace_other.go b/go/internal/statebridge/replace_other.go new file mode 100644 index 0000000..3b8730f --- /dev/null +++ b/go/internal/statebridge/replace_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package statebridge + +import "os" + +func replaceFile(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/go/internal/statebridge/replace_windows.go b/go/internal/statebridge/replace_windows.go new file mode 100644 index 0000000..4bd2a3a --- /dev/null +++ b/go/internal/statebridge/replace_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package statebridge + +import ( + "os" + "syscall" + "unsafe" +) + +var ( + kernel32MoveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW") +) + +const ( + moveFileReplaceExisting = 0x1 + moveFileWriteThrough = 0x8 +) + +func replaceFile(source, destination string) error { + sourcePtr, err := syscall.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPtr, err := syscall.UTF16PtrFromString(destination) + if err != nil { + return err + } + result, _, callErr := kernel32MoveFileEx.Call( + uintptr(unsafe.Pointer(sourcePtr)), + uintptr(unsafe.Pointer(destinationPtr)), + moveFileReplaceExisting|moveFileWriteThrough, + ) + if result == 0 { + if callErr != syscall.Errno(0) { + return callErr + } + return os.ErrInvalid + } + return nil +} diff --git a/go/internal/statebridge/snapshot.go b/go/internal/statebridge/snapshot.go new file mode 100644 index 0000000..72de6ea --- /dev/null +++ b/go/internal/statebridge/snapshot.go @@ -0,0 +1,547 @@ +// Package statebridge adapts the legacy JSON persistence files to the frozen +// cross-language protobuf contract. It never mutates the state directory. +package statebridge + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + statev1 "bd2server/gen/state/v1" +) + +const ( + FormatVersion = 1 + ClientVersion = "2.34.13" + GameDataVersion = "20260910162539" +) + +var stateFiles = []string{ + "characters.json", "collection.json", "deck.json", "equipment.json", + "items.json", "mail.json", "missions.json", "progress.json", "wallet.json", +} + +func CompleteStateAvailable(dir string) (bool, error) { + dir = filepath.Clean(dir) + found := 0 + for _, name := range stateFiles { + info, err := os.Stat(filepath.Join(dir, name)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return false, err + } + if info.IsDir() { + return false, fmt.Errorf("statebridge: %s is a directory", name) + } + found++ + } + if found != 0 && found != len(stateFiles) { + return false, fmt.Errorf("statebridge: incomplete state: %d of %d required files exist", found, len(stateFiles)) + } + return found == len(stateFiles), nil +} + +type pictorial struct { + ID uint64 `json:"id"` + GroupID uint64 `json:"group_id"` +} + +type item struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Type uint64 `json:"type"` + Count uint64 `json:"count"` + KeepFlag uint64 `json:"keep_flag"` + TimeValue uint64 `json:"time_value"` + Pictorial *pictorial `json:"pictorialbook"` + SortID uint64 `json:"sort_id"` + UseCount uint64 `json:"use_count"` +} + +type character struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + HP uint64 `json:"hp"` + Level uint64 `json:"level"` + CostumeID uint64 `json:"costume_id"` + Exp uint64 `json:"exp"` + UseCostume uint64 `json:"use_costume"` + TalentLevel uint64 `json:"talent_level"` + TalentExp uint64 `json:"talent_exp"` + SolidarityReward uint64 `json:"solidarity_reward"` + ExpiryTime uint64 `json:"expiry_time"` + ConnectPotentialCostume uint64 `json:"connect_potential_costume"` + Pictorial []pictorial `json:"pictorialbook"` +} + +type costume struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level"` + UseChar uint64 `json:"use_char"` + SortID uint64 `json:"sort_id"` + PotentialID uint64 `json:"potential_id"` + DesignID uint64 `json:"design_id"` + BurstLevel uint64 `json:"burst_level"` + TimeValue uint64 `json:"time_value"` + Pictorial []pictorial `json:"pictorialbook"` +} + +type progressDisk struct { + Version uint32 `json:"version"` + Position struct { + PackID uint32 `json:"PackID"` + Position struct { + MapID uint32 `json:"MapId"` + PlayerPosition struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` + } `json:"PlayerPosition"` + ColleaguePositions json.RawMessage `json:"ColleaguePositions"` + } `json:"Position"` + RawJSON string `json:"RawJSON"` + } `json:"position"` + Tutorials []uint32 `json:"tutorials"` + Quests map[string]struct { + QuestID uint32 `json:"QuestID"` + PackID uint32 `json:"PackID"` + Values []uint32 `json:"Values"` + } `json:"quests"` + Cleared map[string]bool `json:"cleared_quests"` +} + +type deckSlot struct { + Character uint64 `json:"character_inven_index"` + Costume uint64 `json:"costume_inven_index"` + Slot uint64 `json:"slot"` +} + +type deckDisk struct { + Version string `json:"version"` + Deck []deckSlot `json:"deck"` + FieldDeck []deckSlot `json:"field_deck"` + FieldControl uint64 `json:"field_char_control_deck_type"` + Waypoints map[uint64]uint64 `json:"waypoints"` + Costumes map[uint64]uint64 `json:"costumes"` + Packs map[uint64]uint64 `json:"packs"` + HighestPower uint64 `json:"highest_total_battle_power"` + PortraitCostume uint64 `json:"portrait_costume_id"` + AutoRevive uint64 `json:"auto_revive_catalyst"` +} + +type inventoryDisk struct { + Version string `json:"version"` + NextIndex uint64 `json:"next_index"` + Items []item `json:"items"` + Granted map[string]bool `json:"granted"` + GrantItems map[string][]uint64 `json:"grant_items"` +} + +type equipmentOption struct { + GroupID uint64 `json:"group_id"` + ID uint64 `json:"id"` +} +type equipment struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level"` + UseChar uint64 `json:"use_char"` + KeepFlag uint64 `json:"keep_flag"` + LockFlag uint64 `json:"lock_flag"` + SortID uint64 `json:"sort_id"` + MainOption []equipmentOption `json:"main_option"` + SubOption []equipmentOption `json:"sub_option"` + PrivateOption *equipmentOption `json:"private_option"` + Rank []uint64 `json:"rank"` +} +type equipmentDisk struct { + Version string `json:"version"` + NextIndex uint64 `json:"next_index"` + Equipment []equipment `json:"equipment"` + Granted map[string]uint64 `json:"granted"` +} +type charactersDisk struct { + Version string `json:"version"` + Characters []character `json:"characters"` +} + +type gachaSelection struct { + GroupID uint64 `json:"group_id"` + Slot uint64 `json:"slot"` + ItemID uint64 `json:"item_id"` +} +type gachaUser struct { + GroupID uint64 `json:"group_id"` + Point uint64 `json:"point"` + TotalBuyCount uint64 `json:"total_buy_count"` + OneFreePickCount uint64 `json:"one_free_pick_count"` + OneCashPickCount uint64 `json:"one_cash_pick_count"` + TenFreePickCount uint64 `json:"ten_free_pick_count"` + TenCashPickCount uint64 `json:"ten_cash_pick_count"` + ExchangeItemCount uint64 `json:"exchange_item_count"` + ExchangeMileageCount uint64 `json:"exchange_mileage_count"` +} +type gachaFixed struct { + FixedID uint64 `json:"fixed_id"` + Type uint64 `json:"type"` + Count uint64 `json:"count"` + ApplySort int32 `json:"apply_sort_id"` +} +type pointExchange struct { + GroupID uint64 `json:"group_id"` + Count uint64 `json:"count"` +} +type costumeUpgrade struct { + InvenIndex uint64 `json:"inven_index"` + CostumeID uint64 `json:"costume_id"` + Before uint64 `json:"before"` + After uint64 `json:"after"` + SortID uint64 `json:"sort_id"` +} +type costumeExchange struct { + InvenIndex uint64 `json:"inven_index"` + OriginalItemType uint64 `json:"original_item_type"` + OriginalItemID uint64 `json:"original_item_id"` + OriginalCount uint64 `json:"original_count"` + ExchangeItemType uint64 `json:"exchange_item_type"` + ExchangeItemID uint64 `json:"exchange_item_id"` + ExchangeCount uint64 `json:"exchange_count"` + SortID uint64 `json:"sort_id"` +} +type collectionGrant struct { + CharacterIndices []uint64 `json:"character_indices"` + CostumeIndices []uint64 `json:"costume_indices"` + Upgrades []costumeUpgrade `json:"upgrades"` + Exchanges []costumeExchange `json:"exchanges"` + ViewCostumeIDs []uint64 `json:"view_costume_ids"` + GachaGroupID uint64 `json:"gacha_group_id"` + GachaPoint uint64 `json:"gacha_point"` + GachaFixed []gachaFixed `json:"gacha_fixed"` + SelectionApplySortIDs []uint64 `json:"selection_apply_sort_ids"` +} +type collectionDisk struct { + Version string `json:"version"` + NextCharacterIndex uint64 `json:"next_character_index"` + NextCostumeIndex uint64 `json:"next_costume_index"` + LatestPreview []uint64 `json:"latest_preview"` + PreviewEventIndex uint64 `json:"preview_event_index"` + PreviewLocked bool `json:"preview_locked"` + Characters []character `json:"characters"` + Costumes []costume `json:"costumes"` + BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"` + GachaSelections map[string][]gachaSelection `json:"gacha_selections"` + StepUpProgress map[string]uint64 `json:"step_up_progress"` + GachaUsers map[string]gachaUser `json:"gacha_users"` + GachaFixed map[string]gachaFixed `json:"gacha_fixed"` + GachaApplied map[string]bool `json:"gacha_applied"` + GachaPointExchanges map[string]pointExchange `json:"gacha_point_exchanges"` + GachaCountCorrected bool `json:"gacha_count_corrected"` + Grants map[string]collectionGrant `json:"grants"` +} + +type walletDisk struct { + Version string `json:"version"` + Gold uint64 `json:"gold"` + FreeJewelry uint64 `json:"free_jewelry"` + Jewelry uint64 `json:"jewelry"` + Mileage uint64 `json:"mileage"` + HopePowder uint64 `json:"hope_powder"` + Granted map[string]bool `json:"granted"` + Spent map[string]bool `json:"spent"` +} +type mailDisk struct { + Version string `json:"version"` + Opened []uint64 `json:"opened"` +} +type missionsDisk struct { + Version string `json:"version"` + Completed []string `json:"completed"` + Claimed []string `json:"claimed"` + Progress map[string]uint64 `json:"progress"` +} + +// LoadSnapshot reads every state file before decoding any semantic DTO. Hash +// covers filenames, lengths, and original bytes in a fixed order. +func LoadSnapshot(dir string) (*statev1.Snapshot, [32]byte, error) { + dir = filepath.Clean(dir) + raw := make(map[string][]byte, len(stateFiles)) + hasher := sha256.New() + for _, name := range stateFiles { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, [32]byte{}, fmt.Errorf("statebridge: read %s: %w", name, err) + } + raw[name] = data + _ = binary.Write(hasher, binary.LittleEndian, uint32(len(name))) + _, _ = hasher.Write([]byte(name)) + _ = binary.Write(hasher, binary.LittleEndian, uint64(len(data))) + _, _ = hasher.Write(data) + } + var sum [32]byte + copy(sum[:], hasher.Sum(nil)) + + var progress progressDisk + var deck deckDisk + var inventory inventoryDisk + var equipment equipmentDisk + var characters charactersDisk + var collection collectionDisk + var wallet walletDisk + var mail mailDisk + var missions missionsDisk + for name, target := range map[string]any{ + "progress.json": &progress, "deck.json": &deck, "items.json": &inventory, + "equipment.json": &equipment, "characters.json": &characters, + "collection.json": &collection, "wallet.json": &wallet, + "mail.json": &mail, "missions.json": &missions, + } { + decoder := json.NewDecoder(bytes.NewReader(raw[name])) + if err := decoder.Decode(target); err != nil { + return nil, [32]byte{}, fmt.Errorf("statebridge: decode %s: %w", name, err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, [32]byte{}, fmt.Errorf("statebridge: trailing data in %s", name) + } + } + snapshot, err := buildSnapshot(progress, deck, inventory, equipment, characters, collection, wallet, mail, missions) + return snapshot, sum, err +} + +func buildSnapshot(progress progressDisk, deck deckDisk, inventory inventoryDisk, equipment equipmentDisk, characters charactersDisk, collection collectionDisk, wallet walletDisk, mail mailDisk, missions missionsDisk) (*statev1.Snapshot, error) { + versions := []string{deck.Version, inventory.Version, equipment.Version, characters.Version, collection.Version, wallet.Version, mail.Version, missions.Version} + for _, version := range versions { + if version != ClientVersion { + return nil, fmt.Errorf("statebridge: incompatible client version %q", version) + } + } + progressPB, err := progressProto(progress) + if err != nil { + return nil, err + } + return &statev1.Snapshot{ + FormatVersion: FormatVersion, ClientVersion: ClientVersion, GameDataVersion: GameDataVersion, + Progress: progressPB, Deck: deckProto(deck), Inventory: inventoryProto(inventory), + Equipment: equipmentProto(equipment), Characters: &statev1.CharacterState{ClientVersion: characters.Version, Characters: charactersProto(characters.Characters)}, + Collection: collectionProto(collection), Wallet: walletProto(wallet), + Mail: &statev1.MailState{ClientVersion: mail.Version, OpenedMailIds: append([]uint64(nil), mail.Opened...)}, + Missions: &statev1.MissionState{ClientVersion: missions.Version, Completed: append([]string(nil), missions.Completed...), Claimed: append([]string(nil), missions.Claimed...), Progress: namedCounts(missions.Progress)}, + }, nil +} + +func progressProto(in progressDisk) (*statev1.Progress, error) { + if in.Version != 2 { + return nil, fmt.Errorf("statebridge: expected progress format 2, got %d", in.Version) + } + out := &statev1.Progress{SourceFormatVersion: in.Version, ClearedTutorialIds: append([]uint32(nil), in.Tutorials...)} + out.Position = &statev1.Position{PackId: in.Position.PackID, MapId: in.Position.Position.MapID, + Player: &statev1.Vector3{X: in.Position.Position.PlayerPosition.X, Y: in.Position.Position.PlayerPosition.Y, Z: in.Position.Position.PlayerPosition.Z}, + OriginalJson: []byte(in.Position.RawJSON), ColleaguePositionsJson: append([]byte(nil), in.Position.Position.ColleaguePositions...)} + for _, key := range sortedKeys(in.Quests) { + quest := in.Quests[key] + packID, questID, err := parseQuestKey(key) + if err != nil { + return nil, err + } + if packID != quest.PackID || questID != quest.QuestID { + return nil, fmt.Errorf("statebridge: quest key %q does not match value %d:%d", key, quest.PackID, quest.QuestID) + } + out.Quests = append(out.Quests, &statev1.QuestProgress{Key: &statev1.QuestKey{PackId: quest.PackID, QuestId: quest.QuestID}, Values: append([]uint32(nil), quest.Values...)}) + } + for _, key := range sortedKeys(in.Cleared) { + if !in.Cleared[key] { + return nil, fmt.Errorf("statebridge: cleared quest %q must be true", key) + } + pack, quest, err := parseQuestKey(key) + if err != nil { + return nil, err + } + out.ClearedQuests = append(out.ClearedQuests, &statev1.QuestKey{PackId: pack, QuestId: quest}) + } + return out, nil +} + +func parseQuestKey(key string) (uint32, uint32, error) { + left, right, ok := strings.Cut(key, ":") + if !ok { + return 0, 0, fmt.Errorf("statebridge: malformed quest key %q", key) + } + pack, err1 := strconv.ParseUint(left, 10, 32) + quest, err2 := strconv.ParseUint(right, 10, 32) + if err1 != nil || err2 != nil || pack == 0 || quest == 0 { + return 0, 0, fmt.Errorf("statebridge: malformed quest key %q", key) + } + return uint32(pack), uint32(quest), nil +} + +func deckProto(in deckDisk) *statev1.Deck { + return &statev1.Deck{ClientVersion: in.Version, Battle: deckSlots(in.Deck), Field: deckSlots(in.FieldDeck), FieldControlType: in.FieldControl, + Waypoints: pairs(in.Waypoints), SelectedCostumes: pairs(in.Costumes), BoughtPacks: pairs(in.Packs), HighestBattlePower: in.HighestPower, + PortraitCostumeId: in.PortraitCostume, AutoReviveCatalyst: in.AutoRevive} +} +func deckSlots(in []deckSlot) []*statev1.DeckSlot { + out := make([]*statev1.DeckSlot, 0, len(in)) + for _, v := range in { + out = append(out, &statev1.DeckSlot{CharacterIndex: v.Character, CostumeIndex: v.Costume, Slot: v.Slot}) + } + return out +} +func pairs(in map[uint64]uint64) []*statev1.Pair { + keys := make([]uint64, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + out := make([]*statev1.Pair, 0, len(keys)) + for _, k := range keys { + out = append(out, &statev1.Pair{Key: k, Value: in[k]}) + } + return out +} + +func inventoryProto(in inventoryDisk) *statev1.Inventory { + out := &statev1.Inventory{ClientVersion: in.Version, NextIndex: in.NextIndex} + for _, v := range in.Items { + out.Items = append(out.Items, itemProto(v)) + } + out.GrantedIdentities = sortedTrueKeys(in.Granted) + for _, identity := range sortedKeys(in.GrantItems) { + out.GrantItems = append(out.GrantItems, &statev1.IndexedGrant{Identity: identity, InventoryIndices: append([]uint64(nil), in.GrantItems[identity]...)}) + } + return out +} +func itemProto(v item) *statev1.Item { + out := &statev1.Item{InventoryIndex: v.InvenIndex, Id: v.ID, Type: v.Type, Count: v.Count, KeepFlag: v.KeepFlag, TimeValue: v.TimeValue, SortId: v.SortID, UseCount: v.UseCount} + if v.Pictorial != nil { + out.Pictorial = &statev1.Pictorial{Id: v.Pictorial.ID, GroupId: v.Pictorial.GroupID} + } + return out +} + +func equipmentProto(in equipmentDisk) *statev1.EquipmentInventory { + out := &statev1.EquipmentInventory{ClientVersion: in.Version, NextIndex: in.NextIndex} + for _, v := range in.Equipment { + out.Equipment = append(out.Equipment, equipmentItemProto(v)) + } + for _, k := range sortedKeys(in.Granted) { + out.Grants = append(out.Grants, &statev1.NamedIndex{Identity: k, InventoryIndex: in.Granted[k]}) + } + return out +} +func equipmentItemProto(v equipment) *statev1.Equipment { + out := &statev1.Equipment{InventoryIndex: v.InvenIndex, Id: v.ID, Level: v.Level, UseChar: v.UseChar, KeepFlag: v.KeepFlag, LockFlag: v.LockFlag, SortId: v.SortID, Ranks: append([]uint64(nil), v.Rank...)} + for _, o := range v.MainOption { + out.MainOptions = append(out.MainOptions, &statev1.EquipmentOption{GroupId: o.GroupID, Id: o.ID}) + } + for _, o := range v.SubOption { + out.SubOptions = append(out.SubOptions, &statev1.EquipmentOption{GroupId: o.GroupID, Id: o.ID}) + } + if v.PrivateOption != nil { + out.PrivateOption = &statev1.EquipmentOption{GroupId: v.PrivateOption.GroupID, Id: v.PrivateOption.ID} + } + return out +} + +func charactersProto(in []character) []*statev1.Character { + out := make([]*statev1.Character, 0, len(in)) + for _, v := range in { + p := &statev1.Character{InventoryIndex: v.InvenIndex, Id: v.ID, Hp: v.HP, Level: v.Level, CostumeId: v.CostumeID, Exp: v.Exp, UseCostume: v.UseCostume, TalentLevel: v.TalentLevel, TalentExp: v.TalentExp, SolidarityReward: v.SolidarityReward, ExpiryTime: v.ExpiryTime, ConnectPotentialCostume: v.ConnectPotentialCostume} + for _, book := range v.Pictorial { + p.Pictorial = append(p.Pictorial, &statev1.Pictorial{Id: book.ID, GroupId: book.GroupID}) + } + out = append(out, p) + } + return out +} +func costumesProto(in []costume) []*statev1.Costume { + out := make([]*statev1.Costume, 0, len(in)) + for _, v := range in { + p := &statev1.Costume{InventoryIndex: v.InvenIndex, Id: v.ID, Level: v.Level, UseChar: v.UseChar, SortId: v.SortID, PotentialId: v.PotentialID, DesignId: v.DesignID, BurstLevel: v.BurstLevel, TimeValue: v.TimeValue} + for _, book := range v.Pictorial { + p.Pictorial = append(p.Pictorial, &statev1.Pictorial{Id: book.ID, GroupId: book.GroupID}) + } + out = append(out, p) + } + return out +} + +func collectionProto(in collectionDisk) *statev1.Collection { + out := &statev1.Collection{ClientVersion: in.Version, NextCharacterIndex: in.NextCharacterIndex, NextCostumeIndex: in.NextCostumeIndex, LatestPreview: append([]uint64(nil), in.LatestPreview...), PreviewEventIndex: in.PreviewEventIndex, PreviewLocked: in.PreviewLocked, Characters: charactersProto(in.Characters), Costumes: costumesProto(in.Costumes), BaseCostumeLevels: namedCounts(in.BaseCostumeLevels), StepUpProgress: namedCounts(in.StepUpProgress), GachaApplied: sortedTrueKeys(in.GachaApplied), GachaCountCorrected: in.GachaCountCorrected} + for _, k := range sortedKeys(in.GachaSelections) { + entry := &statev1.NamedSelections{Identity: k} + for _, v := range in.GachaSelections[k] { + entry.Selections = append(entry.Selections, &statev1.GachaSelection{GroupId: v.GroupID, Slot: v.Slot, ItemId: v.ItemID}) + } + out.GachaSelections = append(out.GachaSelections, entry) + } + for _, k := range sortedKeys(in.GachaUsers) { + v := in.GachaUsers[k] + out.GachaUsers = append(out.GachaUsers, &statev1.NamedGachaUser{Identity: k, User: &statev1.GachaUser{GroupId: v.GroupID, Point: v.Point, TotalBuyCount: v.TotalBuyCount, OneFreePickCount: v.OneFreePickCount, OneCashPickCount: v.OneCashPickCount, TenFreePickCount: v.TenFreePickCount, TenCashPickCount: v.TenCashPickCount, ExchangeItemCount: v.ExchangeItemCount, ExchangeMileageCount: v.ExchangeMileageCount}}) + } + for _, k := range sortedKeys(in.GachaFixed) { + out.GachaFixed = append(out.GachaFixed, &statev1.NamedGachaFixed{Identity: k, Fixed: fixedProto(in.GachaFixed[k])}) + } + for _, k := range sortedKeys(in.GachaPointExchanges) { + v := in.GachaPointExchanges[k] + out.GachaPointExchanges = append(out.GachaPointExchanges, &statev1.NamedGachaPointExchange{Identity: k, Exchange: &statev1.GachaPointExchange{GroupId: v.GroupID, Count: v.Count}}) + } + for _, k := range sortedKeys(in.Grants) { + v := in.Grants[k] + grant := &statev1.CollectionGrant{Identity: k, CharacterIndices: append([]uint64(nil), v.CharacterIndices...), CostumeIndices: append([]uint64(nil), v.CostumeIndices...), ViewCostumeIds: append([]uint64(nil), v.ViewCostumeIDs...), GachaGroupId: v.GachaGroupID, GachaPoint: v.GachaPoint, SelectionApplySortIds: append([]uint64(nil), v.SelectionApplySortIDs...)} + for _, u := range v.Upgrades { + grant.Upgrades = append(grant.Upgrades, &statev1.CostumeUpgrade{InventoryIndex: u.InvenIndex, CostumeId: u.CostumeID, Before: u.Before, After: u.After, SortId: u.SortID}) + } + for _, e := range v.Exchanges { + grant.Exchanges = append(grant.Exchanges, &statev1.CostumeExchange{InventoryIndex: e.InvenIndex, OriginalItemType: e.OriginalItemType, OriginalItemId: e.OriginalItemID, OriginalCount: e.OriginalCount, ExchangeItemType: e.ExchangeItemType, ExchangeItemId: e.ExchangeItemID, ExchangeCount: e.ExchangeCount, SortId: e.SortID}) + } + for _, f := range v.GachaFixed { + grant.GachaFixed = append(grant.GachaFixed, fixedProto(f)) + } + out.Grants = append(out.Grants, grant) + } + return out +} +func fixedProto(v gachaFixed) *statev1.GachaFixed { + return &statev1.GachaFixed{FixedId: v.FixedID, Type: v.Type, Count: v.Count, ApplySortId: v.ApplySort} +} +func walletProto(in walletDisk) *statev1.Wallet { + return &statev1.Wallet{ClientVersion: in.Version, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, GrantedIdentities: sortedTrueKeys(in.Granted), SpentIdentities: sortedTrueKeys(in.Spent)} +} +func namedCounts(in map[string]uint64) []*statev1.NamedCount { + out := make([]*statev1.NamedCount, 0, len(in)) + for _, k := range sortedKeys(in) { + out = append(out, &statev1.NamedCount{Identity: k, Value: in[k]}) + } + return out +} + +func sortedTrueKeys(in map[string]bool) []string { + keys := make([]string, 0, len(in)) + for k, v := range in { + if v { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys +} +func sortedKeys[V any](in map[string]V) []string { + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/go/internal/statebridge/tool.go b/go/internal/statebridge/tool.go new file mode 100644 index 0000000..dc998fc --- /dev/null +++ b/go/internal/statebridge/tool.go @@ -0,0 +1,30 @@ +package statebridge + +import ( + "errors" + "os" + "path/filepath" +) + +// ResolveTool prefers an explicit development override. Release builds embed +// the Haskell executable and materialize it into a private temporary directory. +func ResolveTool(explicit string) (string, func(), error) { + if explicit != "" { + return filepath.Clean(explicit), func() {}, nil + } + if env := os.Getenv("BD2_STATE_TOOL"); env != "" { + return filepath.Clean(env), func() {}, nil + } + if path, cleanup, ok, err := embeddedStateTool(); err != nil { + return "", func() {}, err + } else if ok { + return path, cleanup, nil + } + if executable, err := os.Executable(); err == nil { + sibling := filepath.Join(filepath.Dir(executable), "bd2-state.exe") + if info, statErr := os.Stat(sibling); statErr == nil && !info.IsDir() { + return sibling, func() {}, nil + } + } + return "", func() {}, errors.New("statebridge: no Haskell state tool; use --state-tool/--tool or build the embedded release") +} diff --git a/go/internal/statebridge/tool_embedded.go b/go/internal/statebridge/tool_embedded.go new file mode 100644 index 0000000..ea4058c --- /dev/null +++ b/go/internal/statebridge/tool_embedded.go @@ -0,0 +1,29 @@ +//go:build embedded_state_tool + +package statebridge + +import ( + "crypto/sha256" + _ "embed" + "encoding/hex" + "os" + "path/filepath" +) + +//go:embed tool/bd2-state.exe +var embeddedStateToolBytes []byte + +func embeddedStateTool() (string, func(), bool, error) { + sum := sha256.Sum256(embeddedStateToolBytes) + dir, err := os.MkdirTemp("", "bd2-state-"+hex.EncodeToString(sum[:6])+"-") + if err != nil { + return "", func() {}, true, err + } + cleanup := func() { _ = os.RemoveAll(dir) } + path := filepath.Join(dir, "bd2-state.exe") + if err := os.WriteFile(path, embeddedStateToolBytes, 0o700); err != nil { + cleanup() + return "", func() {}, true, err + } + return path, cleanup, true, nil +} diff --git a/go/internal/statebridge/tool_external.go b/go/internal/statebridge/tool_external.go new file mode 100644 index 0000000..6970d77 --- /dev/null +++ b/go/internal/statebridge/tool_external.go @@ -0,0 +1,7 @@ +//go:build !embedded_state_tool + +package statebridge + +func embeddedStateTool() (string, func(), bool, error) { + return "", func() {}, false, nil +} diff --git a/go/internal/transport/http.go b/go/internal/transport/http.go new file mode 100644 index 0000000..a9c453f --- /dev/null +++ b/go/internal/transport/http.go @@ -0,0 +1,205 @@ +// Package transport owns the HTTP envelope and dispatches plaintext protobufs. +package transport + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "bd2server/internal/bootstrap" +) + +type Envelope struct { + PacketCode int `json:"packetCode"` + ErrorType int `json:"errorType"` + ErrorMessage string `json:"errorMessage"` + Length int `json:"length"` + Data string `json:"data"` + ServerNowTime int64 `json:"serverNowTime"` + Notify string `json:"notify,omitempty"` + IP string `json:"ip,omitempty"` +} + +// Reply is a decoded protobuf message; encryption belongs to the session layer. +type Reply struct { + PacketCode int + Data []byte + Notify string + Cookie string +} + +type Dispatcher interface { + Dispatch(path string, request []byte) (Reply, error) +} + +type RawReply struct { + Body []byte + ContentType string + Cookie string +} + +// RawDispatcher handles encrypted/session packets whose outer representation +// is not always an Envelope (BatchRequest returns a JSON array). +type RawDispatcher interface { + DispatchRaw(path string, wireBody []byte, cookie string) (RawReply, error) +} + +type Bootstrap struct { + Config bootstrap.Config + Now func() time.Time +} + +var ErrNotImplemented = errors.New("packet not implemented") + +func (b Bootstrap) Dispatch(path string, request []byte) (Reply, error) { + switch path { + case "/MaintenanceInfo": + data, err := bootstrap.Maintenance(b.Config.Version, b.Config.BundleVer, request) + return Reply{Data: data}, err + case "/ServerInfo": + return Reply{Data: bootstrap.ServerInfo(b.Config)}, nil + case "/ServerNowTime": + return Reply{Data: bootstrap.ServerNowTime(b.now())}, nil + case "/NoticeInfo": + return Reply{Data: bootstrap.NoticeInfo()}, nil + default: + return Reply{}, ErrNotImplemented + } +} + +func (b Bootstrap) now() time.Time { + if b.Now != nil { + return b.Now() + } + return time.Now() +} + +type HTTP struct { + Dispatcher Dispatcher + Raw RawDispatcher + Logger *slog.Logger + Now func() time.Time + CDNDir string + GameDataDir string +} + +func (h HTTP) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/StateCheckInfoJson", h.stateCheck) + mux.HandleFunc("/game/StateCheckInfoJson", h.stateCheck) + mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + if h.CDNDir != "" { + mux.Handle("/assets/ServerData/", http.StripPrefix("/assets/ServerData/", http.FileServer(http.Dir(h.CDNDir)))) + } + if h.GameDataDir != "" { + mux.Handle("/assets/GameData/", http.StripPrefix("/assets/GameData/", http.FileServer(http.Dir(h.GameDataDir)))) + } + mux.HandleFunc("/game/", h.game) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) + }) + return mux +} + +func (h HTTP) stateCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"country": "CN", "errorType": 0, "errorMessage": ""}) +} + +func (h HTTP) game(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "PUT required", http.StatusMethodNotAllowed) + return + } + path := strings.TrimPrefix(r.URL.Path, "/game") + if path == "" || path == "/" || strings.Contains(path[1:], "/") { + http.NotFound(w, r) + return + } + if h.Dispatcher == nil && h.Raw == nil { + http.Error(w, "no dispatcher", http.StatusServiceUnavailable) + return + } + defer r.Body.Close() + body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20+1)) + if err != nil || len(body) > 8<<20 { + http.Error(w, "request too large", http.StatusRequestEntityTooLarge) + return + } + bootstrapPacket := path == "/MaintenanceInfo" || path == "/ServerInfo" || path == "/ServerNowTime" || path == "/NoticeInfo" + if !bootstrapPacket { + if h.Raw == nil { + h.logger().Info("packet needs session handler", "path", path) + http.Error(w, "authenticated packet not yet implemented", http.StatusNotImplemented) + return + } + reply, err := h.Raw.DispatchRaw(path, body, r.Header.Get("Cookie")) + if err != nil { + h.logger().Warn("session packet rejected", "path", path, "error", err) + http.Error(w, "session packet rejected", http.StatusBadRequest) + return + } + if reply.Cookie != "" { + http.SetCookie(w, &http.Cookie{Name: "s", Value: reply.Cookie, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) + } + if reply.ContentType == "" { + reply.ContentType = "application/json; charset=utf-8" + } + w.Header().Set("Content-Type", reply.ContentType) + h.logger().Info("session packet handled", "path", path, "responseBytes", len(reply.Body)) + _, _ = w.Write(reply.Body) + return + } + if h.Dispatcher == nil { + http.Error(w, "no bootstrap dispatcher", http.StatusServiceUnavailable) + return + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(body))) + if err != nil { + http.Error(w, "invalid base64 request", http.StatusBadRequest) + return + } + reply, err := h.Dispatcher.Dispatch(path, decoded) + if err != nil { + h.logger().Error("bootstrap packet failed", "path", path, "error", err) + if errors.Is(err, ErrNotImplemented) { + http.Error(w, "packet not implemented", http.StatusNotImplemented) + } else { + http.Error(w, "bad packet", http.StatusBadRequest) + } + return + } + if reply.Cookie != "" { + http.SetCookie(w, &http.Cookie{Name: "s", Value: reply.Cookie, Path: "/game/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) + } + answer := Envelope{PacketCode: reply.PacketCode, Data: base64.StdEncoding.EncodeToString(reply.Data), ServerNowTime: h.now().UnixMilli(), Notify: reply.Notify} + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(answer); err != nil { + h.logger().Error("write response", "error", fmt.Errorf("%s: %w", path, err)) + } +} + +func (h HTTP) now() time.Time { + if h.Now != nil { + return h.Now() + } + return time.Now() +} + +func (h HTTP) logger() *slog.Logger { + if h.Logger != nil { + return h.Logger + } + return slog.Default() +} diff --git a/go/internal/transport/http_test.go b/go/internal/transport/http_test.go new file mode 100644 index 0000000..3e7c786 --- /dev/null +++ b/go/internal/transport/http_test.go @@ -0,0 +1,63 @@ +package transport + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "bd2server/internal/bootstrap" + "bd2server/internal/wire" +) + +func TestBootstrapRoundTrip(t *testing.T) { + cfg := bootstrap.Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: bootstrap.ClientVersion, BundleVer: bootstrap.BundleVersion} + now := func() time.Time { return time.UnixMilli(12345) } + h := HTTP{Dispatcher: Bootstrap{Config: cfg, Now: now}, Now: now}.Handler() + for _, path := range []string{"/MaintenanceInfo", "/ServerInfo", "/NoticeInfo", "/ServerNowTime"} { + request := base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2)) + req := httptest.NewRequest(http.MethodPut, "/game"+path, strings.NewReader(request)) + res := httptest.NewRecorder() + h.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("%s: %d %s", path, res.Code, res.Body.String()) + } + var envelope Envelope + if err := json.Unmarshal(res.Body.Bytes(), &envelope); err != nil || envelope.ErrorType != 0 || envelope.ServerNowTime != 12345 { + t.Fatalf("%s: %v %+v", path, err, envelope) + } + if _, err := base64.StdEncoding.DecodeString(envelope.Data); err != nil { + t.Fatalf("%s: %v", path, err) + } + } +} + +func TestUnknownPacketFailsClosed(t *testing.T) { + h := HTTP{Dispatcher: Bootstrap{}}.Handler() + res := httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/game/InventedPacket", strings.NewReader("AA=="))) + if res.Code != http.StatusNotImplemented { + t.Fatalf("unknown packet status: %d", res.Code) + } +} + +func TestGameDataStaticFiles(t *testing.T) { + dir := t.TempDir() + path := dir + "/123/release" + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+"/common-dbdata.info", []byte("42"), 0o644); err != nil { + t.Fatal(err) + } + h := HTTP{GameDataDir: dir}.Handler() + res := httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/assets/GameData/123/release/common-dbdata.info", nil)) + if res.Code != http.StatusOK || res.Body.String() != "42" { + t.Fatalf("static GameData: status=%d body=%q", res.Code, res.Body.String()) + } +} diff --git a/go/internal/wire/wire.go b/go/internal/wire/wire.go new file mode 100644 index 0000000..3c7effd --- /dev/null +++ b/go/internal/wire/wire.go @@ -0,0 +1,162 @@ +// Package wire handles the few protobuf fields needed for bootstrap messages. +// Business messages remain opaque and belong to their own domain handlers. +package wire + +import ( + "encoding/binary" + "errors" + "fmt" + "math" +) + +var ErrMalformed = errors.New("malformed protobuf wire data") + +type Field struct { + Number int + Type int + Value []byte + Start int + End int +} + +func AppendVarint(dst []byte, field int, value uint64) []byte { + dst = binary.AppendUvarint(dst, uint64(field<<3)) + return binary.AppendUvarint(dst, value) +} + +func AppendBytes(dst []byte, field int, value []byte) []byte { + dst = binary.AppendUvarint(dst, uint64(field<<3|2)) + dst = binary.AppendUvarint(dst, uint64(len(value))) + return append(dst, value...) +} + +func AppendString(dst []byte, field int, value string) []byte { + return AppendBytes(dst, field, []byte(value)) +} + +// AppendFixed64 appends a protobuf fixed64 field. It is kept here instead of +// hand-building tags in domain packages so doubles use one canonical encoder. +func AppendFixed64(dst []byte, field int, value uint64) []byte { + dst = binary.AppendUvarint(dst, uint64(field<<3|1)) + return binary.LittleEndian.AppendUint64(dst, value) +} + +func AppendDouble(dst []byte, field int, value float64) []byte { + return AppendFixed64(dst, field, math.Float64bits(value)) +} + +// Walk visits wire fields without interpreting the payload or changing its bytes. +func Walk(data []byte, visit func(Field) error) error { + for pos := 0; pos < len(data); { + start := pos + tag, n := binary.Uvarint(data[pos:]) + if n <= 0 || tag>>3 == 0 || tag>>3 > uint64(^uint(0)>>1) { + return fmt.Errorf("%w: field tag at %d", ErrMalformed, pos) + } + pos += n + field := Field{Number: int(tag >> 3), Type: int(tag & 7), Start: start} + switch field.Type { + case 0: + _, n = binary.Uvarint(data[pos:]) + if n <= 0 { + return fmt.Errorf("%w: varint at %d", ErrMalformed, pos) + } + field.Value = data[pos : pos+n] + pos += n + case 1: + if len(data)-pos < 8 { + return ErrMalformed + } + field.Value = data[pos : pos+8] + pos += 8 + case 2: + length, count := binary.Uvarint(data[pos:]) + if count <= 0 || length > uint64(len(data)-pos-count) { + return fmt.Errorf("%w: length at %d", ErrMalformed, pos) + } + pos += count + field.Value = data[pos : pos+int(length)] + pos += int(length) + case 5: + if len(data)-pos < 4 { + return ErrMalformed + } + field.Value = data[pos : pos+4] + pos += 4 + default: + return fmt.Errorf("%w: wire type %d", ErrMalformed, field.Type) + } + field.End = pos + if err := visit(field); err != nil { + return err + } + } + return nil +} + +func Varint(data []byte, number int) (uint64, bool, error) { + var result uint64 + var found bool + err := Walk(data, func(field Field) error { + if field.Number == number && field.Type == 0 { + result, _ = binary.Uvarint(field.Value) + found = true + } + return nil + }) + return result, found, err +} + +func Bytes(data []byte, number int) ([]byte, bool, error) { + var result []byte + var found bool + err := Walk(data, func(field Field) error { + if field.Number == number && field.Type == 2 { + result = field.Value + found = true + } + return nil + }) + return result, found, err +} + +// ReplaceBytes keeps every unrelated field intact, including unknown fields. +func ReplaceBytes(data []byte, number int, value []byte) ([]byte, bool, error) { + var result []byte + var replaced bool + err := Walk(data, func(field Field) error { + if field.Number == number && field.Type == 2 && !replaced { + result = AppendBytes(result, number, value) + replaced = true + } else { + result = append(result, data[field.Start:field.End]...) + } + return nil + }) + return result, replaced, err +} + +// ReplaceVarint replaces the first varint field with number while preserving +// every unrelated (including unknown) protobuf field byte-for-byte. If the +// field is absent it is appended, which is required for proto3 zero values +// omitted from a versioned template. +func ReplaceVarint(data []byte, number int, value uint64) ([]byte, bool, error) { + var result []byte + var replaced bool + err := Walk(data, func(field Field) error { + if field.Number == number && field.Type == 0 && !replaced { + result = AppendVarint(result, number, value) + replaced = true + } else { + result = append(result, data[field.Start:field.End]...) + } + return nil + }) + if err != nil { + return nil, false, err + } + if !replaced { + result = AppendVarint(result, number, value) + } + return result, replaced, nil +} diff --git a/go/internal/wire/wire_test.go b/go/internal/wire/wire_test.go new file mode 100644 index 0000000..afc8f4e --- /dev/null +++ b/go/internal/wire/wire_test.go @@ -0,0 +1,56 @@ +package wire + +import ( + "bytes" + "errors" + "testing" +) + +func TestWalkAndReplace(t *testing.T) { + original := AppendVarint(nil, 1, 42) + original = AppendString(original, 2, "old") + original = AppendBytes(original, 15, []byte{0, 1, 2}) + modified, replaced, err := ReplaceBytes(original, 2, []byte("longer")) + if err != nil || !replaced { + t.Fatalf("replace: %v, %v", replaced, err) + } + v, ok, err := Varint(modified, 1) + if err != nil || !ok || v != 42 { + t.Fatalf("preserved varint: %d %v %v", v, ok, err) + } + x, ok, err := Bytes(modified, 2) + if err != nil || !ok || string(x) != "longer" { + t.Fatalf("replacement: %q %v %v", x, ok, err) + } + unknown, _, _ := Bytes(modified, 15) + if !bytes.Equal(unknown, []byte{0, 1, 2}) { + t.Fatalf("unknown field changed: %x", unknown) + } +} + +func TestTruncated(t *testing.T) { + if err := Walk([]byte{0x12, 0x03, 0x01}, func(Field) error { return nil }); !errors.Is(err, ErrMalformed) { + t.Fatalf("expected malformed: %v", err) + } +} + +func TestReplaceVarintAndAppendMissing(t *testing.T) { + original := AppendVarint(nil, 1, 7) + original = AppendString(original, 2, "preserved") + modified, replaced, err := ReplaceVarint(original, 1, 300) + if err != nil || !replaced { + t.Fatalf("replace existing: replaced=%v err=%v", replaced, err) + } + value, found, err := Varint(modified, 1) + if err != nil || !found || value != 300 { + t.Fatalf("existing value=%d found=%v err=%v", value, found, err) + } + modified, replaced, err = ReplaceVarint(modified, 9, 0) + if err != nil || replaced { + t.Fatalf("append missing: replaced=%v err=%v", replaced, err) + } + value, found, err = Varint(modified, 9) + if err != nil || !found || value != 0 { + t.Fatalf("appended zero=%d found=%v err=%v", value, found, err) + } +} diff --git a/go/internal/world/world.go b/go/internal/world/world.go new file mode 100644 index 0000000..fc445eb --- /dev/null +++ b/go/internal/world/world.go @@ -0,0 +1,711 @@ +// Package world implements the local map/quest state that is not part of a +// player's inventory. It deliberately stores semantic seed values, never a +// captured response payload. +package world + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "sort" + + "bd2server/internal/deck" + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/progress" + "bd2server/internal/wire" +) + +var ErrInvalidRequest = errors.New("world: invalid request") + +type Seed struct { + Version string `json:"version"` + PackID int `json:"pack_id"` + StartQuestID int `json:"start_quest_id"` + BattleUnlockQuestID int `json:"battle_unlock_quest_id"` + RewardCharacter player.Character `json:"reward_character"` + RewardCostume player.Costume `json:"reward_costume"` + StoryCharacters []player.Character `json:"story_characters"` +} + +// Load reads the small, versioned world seed. Quest IDs are then verified +// against the authoritative shared QuestTable GameData database. +func Load(seedPath, gameDataRoot, gameDataVersion, characterStatePath string, state *progress.Store, starter *player.Starter, equipment *player.EquipmentInventory, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) { + if state == nil || starter == nil || equipment == nil || inventory == nil || wallet == nil { + return nil, errors.New("world: nil player state") + } + b, err := os.ReadFile(seedPath) + if err != nil { + return nil, fmt.Errorf("world: read seed: %w", err) + } + var seed Seed + if err := json.Unmarshal(b, &seed); err != nil { + return nil, fmt.Errorf("world: decode seed: %w", err) + } + if seed.Version != "2.34.13" || seed.PackID <= 0 || seed.StartQuestID <= 0 || seed.BattleUnlockQuestID <= 0 || seed.RewardCharacter.ID == 0 || seed.RewardCostume.ID == 0 || len(seed.StoryCharacters) == 0 { + return nil, errors.New("world: invalid seed") + } + ownedCharacters := append([]player.Character(nil), starter.Characters...) + ownedCharacters = append(ownedCharacters, seed.RewardCharacter) + ownedCharacters = append(ownedCharacters, seed.StoryCharacters...) + characters, err := player.OpenCharacterStore(characterStatePath, ownedCharacters, inventory, gameDataRoot, gameDataVersion) + if err != nil { + return nil, fmt.Errorf("world: open character state: %w", err) + } + packs, transitions, err := gamedata.LoadQuestPackChain(gameDataRoot, gameDataVersion, seed.PackID) + if err != nil { + return nil, err + } + quests := packs[seed.PackID] + if _, ok := quests[seed.StartQuestID]; !ok { + return nil, fmt.Errorf("world: start quest %d is absent from QuestTable%d", seed.StartQuestID, seed.PackID) + } + transition := transitions[seed.PackID] + service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, quests: quests, transition: transition, packs: packs, transitions: transitions} + if err := service.MigrateClearedRewards(); err != nil { + return nil, fmt.Errorf("world: migrate cleared quest rewards: %w", err) + } + return service, nil +} + +func (s *Service) CharacterService() *player.CharacterStore { return s.characters } + +type Service struct { + 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 +} + +func (s *Service) AttachCollection(collection *player.CollectionStore) error { + if collection == nil { + return errors.New("world: nil collection store") + } + s.collection = collection + return s.characters.AttachCollection(collection) +} + +func (s *Service) AttachDecks(decks *deck.Store) error { + if decks == nil { + return errors.New("world: nil deck store") + } + s.decks = decks + return nil +} + +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + switch path { + case "/PackInfo": + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, errors.New("world: PackInfo missing sequence") + } + return 4, s.accountPackInfo(), true, nil + case "/CharInfo": + if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) { + return 0, nil, false, nil + } + var response []byte + characters := s.characters.All() + slog.Info("team trace: deliver owned characters", "characters", characters) + for _, character := range characters { + response = wire.AppendBytes(response, 1, encodeCharacter(character)) + } + response = wire.AppendVarint(response, 2, s.starter.FieldCharControlDeckType) + return 9, response, true, nil + case "/CostumeInfo": + if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) { + return 0, nil, false, nil + } + var response []byte + costumes := s.starter.Costumes + if s.collection != nil { + costumes = s.collection.Costumes() + } + slog.Info("team trace: deliver owned costumes", "costumes", costumes, "quest26", s.seed.RewardCostume) + for _, costume := range costumes { + response = wire.AppendBytes(response, 1, encodeCostume(costume)) + } + response = wire.AppendBytes(response, 1, encodeCostume(s.seed.RewardCostume)) + return 40, response, true, nil + case "/PackInGameInfo": + pack, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if !s.packUnlocked(pack) { + return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack) + } + slog.Info("team trace: deliver pack progress", "pack", pack, "clearedQuests", s.state.ClearedQuests(pack), "storyCharacters", s.storyCharacters(pack)) + return 5, s.packInfoFor(pack), true, nil + case "/QuestClear": + quest, pack, err := requestQuest(request) + if err != nil { + return 0, nil, true, err + } + quests, unlocked := s.questsFor(pack) + design, exists := quests[quest] + if !unlocked || !s.packUnlocked(pack) || !exists { + return 0, nil, true, fmt.Errorf("%w: quest %d pack %d", ErrInvalidRequest, quest, pack) + } + if !s.canClear(pack, quest) { + return 0, nil, true, fmt.Errorf("%w: quest %d is not active", ErrInvalidRequest, quest) + } + items, questEquipment, err := s.grantQuestRewards(pack, quest, design.Rewards[0]) + if err != nil { + return 0, nil, true, err + } + if err := s.state.ClearQuest(quest, pack); err != nil { + return 0, nil, true, fmt.Errorf("world: clear quest: %w", err) + } + slog.Info("team trace: quest cleared", "pack", pack, "quest", quest, "changesBattleDeck", pack == s.seed.PackID && quest == s.seed.BattleUnlockQuestID) + return 18, s.clearResponse(pack, quest, design.Rewards[0], items, questEquipment), true, nil + default: + return 0, nil, false, nil + } +} + +func requestPack(request []byte) (int, error) { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, ErrInvalidRequest + } + pack, found, err := wire.Varint(request, 2) + if err != nil || !found || pack == 0 || pack > uint64(^uint32(0)>>1) { + return 0, ErrInvalidRequest + } + return int(pack), nil +} + +func requestQuest(request []byte) (int, int, error) { + quest, err := requestPack(request) // fields 1 and 2 have the same validation. + if err != nil { + return 0, 0, err + } + pack, found, err := wire.Varint(request, 3) + if err != nil || !found || pack == 0 || pack > uint64(^uint32(0)>>1) { + return 0, 0, ErrInvalidRequest + } + return quest, int(pack), nil +} + +func (s *Service) questsFor(packID int) (map[int]gamedata.QuestDesign, bool) { + if s.packs != nil { + quests, found := s.packs[packID] + return quests, found + } + if packID == s.seed.PackID && s.quests != nil { + return s.quests, true + } + return nil, false +} + +func (s *Service) transitionFor(packID int) gamedata.PackTransition { + if s.transitions != nil { + return s.transitions[packID] + } + if packID == s.seed.PackID { + return s.transition + } + return gamedata.PackTransition{PackID: packID} +} + +// packUnlocked follows the static story chain and requires every preceding +// pack to be complete. This accepts pack22 after pack21 quest38 without +// accidentally exposing arbitrary tables from the shared GameData database. +func (s *Service) packUnlocked(packID int) bool { + current := s.seed.PackID + for steps := 0; steps < 64 && current != 0; steps++ { + if current == packID { + _, found := s.questsFor(current) + return found + } + if !s.packCompleteFor(current) { + return false + } + current = s.transitionFor(current).NextPackID + } + return false +} + +func (s *Service) storyCharacters(packID int) []player.Character { + if packID != s.seed.PackID { + return nil + } + return s.seed.StoryCharacters +} + +func (s *Service) canClear(packID, quest int) bool { + if s.state.QuestCleared(quest, packID) { + return true + } + quests, found := s.questsFor(packID) + if !found { + return false + } + startQuestID := 0 + for id := range quests { + if startQuestID == 0 || id < startQuestID { + startQuestID = id + } + } + if packID == s.seed.PackID { + startQuestID = s.seed.StartQuestID + } + if quest == startQuestID { + return true + } + for id := range quests { + if id < quest && !s.state.QuestCleared(id, packID) { + return false + } + } + return true +} + +// MigrateClearedRewards repairs legacy saves created while QuestClear only +// rendered rewards in its response. Every backing store is idempotent, so it +// is safe to run at each startup and after a partially completed write. +func (s *Service) MigrateClearedRewards() error { + packIDs := []int{s.seed.PackID} + if s.packs != nil { + packIDs = packIDs[:0] + for packID := range s.packs { + packIDs = append(packIDs, packID) + } + sort.Ints(packIDs) + } + for _, packID := range packIDs { + quests, _ := s.questsFor(packID) + for _, quest := range s.state.ClearedQuests(packID) { + design, ok := quests[quest] + if !ok { + return fmt.Errorf("cleared quest %d is absent from QuestTable%d", quest, packID) + } + if _, _, err := s.grantQuestRewards(packID, quest, design.Rewards[0]); err != nil { + return fmt.Errorf("pack %d quest %d: %w", packID, quest, err) + } + } + } + return nil +} + +func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.Reward) ([]player.Item, *player.Equipment, error) { + identity := fmt.Sprintf("pack%d:quest%d", packID, quest) + if s.wallet != nil { + if _, err := s.wallet.GrantQuestOnce(identity, designRewards); err != nil { + return nil, nil, fmt.Errorf("world: grant quest currency: %w", err) + } + } + var itemRewards []gamedata.BattleReward + var equipmentReward *gamedata.Reward + for i := range designRewards { + reward := designRewards[i] + switch reward.Type { + case 3, 4: + if reward.Count == 0 { + return nil, nil, errors.New("world: zero currency reward") + } + case 10: + if reward.ID == 0 || equipmentReward != nil { + return nil, nil, errors.New("world: invalid equipment reward") + } + equipmentReward = &reward + case 11: + // Quest 26's character/costume instances come from the versioned + // story seed and are encoded below; they are not stackable items. + if packID != s.seed.PackID || quest != s.seed.BattleUnlockQuestID || reward.ID != s.seed.RewardCostume.ID { + return nil, nil, fmt.Errorf("world: unsupported costume reward %d", reward.ID) + } + default: + if reward.ID == 0 || reward.Count == 0 { + return nil, nil, fmt.Errorf("world: invalid item reward type=%d id=%d count=%d", reward.Type, reward.ID, reward.Count) + } + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + } + } + if packID == s.seed.PackID { + for _, reward := range questPictorialItems[quest] { + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + } + } + var items []player.Item + if len(itemRewards) != 0 { + if s.inventory == nil { + return nil, nil, errors.New("world: inventory unavailable") + } + var err error + items, err = s.inventory.GrantOnce(identity+":items", itemRewards) + if err != nil { + return nil, nil, fmt.Errorf("world: grant quest items: %w", err) + } + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + } + var equipment *player.Equipment + if equipmentReward != nil { + if s.equipment == nil { + return nil, nil, errors.New("world: equipment inventory unavailable") + } + entry, err := s.equipment.GrantOnce(fmt.Sprintf("%s:equip%d", identity, equipmentReward.ID), equipmentReward.ID) + if err != nil { + return nil, nil, fmt.Errorf("world: grant quest equipment: %w", err) + } + equipment = &entry + } + return items, equipment, nil +} + +// packInfo is the canonical protobuf encoding of the semantic new-account +// pack21 state. Its first-call bytes match the 2.34.13 observed response. +func (s *Service) packInfo() []byte { + return s.packInfoFor(s.seed.PackID) +} + +func (s *Service) packInfoFor(packID int) []byte { + var out []byte + if packID == s.seed.PackID && s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) { + for _, fallback := range s.seed.StoryCharacters { + character, found := s.characters.Find(fallback.InvenIndex) + if !found { + character = fallback + } + out = wire.AppendBytes(out, 1, encodeCharacter(character)) + } + character, found := s.characters.Find(s.seed.RewardCharacter.InvenIndex) + if !found { + character = s.seed.RewardCharacter + } + out = wire.AppendBytes(out, 1, encodeCharacter(character)) + } + if active := s.firstUnclearedQuestFor(packID); active != 0 { + quest := wire.AppendVarint(nil, 1, uint64(active)) + quest = wire.AppendVarint(quest, 6, uint64(packID)) + out = wire.AppendBytes(out, 2, quest) + } + cleared := s.state.ClearedQuests(packID) + if len(cleared) != 0 { + var packed []byte + for _, id := range cleared { + packed = binary.AppendUvarint(packed, uint64(id)) + } + out = wire.AppendBytes(out, 3, packed) + } + position := "{}" + if saved, found := s.state.Position(); found && saved.PackID == packID && saved.RawJSON != "" { + position = saved.RawJSON + } + out = wire.AppendString(out, 4, position) + // The remaining starter-only records were observed in the official + // pack21 response. They represent reputation, hunting-ground, statue, and + // reward state, not generic pack defaults, so a newly entered pack22 must + // not inherit them. + if packID != s.seed.PackID { + visit := wire.AppendVarint(nil, 5, uint64(packID)) + return wire.AppendBytes(out, 12, visit) + } + open := wire.AppendVarint(nil, 1, 1) + open = wire.AppendVarint(open, 2, 1) + out = wire.AppendBytes(out, 9, open) + visit := wire.AppendVarint(nil, 5, uint64(packID)) + out = wire.AppendBytes(out, 12, visit) + stat := wire.AppendVarint(nil, 1, 3) + stat = wire.AppendVarint(stat, 2, 77) + stat = wire.AppendVarint(stat, 3, 1) + out = wire.AppendBytes(out, 14, stat) + reward := wire.AppendVarint(nil, 3, 3) + reward = wire.AppendVarint(reward, 4, 150) + group := wire.AppendBytes(nil, 1, reward) + group = wire.AppendBytes(group, 6, reward) + return wire.AppendBytes(out, 16, group) +} + +func (s *Service) firstUnclearedQuest() int { + return s.firstUnclearedQuestFor(s.seed.PackID) +} + +func (s *Service) firstUnclearedQuestFor(packID int) int { + quests, found := s.questsFor(packID) + if !found { + return 0 + } + ids := make([]int, 0, len(quests)) + for id := range quests { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + if !s.state.QuestCleared(id, packID) { + return id + } + } + return 0 +} + +func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Reward, items []player.Item, questEquipment *player.Equipment) []byte { + var rewards []byte + for _, reward := range designRewards { + if reward.Type != 3 && reward.Type != 4 { + continue + } + currency := wire.AppendVarint(nil, 3, reward.Type) + currency = wire.AppendVarint(currency, 4, reward.Count) + rewards = wire.AppendBytes(rewards, 1, currency) + } + for _, item := range items { + entry := player.ItemWire(item) + if item.Type == 17 { + pictorial := wire.AppendVarint(nil, 1, 5) + pictorial = wire.AppendVarint(pictorial, 2, item.ID) + entry = wire.AppendBytes(entry, 7, pictorial) + } + rewards = wire.AppendBytes(rewards, 1, entry) + view := wire.AppendVarint(nil, 2, item.ID) + view = wire.AppendVarint(view, 3, item.Type) + view = wire.AppendVarint(view, 4, item.Count) + rewards = wire.AppendBytes(rewards, 6, view) + } + if questEquipment != nil { + // RewardDBInfoBundle field 4 is EquipDBInfo. Equipment is an instance, + // not an ItemDBInfo with a fabricated stack count. + rewards = wire.AppendBytes(rewards, 4, player.EquipmentWire(*questEquipment)) + } + if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID { + rewardCharacter := encodeCharacter(s.seed.RewardCharacter) + // Pictorial state: acquired level 1, progress 60. + rewardCharacter = wire.AppendBytes(rewardCharacter, 12, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 60)) + rewards = wire.AppendBytes(rewards, 2, rewardCharacter) + costume := encodeCostume(s.seed.RewardCostume) + // Pictorial state observed for the starter costume. + costume = wire.AppendBytes(costume, 5, wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 116)) + rewards = wire.AppendBytes(rewards, 3, costume) + for _, character := range s.seed.StoryCharacters { + view := wire.AppendVarint(nil, 2, character.ID) + view = wire.AppendVarint(view, 3, 6) + rewards = wire.AppendBytes(rewards, 6, view) + } + viewCostume := wire.AppendVarint(nil, 2, s.seed.RewardCostume.ID) + viewCostume = wire.AppendVarint(viewCostume, 3, 11) + rewards = wire.AppendBytes(rewards, 6, viewCostume) + viewCharacter := wire.AppendVarint(nil, 2, s.seed.RewardCharacter.ID) + viewCharacter = wire.AppendVarint(viewCharacter, 3, 6) + viewCharacter = wire.AppendVarint(viewCharacter, 4, 1) + rewards = wire.AppendBytes(rewards, 6, viewCharacter) + } + var out []byte + out = wire.AppendBytes(out, 1, rewards) + next := s.nextQuestFor(packID, quest) + if next != 0 { + out = wire.AppendBytes(out, 2, wire.AppendVarint(nil, 1, uint64(next))) + } else { + // QuestClearResponse.QuestInfo is dereferenced by the 2.34.13 client + // even when this is the final quest of a pack. An explicitly present, + // empty QuestDBInfo gives that generated protobuf property a non-null + // object whose Id is the client-recognized zero sentinel. Omitting the + // field parses as null and makes the completion coroutine throw before + // it can mark the pack complete. A final-pack official capture has not + // yet been obtained, so this exact wire choice remains marked for parity + // verification even though its client behavior is deterministic. + out = wire.AppendBytes(out, 2, nil) + } + out = wire.AppendVarint(out, 3, uint64(quest)) + if next == 0 && s.packCompleteFor(packID) { + // The final normal quest unlocks PackTable.NextPackId. Without these + // PackDBInfo updates the client cannot find the next story pack and + // falls back to presenting the hard-difficulty objective. + for _, info := range s.packDBInfoRows() { + out = wire.AppendBytes(out, 11, info) + } + } + if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID { + deckIDs := []uint64{s.seed.RewardCharacter.InvenIndex, s.seed.StoryCharacters[0].InvenIndex, s.seed.StoryCharacters[1].InvenIndex, s.seed.StoryCharacters[2].InvenIndex, s.starter.Characters[0].InvenIndex} + for index, characterID := range deckIDs { + deck := wire.AppendVarint(nil, 1, characterID) + deck = wire.AppendVarint(deck, 2, ^uint64(0)) + deck = wire.AppendVarint(deck, 3, uint64(index+1)) + out = wire.AppendBytes(out, 4, deck) + } + for _, character := range s.seed.StoryCharacters { + out = wire.AppendBytes(out, 5, encodeCharacter(character)) + } + } else if s.decks != nil { + // Official 2.34.13 QuestClear always echoes the authoritative current + // DeckInfo. Omitting it after quest 29 makes the client rebuild a party + // from ordinary owned characters and overwrite the story formation. + for _, current := range s.decks.CurrentDeck() { + entry := wire.AppendVarint(nil, 1, current.CharacterInvenIndex) + if current.CostumeInvenIndex != 0 { + entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex) + } + entry = wire.AppendVarint(entry, 3, current.Slot) + out = wire.AppendBytes(out, 4, entry) + } + } + out = wire.AppendBytes(out, 12, nil) + return wire.AppendBytes(out, 13, nil) +} + +func (s *Service) packComplete() bool { + return s.packCompleteFor(s.seed.PackID) +} + +func (s *Service) packCompleteFor(packID int) bool { + quests, found := s.questsFor(packID) + if !found || len(quests) == 0 { + return false + } + for id := range quests { + if !s.state.QuestCleared(id, packID) { + return false + } + } + return true +} + +func (s *Service) packDBInfoRows() [][]byte { + var rows [][]byte + packID := s.seed.PackID + for steps := 0; steps < 64 && packID != 0; steps++ { + cleared := s.state.ClearedQuests(packID) + current := wire.AppendVarint(nil, 1, uint64(packID)) + if len(cleared) != 0 { + current = wire.AppendVarint(current, 2, uint64(len(cleared))) + } + complete := s.packCompleteFor(packID) + if complete { + current = wire.AppendVarint(current, 3, 1) + } + // The seed pack was purchased by the account bootstrap. A later pack is + // considered entered once it has its own progress or saved position; + // the newly unlocked, untouched row intentionally remains unpurchased. + purchased := packID == s.seed.PackID || len(cleared) != 0 + if saved, found := s.state.Position(); found && saved.PackID == packID { + purchased = true + } + if purchased { + current = wire.AppendVarint(current, 8, 1) + } + rows = append(rows, current) + if !complete { + break + } + nextPackID := s.transitionFor(packID).NextPackID + if nextPackID == 0 { + break + } + if _, found := s.questsFor(nextPackID); !found { + // Unit-sized services and partial deployments may know the real + // transition before the next QuestTable is attached. Preserve the + // protocol's unlocked row, but packUnlocked still refuses entry until + // that table is actually available. + rows = append(rows, wire.AppendVarint(nil, 1, uint64(nextPackID))) + break + } + packID = nextPackID + } + return rows +} + +func (s *Service) accountPackInfo() []byte { + var out []byte + for _, info := range s.packDBInfoRows() { + out = wire.AppendBytes(out, 1, info) + } + if s.packComplete() { + level := wire.AppendVarint(nil, 1, uint64(s.seed.PackID)) + level = wire.AppendVarint(level, 3, uint64(len(s.state.ClearedQuests(s.seed.PackID)))) + level = wire.AppendVarint(level, 5, 1) + out = wire.AppendBytes(out, 2, level) + } + return wire.AppendVarint(out, 5, 3) +} + +var questPictorialItems = map[int][]gamedata.Reward{ + 3: {{ID: 2101, Type: 17, Count: 1}}, + 10: {{ID: 2102, Type: 17, Count: 1}}, + 28: {{ID: 2103, Type: 17, Count: 1}}, +} + +// PictorialCharacters hides quest-26 rewards until they are earned, even +// though their instances already exist in the versioned world seed. +func (s *Service) PictorialCharacters() []player.Character { + if s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) && s.characters != nil { + return s.characters.RawAll() + } + return append([]player.Character(nil), s.starter.Characters...) +} + +func (s *Service) PictorialCostumes() []player.Costume { + result := append([]player.Costume(nil), s.starter.Costumes...) + if s.collection != nil { + result = s.collection.Costumes() + } + if s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) { + result = append(result, s.seed.RewardCostume) + } + return result +} + +func (s *Service) PictorialItems() []player.Item { + var result []player.Item + if s.inventory != nil { + result = s.inventory.All() + } else { + result = append(result, s.starter.Items...) + } + return result +} + +func (s *Service) PictorialEquipment() []player.Equipment { + if s.equipment == nil { + return nil + } + return s.equipment.All() +} + +func (s *Service) PictorialDiscovered() []player.Pictorial { + return append([]player.Pictorial(nil), s.starter.Pictorialbook...) +} + +func encodeCostume(c player.Costume) []byte { + return player.CostumeWire(c) +} + +func (s *Service) nextQuest(current int) int { + return s.nextQuestFor(s.seed.PackID, current) +} + +func (s *Service) nextQuestFor(packID, current int) int { + quests, found := s.questsFor(packID) + if !found { + return 0 + } + ids := make([]int, 0, len(quests)) + for id := range quests { + if id > current { + ids = append(ids, id) + } + } + sort.Ints(ids) + if len(ids) == 0 { + return 0 + } + return ids[0] +} + +func encodeCharacter(c player.Character) []byte { + return player.CharacterWire(c) +} diff --git a/go/internal/world/world_test.go b/go/internal/world/world_test.go new file mode 100644 index 0000000..5c37e35 --- /dev/null +++ b/go/internal/world/world_test.go @@ -0,0 +1,407 @@ +package world + +import ( + "bytes" + "encoding/binary" + "path/filepath" + "testing" + + "bd2server/internal/deck" + "bd2server/internal/gamedata" + "bd2server/internal/player" + "bd2server/internal/progress" + "bd2server/internal/wire" +) + +func testService() *Service { + return &Service{seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, state: progress.NewStore(), starter: &player.Starter{Version: "2.34.13"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}} +} + +func TestQuest29EchoesCurrentStoryDeck(t *testing.T) { + state := progress.NewStore() + quests := make(map[int]gamedata.QuestDesign) + for id := 1; id <= 30; id++ { + quests[id] = gamedata.QuestDesign{ID: id} + if id < 29 { + if err := state.ClearQuest(id, 21); err != nil { + t.Fatal(err) + } + } + } + decks, err := deck.NewStore(deck.Seed{Version: "2.34.13"}) + if err != nil { + t.Fatal(err) + } + save := wire.AppendVarint(nil, 1, 1) + want := []uint64{535607162, 535607159, 535607160, 535607161, 535604120} + for i, index := range want { + entry := wire.AppendVarint(nil, 1, index) + entry = wire.AppendVarint(entry, 2, uint64(i+1)) + entry = wire.AppendVarint(entry, 3, uint64(i+1)) + save = wire.AppendBytes(save, 2, entry) + } + if _, _, ok, err := decks.Handle("/DeckSave", save); err != nil || !ok { + t.Fatalf("deck save ok=%v err=%v", ok, err) + } + s := &Service{seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, state: state, + starter: &player.Starter{Version: "2.34.13"}, quests: quests, decks: decks} + request := wire.AppendVarint(nil, 1, 2) + request = wire.AppendVarint(request, 2, 29) + request = wire.AppendVarint(request, 3, 21) + _, response, ok, err := s.Handle("/QuestClear", request) + if err != nil || !ok { + t.Fatalf("quest clear ok=%v err=%v", ok, err) + } + var got []uint64 + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 4 { + index, _, _ := wire.Varint(field.Value, 1) + got = append(got, index) + } + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != len(want) { + t.Fatalf("deck rows=%v want=%v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("deck=%v want=%v", got, want) + } + } +} + +func TestInitialPackInfoMatchesVersionedProtocol(t *testing.T) { + s := testService() + request := wire.AppendVarint(nil, 1, 89) + request = wire.AppendVarint(request, 2, 21) + code, got, ok, err := s.Handle("/PackInGameInfo", request) + if err != nil || !ok || code != 5 { + t.Fatalf("Handle: code=%d ok=%v err=%v", code, ok, err) + } + want := []byte{0x12, 4, 8, 1, 0x30, 0x15, 0x22, 2, '{', '}', 0x4a, 4, 8, 1, 0x10, 1, 0x62, 2, 0x28, 0x15, 0x72, 6, 8, 3, 0x10, 0x4d, 0x18, 1, 0x82, 1, 0x0e, 0x0a, 5, 0x18, 3, 0x20, 0x96, 1, 0x32, 5, 0x18, 3, 0x20, 0x96, 1} + if !bytes.Equal(got, want) { + t.Fatalf("pack proto mismatch\ngot %x\nwant %x", got, want) + } +} + +func TestQuestClearAdvancesAndPersists(t *testing.T) { + s := testService() + request := wire.AppendVarint(nil, 1, 107) + request = wire.AppendVarint(request, 2, 1) + request = wire.AppendVarint(request, 3, 21) + code, response, ok, err := s.Handle("/QuestClear", request) + if err != nil || !ok || code != 18 { + t.Fatalf("first clear: code=%d ok=%v err=%v", code, ok, err) + } + cleared, found, err := wire.Varint(response, 3) + if err != nil || !found || cleared != 1 { + t.Fatalf("clear echo: %d %v %v", cleared, found, err) + } + next, found, err := wire.Bytes(response, 2) + if err != nil || !found { + t.Fatalf("next: %v", err) + } + id, found, err := wire.Varint(next, 1) + if err != nil || !found || id != 2 { + t.Fatalf("next id=%d found=%v err=%v", id, found, err) + } + if !s.state.QuestCleared(1, 21) { + t.Fatal("clear was not stored") + } + var deckRows int + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 4 { + deckRows++ + } + return nil + }); err != nil { + t.Fatal(err) + } + if deckRows != 0 { + t.Fatalf("ordinary quest clear unexpectedly replaced deck with %d rows", deckRows) + } + packRequest := wire.AppendVarint(nil, 1, 200) + packRequest = wire.AppendVarint(packRequest, 2, 21) + _, restored, _, err := s.Handle("/PackInGameInfo", packRequest) + if err != nil { + t.Fatal(err) + } + active, found, err := wire.Bytes(restored, 2) + if err != nil || !found { + t.Fatalf("restored active quest missing: %v", err) + } + activeID, found, err := wire.Varint(active, 1) + if err != nil || !found || activeID != 2 { + t.Fatalf("restored active quest=%d found=%v err=%v", activeID, found, err) + } + clearedIDs, found, err := wire.Bytes(restored, 3) + clearedID, count := binary.Uvarint(clearedIDs) + if err != nil || !found || count <= 0 || clearedID != 1 { + t.Fatalf("restored cleared quests=%x found=%v err=%v", clearedIDs, found, err) + } + request = wire.AppendVarint(nil, 1, 108) + request = wire.AppendVarint(request, 2, 3) + request = wire.AppendVarint(request, 3, 21) + if _, _, _, err := s.Handle("/QuestClear", request); err == nil { + t.Fatal("out-of-order clear succeeded") + } +} + +func TestFinalQuestClearIncludesEmptyNextQuestInfo(t *testing.T) { + // In 2.34.13 the completion coroutine unconditionally evaluates + // QuestClearResponse.QuestInfo.Id. An absent protobuf field becomes a + // null C# reference; an explicitly present zero-length message becomes a + // non-null QuestDBInfo with ID 0, satisfying the client's final-quest + // sentinel path. Exact official final-pack wire parity is still unverified. + s := &Service{ + seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 38}, + state: progress.NewStore(), + starter: &player.Starter{Version: "2.34.13"}, + quests: map[int]gamedata.QuestDesign{38: {ID: 38}}, + transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}, + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, 38) + request = wire.AppendVarint(request, 3, 21) + _, response, ok, err := s.Handle("/QuestClear", request) + if err != nil || !ok { + t.Fatalf("final clear ok=%v err=%v", ok, err) + } + var nextCount int + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + nextCount++ + if len(field.Value) != 0 { + t.Fatalf("final next QuestInfo=%x, want explicitly empty message", field.Value) + } + return nil + }); err != nil { + t.Fatal(err) + } + if nextCount != 1 { + t.Fatalf("final clear has %d QuestInfo fields, want one empty message", nextCount) + } + var packs []uint64 + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 11 { + id, _, _ := wire.Varint(field.Value, 1) + packs = append(packs, id) + } + return nil + }); err != nil || len(packs) != 2 || packs[0] != 21 || packs[1] != 22 { + t.Fatalf("final pack transition=%v err=%v", packs, err) + } +} + +func TestPackInfoRestoresCompletedPackAndUnlockedNextPack(t *testing.T) { + state := progress.NewStore() + for _, id := range []int{1, 2} { + if err := state.ClearQuest(id, 21); err != nil { + t.Fatal(err) + } + } + s := &Service{seed: Seed{Version: "2.34.13", PackID: 21}, state: state, + quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}} + code, response, handled, err := s.Handle("/PackInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil || !handled || code != 4 { + t.Fatalf("PackInfo code=%d handled=%v err=%v", code, handled, err) + } + var packs []uint64 + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 1 { + id, _, _ := wire.Varint(field.Value, 1) + packs = append(packs, id) + if id == 22 { + bought, _, _ := wire.Varint(field.Value, 8) + if bought != 0 { + t.Fatalf("newly unlocked pack22 is already marked bought") + } + } + } + return nil + }); err != nil || len(packs) != 2 || packs[0] != 21 || packs[1] != 22 { + t.Fatalf("PackInfo packs=%v err=%v", packs, err) + } +} + +func TestPack22InitializesWithIndependentQuestIdentity(t *testing.T) { + state := progress.NewStore() + if err := state.ClearQuest(1, 21); err != nil { + t.Fatal(err) + } + pack21 := map[int]gamedata.QuestDesign{1: {ID: 1}} + pack22 := map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}} + s := &Service{ + seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, + state: state, + starter: &player.Starter{Version: "2.34.13"}, + quests: pack21, + packs: map[int]map[int]gamedata.QuestDesign{21: pack21, 22: pack22}, + transitions: map[int]gamedata.PackTransition{ + 21: {PackID: 21, NextPackID: 22}, + 22: {PackID: 22}, + }, + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, 22) + code, response, handled, err := s.Handle("/PackInGameInfo", request) + if err != nil || !handled || code != 5 { + t.Fatalf("pack22 init code=%d handled=%v err=%v", code, handled, err) + } + active, found, err := wire.Bytes(response, 2) + if err != nil || !found { + t.Fatalf("pack22 active quest missing: %v", err) + } + id, _, _ := wire.Varint(active, 1) + packID, _, _ := wire.Varint(active, 6) + if id != 1 || packID != 22 { + t.Fatalf("pack22 active quest id=%d pack=%d", id, packID) + } + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 1 || field.Number == 9 || field.Number == 14 || field.Number == 16 { + t.Fatalf("pack22 inherited pack21-only field %d", field.Number) + } + return nil + }); err != nil { + t.Fatal(err) + } + clear := wire.AppendVarint(nil, 1, 2) + clear = wire.AppendVarint(clear, 2, 1) + clear = wire.AppendVarint(clear, 3, 22) + if _, _, _, err := s.Handle("/QuestClear", clear); err != nil { + t.Fatal(err) + } + if !state.QuestCleared(1, 21) || !state.QuestCleared(1, 22) { + t.Fatal("quest 1 did not remain independently cleared in pack21 and pack22") + } +} + +func TestQuest28GrantsEquipmentInRewardBundle(t *testing.T) { + state := progress.NewStore() + quests := make(map[int]gamedata.QuestDesign) + for id := 1; id <= 29; id++ { + quests[id] = gamedata.QuestDesign{ID: id} + if id < 28 { + if err := state.ClearQuest(id, 21); err != nil { + t.Fatal(err) + } + } + } + equipment, err := player.OpenEquipmentInventory(filepath.Join(t.TempDir(), "equipment.json")) + if err != nil { + t.Fatal(err) + } + starter := &player.Starter{Version: "2.34.13"} + inventory, err := player.OpenInventory(filepath.Join(t.TempDir(), "items.json"), starter) + if err != nil { + t.Fatal(err) + } + entry := quests[28] + entry.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}, {Type: 10, ID: 10010}} + quests[28] = entry + s := &Service{seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, state: state, + starter: starter, equipment: equipment, inventory: inventory, quests: quests} + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, 28) + request = wire.AppendVarint(request, 3, 21) + code, response, ok, err := s.Handle("/QuestClear", request) + if err != nil || !ok || code != 18 { + t.Fatalf("clear: code=%d ok=%v err=%v", code, ok, err) + } + bundle, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("reward bundle: %v", err) + } + encoded, found, err := wire.Bytes(bundle, 4) + if err != nil || !found { + t.Fatalf("equipment reward: %v", err) + } + base, found, err := wire.Bytes(encoded, 5) + if err != nil || !found { + t.Fatalf("equipment base: %v", err) + } + id, found, err := wire.Varint(base, 1) + if err != nil || !found || id != 10010 { + t.Fatalf("equipment id=%d found=%v err=%v", id, found, err) + } +} + +func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) { + dir := t.TempDir() + state := progress.NewStore() + quests := make(map[int]gamedata.QuestDesign) + for id := 1; id <= 27; id++ { + quests[id] = gamedata.QuestDesign{ID: id} + if id < 27 { + if err := state.ClearQuest(id, 21); err != nil { + t.Fatal(err) + } + } + } + design := quests[27] + design.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}} + quests[27] = design + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{}) + if err != nil { + t.Fatal(err) + } + s := &Service{seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, state: state, + starter: &player.Starter{Version: "2.34.13"}, wallet: wallet, quests: quests} + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, 27) + request = wire.AppendVarint(request, 3, 21) + _, response, _, err := s.Handle("/QuestClear", request) + if err != nil { + t.Fatal(err) + } + if got := wallet.Snapshot().FreeJewelry; got != 70 { + t.Fatalf("free jewelry=%d", got) + } + bundle, _, _ := wire.Bytes(response, 1) + item, _, _ := wire.Bytes(bundle, 1) + typ, _, _ := wire.Varint(item, 3) + count, _, _ := wire.Varint(item, 4) + if typ != 3 || count != 70 { + t.Fatalf("currency type=%d count=%d", typ, count) + } +} + +func TestPackInfoUsesPersistedCharacterLevel(t *testing.T) { + dir := t.TempDir() + starter := &player.Starter{Version: "2.34.13"} + inventory, err := player.OpenInventory(filepath.Join(dir, "items.json"), starter) + if err != nil { + t.Fatal(err) + } + characters, err := player.OpenCharacterStore(filepath.Join(dir, "characters.json"), + []player.Character{{InvenIndex: 77, ID: 350, Level: 20}}, inventory, "", "") + if err != nil { + t.Fatal(err) + } + state := progress.NewStore() + if err := state.ClearQuest(26, 21); err != nil { + t.Fatal(err) + } + s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26, RewardCharacter: player.Character{InvenIndex: 77, ID: 350, Level: 1}}, state: state, starter: starter, characters: characters} + response := s.packInfo() + var level uint64 + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number != 1 { + return nil + } + index, _, _ := wire.Varint(field.Value, 1) + if index == 77 { + level, _, _ = wire.Varint(field.Value, 4) + } + return nil + }); err != nil { + t.Fatal(err) + } + if level != 20 { + t.Fatalf("pack character level=%d want20", level) + } +} diff --git a/go/seed/v2_34_13/decks.json b/go/seed/v2_34_13/decks.json new file mode 100644 index 0000000..a2396da --- /dev/null +++ b/go/seed/v2_34_13/decks.json @@ -0,0 +1,12 @@ +{ + "version": "2.34.13", + "field_deck": [ + {"slot": 1, "character_inven_index": 535604120, "costume_inven_index": 609336981}, + {"slot": 2, "character_inven_index": 535604119, "costume_inven_index": 609336980}, + {"slot": 3, "character_inven_index": 535604118, "costume_inven_index": 609336979}, + {"slot": 4, "character_inven_index": 535604117, "costume_inven_index": 609336978}, + {"slot": 5, "character_inven_index": 535604116, "costume_inven_index": 609336977} + ], + "field_char_control_deck_type": 2, + "auto_revive_catalyst": 200 +} diff --git a/go/seed/v2_34_13/login_user.json b/go/seed/v2_34_13/login_user.json new file mode 100644 index 0000000..4a06f6b --- /dev/null +++ b/go/seed/v2_34_13/login_user.json @@ -0,0 +1,6 @@ +{ + "version": "2.34.13", + "packet_code": 3, + "user_info_base64": "CJqO1A4SDkd1ZXN0XzMwNzM4MjAyKGQwZDiQTlD0A2gBeGTgAQXqAQIQAeoBDAgCEAEaBjEwLDEuNeoBBAgDEAHqAQQIBBAB6gEKCAUQARoEMzAsM+oBBwgGEAEaATXqAQcICBABGgEw6gEECAkQAeoBBAgKEAH4AYDLqrEEgAIDiAIFsAKQ5vPTizS4AgHYAtKFj9SLNKADBagDBcgDBfADCvgDHoAEHogEyAE=", + "response_fields_base64": "IgBQAVgBYAFoAQ==" +} diff --git a/go/seed/v2_34_13/mail.json b/go/seed/v2_34_13/mail.json new file mode 100644 index 0000000..af5b4d4 --- /dev/null +++ b/go/seed/v2_34_13/mail.json @@ -0,0 +1,70 @@ +{ + "version": "2.34.13", + "mails": [ + { + "mail_id": 13042978139, + "mail_type": 2, + "title": "Special Reward for New Users", + "body": "Your reward has arrived.", + "expires_at": 1792427149000, + "reward_types": [8, 8, 8, 8, 8], + "reward_ids": [1000, 1000, 1003, 1003, 1004], + "reward_counts": [1, 1, 1, 1, 1], + "sent_at": 1789835149000 + }, + { + "mail_id": 13042978140, + "mail_type": 2, + "title": "社群 Boost 活动第 3 阶段达成奖励", + "body": "现已发放社群 Boost 活动第 3 阶段达成奖励。\n也请今后继续多多关注", + "expires_at": 1792427149000, + "reward_types": [8], + "reward_ids": [1000], + "reward_counts": [10], + "sent_at": 1789835149000 + }, + { + "mail_id": 13042978141, + "mail_type": 2, + "title": "联动 PV 分享活动奖励", + "body": "今后也请多多参与各种活动。", + "expires_at": 1792427149000, + "reward_types": [3], + "reward_ids": [0], + "reward_counts": [1000], + "sent_at": 1789835149000 + }, + { + "mail_id": 13043056707, + "template_id": 5, + "sender": "0", + "expires_at": 1790440414000, + "reward_types": [4], + "reward_ids": [0], + "reward_counts": [5000], + "sent_at": 1789835614000 + }, + { + "mail_id": 13043056708, + "template_id": 429, + "sender": "0", + "expires_at": 1790440414000, + "reward_types": [8], + "reward_ids": [9], + "reward_counts": [10], + "sent_at": 1789835614000 + }, + { + "mail_id": 13043056709, + "template_id": 429, + "sender": "0", + "expires_at": 1790440414000, + "reward_types": [8], + "reward_ids": [2063], + "reward_counts": [7], + "sent_at": 1789835614000 + } + ], + "mail_count": 7, + "max_mail_id": 13043056709 +} diff --git a/go/seed/v2_34_13/readonly.json b/go/seed/v2_34_13/readonly.json new file mode 100644 index 0000000..da86f55 --- /dev/null +++ b/go/seed/v2_34_13/readonly.json @@ -0,0 +1,3686 @@ +{ + "version": "2.34.13", + "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 + } + ] + }, + "/CharScoutInfo": { + "packet_code": 0, + "fields": [ + { + "number": 1, + "type": 2, + "bytes": "6jDALw==" + }, + { + "number": 3, + "type": 0, + "varint": 1789842805819 + } + ] + }, + "/ColosseumUserInfo": { + "packet_code": 473, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 42 + }, + { + "number": 4, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 1000 + }, + { + "number": 5, + "type": 1, + "fixed64_le": 4636737291354636288 + } + ] + } + ] + }, + "/EventExchangeInfo": { + "packet_code": 183, + "fields": [ + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 1676 + }, + { + "number": 2, + "type": 0, + "varint": 27 + }, + { + "number": 3, + "type": 0, + "varint": 1 + }, + { + "number": 5, + "type": 0, + "varint": 1 + }, + { + "number": 6, + "type": 0, + "varint": 1789835605000 + } + ] + } + ] + }, + "/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": [ + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 2, + "type": 0, + "varint": 1 + }, + { + "number": 3, + "type": 0, + "varint": 1 + }, + { + "number": 4, + "type": 0, + "varint": 1 + }, + { + "number": 5, + "type": 0, + "varint": 2259729 + }, + { + "number": 6, + "type": 0, + "varint": 1789862400000 + } + ] + }, + { + "number": 2, + "type": 2, + "bytes": "AQ==" + }, + { + "number": 4, + "type": 2, + "fields": [ + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 2259729 + }, + { + "number": 2, + "type": 0, + "varint": 1 + }, + { + "number": 3, + "type": 0, + "varint": 57 + }, + { + "number": 4, + "type": 0, + "varint": 1 + }, + { + "number": 6, + "type": 0, + "varint": 1789835605000 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 2025894 + }, + { + "number": 2, + "type": 0, + "varint": 1 + }, + { + "number": 3, + "type": 0, + "varint": 58 + }, + { + "number": 4, + "type": 0, + "varint": 1 + }, + { + "number": 6, + "type": 0, + "varint": 1789835605000 + } + ] + } + ] + } + ] + }, + "/MiniGameDefenseInfo": { + "packet_code": 393, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 870 + } + ] + }, + "/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 + } + ] + } + ] + }, + "/MonsterHuntUserInfo": { + "packet_code": 196, + "fields": [ + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 78 + }, + { + "number": 2, + "type": 0, + "varint": 73 + }, + { + "number": 3, + "type": 0, + "varint": 1 + }, + { + "number": 4, + "type": 0, + "varint": 5500 + }, + { + "number": 6, + "type": 0, + "varint": 5500 + }, + { + "number": 7, + "type": 0, + "varint": 1789835605000 + } + ] + } + ] + }, + "/PassInfo": { + "packet_code": 124, + "fields": [ + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 138 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 136 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 140 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 139 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 1 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 35 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 36 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 37 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 76 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 77 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 78 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 79 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 80 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 81 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 82 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 83 + } + ] + } + ] + }, + "/PvpBattleUserInfo": { + "packet_code": 86, + "fields": [ + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 1000 + } + ] + }, + { + "number": 3, + "type": 0, + "varint": 30 + }, + { + "number": 4, + "type": 0, + "varint": 1 + } + ] + }, + "/RoomChatReportAndBlockInfo": { + "packet_code": 521, + "fields": [ + { + "number": 2, + "type": 2, + "fields": [ + { + "number": 2, + "type": 0, + "varint": 1789862400000 + } + ] + }, + { + "number": 3, + "type": 2 + } + ] + }, + "/SeasonRewardInfo": { + "packet_code": 0, + "fields": [ + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 3003 + }, + { + "number": 2, + "type": 0, + "varint": 57 + }, + { + "number": 3, + "type": 0, + "varint": 1 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 3001 + }, + { + "number": 2, + "type": 0, + "varint": 159 + }, + { + "number": 3, + "type": 0, + "varint": 1 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 3008 + }, + { + "number": 2, + "type": 0, + "varint": 41 + }, + { + "number": 3, + "type": 0, + "varint": 1 + } + ] + } + ] + }, + "/SkyWayScheduleInfo": { + "packet_code": 162, + "fields": [ + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 1 + }, + { + "number": 2, + "type": 2, + "bytes": "AgQFBgA=" + }, + { + "number": 3, + "type": 0, + "varint": 100 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 2 + }, + { + "number": 2, + "type": 2, + "bytes": "AQMFBgA=" + }, + { + "number": 3, + "type": 0, + "varint": 100 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 3 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 4 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 5 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 6 + } + ] + }, + { + "number": 1, + "type": 2, + "fields": [ + { + "number": 1, + "type": 0, + "varint": 7 + } + ] + } + ] + } + } +} diff --git a/go/seed/v2_34_13/starter_player.json b/go/seed/v2_34_13/starter_player.json new file mode 100644 index 0000000..1c24d4c --- /dev/null +++ b/go/seed/v2_34_13/starter_player.json @@ -0,0 +1,126 @@ +{ + "version": "2.34.13", + "items": [ + { + "inven_index": 853258187, + "id": 1, + "type": 14, + "count": 1, + "time_value": 1789835146000, + "pictorialbook": {"id": 4, "group_id": 1} + }, + { + "inven_index": 72020332, + "id": 27, + "type": 13, + "count": 1, + "time_value": 1789835146000, + "pictorialbook": {"id": 5, "group_id": 30021} + }, + { + "inven_index": 0, + "id": 1, + "type": 25, + "count": 1, + "time_value": 1789835146000 + }, + { + "inven_index": 0, + "id": 181, + "type": 25, + "count": 1, + "time_value": 1789835146000 + } + ], + "costumes": [ + { + "inven_index": 609336977, + "id": 64701, + "use_char": 535604116, + "time_value": 1789835146000 + }, + { + "inven_index": 609336978, + "id": 64801, + "use_char": 535604117, + "time_value": 1789835146000 + }, + { + "inven_index": 609336979, + "id": 64901, + "use_char": 535604118, + "time_value": 1789835146000 + }, + { + "inven_index": 609336980, + "id": 65001, + "use_char": 535604119, + "time_value": 1789835146000 + }, + { + "inven_index": 609336981, + "id": 60101, + "use_char": 535604120, + "time_value": 1789835146000 + } + ], + "characters": [ + { + "inven_index": 535604120, + "id": 6010, + "hp": 7, + "level": 1, + "use_costume": 609336981, + "talent_level": 1, + "solidarity_reward": 1, + "expiry_time": 18446744073677151616, + "connect_potential_costume": 60101 + }, + { + "inven_index": 535604116, + "id": 6470, + "hp": 264, + "level": 1, + "use_costume": 609336977, + "talent_level": 1, + "solidarity_reward": 1, + "expiry_time": 18446744073677151616, + "connect_potential_costume": 64701 + }, + { + "inven_index": 535604117, + "id": 6480, + "hp": 181, + "level": 1, + "use_costume": 609336978, + "talent_level": 1, + "solidarity_reward": 1, + "expiry_time": 18446744073677151616, + "connect_potential_costume": 64801 + }, + { + "inven_index": 535604118, + "id": 6490, + "hp": 164, + "level": 1, + "use_costume": 609336979, + "talent_level": 1, + "solidarity_reward": 1, + "expiry_time": 18446744073677151616, + "connect_potential_costume": 64901 + }, + { + "inven_index": 535604119, + "id": 6500, + "hp": 160, + "level": 1, + "use_costume": 609336980, + "talent_level": 1, + "solidarity_reward": 1, + "expiry_time": 18446744073677151616, + "connect_potential_costume": 65001 + } + ], + "pictorialbook": [{"id": 2, "group_id": 1}], + "field_char_control_deck_type": 2 +} diff --git a/go/seed/v2_34_13/world.json b/go/seed/v2_34_13/world.json new file mode 100644 index 0000000..8a0fe2a --- /dev/null +++ b/go/seed/v2_34_13/world.json @@ -0,0 +1,27 @@ +{ + "version": "2.34.13", + "pack_id": 21, + "start_quest_id": 1, + "battle_unlock_quest_id": 26, + "reward_character": { + "inven_index": 535607162, + "id": 350, + "hp": 122, + "level": 1, + "use_costume": 609338889, + "talent_level": 1, + "solidarity_reward": 1, + "expiry_time": 18446744073677151616, + "connect_potential_costume": 3501 + }, + "reward_costume": { + "inven_index": 609338889, + "id": 3501, + "use_char": 535607162 + }, + "story_characters": [ + {"inven_index": 535607159, "id": 10383, "hp": 462, "level": 15, "talent_level": 1, "solidarity_reward": 1, "expiry_time": 18446744073677151616}, + {"inven_index": 535607160, "id": 10372, "hp": 177, "level": 15, "talent_level": 1, "solidarity_reward": 1, "expiry_time": 18446744073677151616}, + {"inven_index": 535607161, "id": 10412, "hp": 187, "level": 15, "talent_level": 1, "solidarity_reward": 1, "expiry_time": 18446744073677151616} + ] +} diff --git a/haskell/cabal.project b/haskell/cabal.project new file mode 100644 index 0000000..8f1579d --- /dev/null +++ b/haskell/cabal.project @@ -0,0 +1,5 @@ +packages: state-migrator + +package state-migrator + optimization: 1 + tests: True diff --git a/haskell/hie.yaml b/haskell/hie.yaml new file mode 100644 index 0000000..dd0d63d --- /dev/null +++ b/haskell/hie.yaml @@ -0,0 +1,21 @@ +cradle: + multi: + - path: "./state-migrator/Setup.hs" + config: + cradle: + none: + - path: "./state-migrator/src" + config: + cradle: + cabal: + component: "lib:bd2-state-migrator" + - path: "./state-migrator/app" + config: + cradle: + cabal: + component: "exe:bd2-state" + - path: "./state-migrator/test" + config: + cradle: + cabal: + component: "test:state-migrator-test" diff --git a/haskell/state-migrator/Setup.hs b/haskell/state-migrator/Setup.hs new file mode 100644 index 0000000..85e61ae --- /dev/null +++ b/haskell/state-migrator/Setup.hs @@ -0,0 +1,11 @@ +import Data.ProtoLens.Setup (defaultMainGeneratingSpecificProtos) + +main :: IO () +main = + defaultMainGeneratingSpecificProtos "../../proto" $ \_ -> + pure + [ "bd2/state/v1/state.proto" + , "bd2/state/control/v1/control.proto" + , "bd2/state/v2/state.proto" + , "bd2/state/control/v2/control.proto" + ] diff --git a/haskell/state-migrator/app/Main.hs b/haskell/state-migrator/app/Main.hs new file mode 100644 index 0000000..2ed34e0 --- /dev/null +++ b/haskell/state-migrator/app/Main.hs @@ -0,0 +1,104 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Main (main) where + +import BD2.State.Domain +import BD2.State.Framing +import BD2.State.Migrate.V1ToV2 +import BD2.State.Repair.Pipeline +import BD2.State.Validate +import qualified Data.ByteString as BS +import Data.ProtoLens (defMessage) +import Lens.Family2 ((&), (.~), (^.)) +import qualified Proto.Bd2.State.Control.V1.Control as Control +import qualified Proto.Bd2.State.Control.V1.Control_Fields as ControlFields +import qualified Proto.Bd2.State.Control.V2.Control as ControlV2 +import qualified Proto.Bd2.State.Control.V2.Control_Fields as ControlV2Fields +import System.Environment (getArgs) +import System.Exit (exitFailure) +import System.IO (hPutStrLn, stderr) + +main :: IO () +main = do + args <- getArgs + input <- BS.getContents + case args of + [] -> runCheck input + ["check"] -> runCheck input + ["migrate-v1-v2"] -> runMigrateV1ToV2 input + ["repair"] -> runRepair input + _ -> hPutStrLn stderr "usage: bd2-state [check|migrate-v1-v2|repair]" >> exitFailure + +runCheck :: BS.ByteString -> IO () +runCheck input = + case (decodeDelimited input :: Either String Control.CheckRequest) of + Left err -> hPutStrLn stderr ("bd2-state: " <> err) >> exitFailure + Right request -> do + let (problems, _) = validateRequest request + response :: Control.CheckResponse + response = + defMessage + & ControlFields.bridgeApiVersion .~ 1 + & ControlFields.sourceSha256 .~ (request ^. ControlFields.sourceSha256) + & ControlFields.violations .~ map problemToViolation problems + BS.putStr (encodeDelimited response) + +runRepair :: BS.ByteString -> IO () +runRepair input = + case (decodeDelimited input :: Either String ControlV2.RepairRequest) of + Left err -> hPutStrLn stderr ("bd2-state: " <> err) >> exitFailure + Right request -> do + let requestProblems = + [ Problem "bridge.version" Error ["bridge_api_version"] "bridge API version must be 2" [] + | request ^. ControlV2Fields.bridgeApiVersion /= 2 + ] + <> [ Problem "bridge.source_hash" Error ["source_sha256"] "source SHA-256 must contain 32 bytes" [] + | BS.length (request ^. ControlV2Fields.sourceSha256) /= 32 + ] + outcome = if null requestProblems then repairV1ToCurrent request else Left requestProblems + responseBase :: ControlV2.RepairResponse + responseBase = defMessage + & ControlV2Fields.bridgeApiVersion .~ 2 + & ControlV2Fields.sourceSha256 .~ (request ^. ControlV2Fields.sourceSha256) + response = case outcome of + Left problems -> responseBase & ControlV2Fields.violations .~ map problemToViolation problems + Right (target, changed) -> responseBase + & ControlV2Fields.repaired .~ True + & ControlV2Fields.changed .~ changed + & ControlV2Fields.target .~ target + BS.putStr (encodeDelimited response) + +runMigrateV1ToV2 :: BS.ByteString -> IO () +runMigrateV1ToV2 input = + case (decodeDelimited input :: Either String ControlV2.MigrateV1ToV2Request) of + Left err -> hPutStrLn stderr ("bd2-state: " <> err) >> exitFailure + Right request -> do + let source = request ^. ControlV2Fields.source + sourceProblems = + [ Problem "bridge.version" Error ["bridge_api_version"] "bridge API version must be 2" [] + | request ^. ControlV2Fields.bridgeApiVersion /= 2 + ] + <> [ Problem "bridge.source_hash" Error ["source_sha256"] "source SHA-256 must contain 32 bytes" [] + | BS.length (request ^. ControlV2Fields.sourceSha256) /= 32 + ] + outcome = if null sourceProblems then migrateV1ToV2 source else Left sourceProblems + responseBase :: ControlV2.MigrateV1ToV2Response + responseBase = + defMessage + & ControlV2Fields.bridgeApiVersion .~ 2 + & ControlV2Fields.sourceSha256 .~ (request ^. ControlV2Fields.sourceSha256) + response = case outcome of + Left problems -> responseBase & ControlV2Fields.violations .~ map problemToViolation problems + Right target -> responseBase & ControlV2Fields.migrated .~ True & ControlV2Fields.target .~ target + BS.putStr (encodeDelimited response) + +problemToViolation :: Problem -> Control.Violation +problemToViolation problem = + defMessage + & ControlFields.code .~ problemCode problem + & ControlFields.severity .~ case problemSeverity problem of + Warning -> Control.SEVERITY_WARNING + Error -> Control.SEVERITY_ERROR + & ControlFields.path .~ problemPath problem + & ControlFields.message .~ problemMessage problem + & ControlFields.relatedIds .~ problemRelatedIds problem diff --git a/haskell/state-migrator/src/BD2/State/Domain.hs b/haskell/state-migrator/src/BD2/State/Domain.hs new file mode 100644 index 0000000..ffdd0d8 --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Domain.hs @@ -0,0 +1,61 @@ +{-# LANGUAGE DeriveGeneric #-} + +module BD2.State.Domain + ( InventoryIndex (..) + , PackId (..) + , QuestId (..) + , QuestKey (..) + , ValidSnapshot (..) + , CharacterRedirect (..) + , Problem (..) + , Severity (..) + ) where + +import Data.Text (Text) +import Data.Word (Word32, Word64) +import GHC.Generics (Generic) +import qualified Proto.Bd2.State.V1.State as State + +newtype InventoryIndex = InventoryIndex Word64 + deriving stock (Eq, Ord, Show, Generic) + +newtype PackId = PackId Word32 + deriving stock (Eq, Ord, Show, Generic) + +newtype QuestId = QuestId Word32 + deriving stock (Eq, Ord, Show, Generic) + +data QuestKey = QuestKey + { packId :: PackId + , questId :: QuestId + } + deriving stock (Eq, Ord, Show, Generic) + +-- Validated semantic view used by migrations. The original DTO is retained +-- for lossless pass-through, while ownership is no longer inferred again by +-- each migration from two unrelated protobuf containers. +data ValidSnapshot = ValidSnapshot + { validatedSource :: State.Snapshot + , validatedBaseCharacters :: [State.Character] + , validatedAcquiredCharacters :: [State.Character] + , validatedCostumes :: [State.Costume] + } + +data CharacterRedirect = CharacterRedirect + { redirectCharacterId :: Word64 + , redirectFromIndex :: Word64 + , redirectToIndex :: Word64 + } + deriving stock (Eq, Ord, Show, Generic) + +data Severity = Warning | Error + deriving stock (Eq, Ord, Show, Generic) + +data Problem = Problem + { problemCode :: Text + , problemSeverity :: Severity + , problemPath :: [Text] + , problemMessage :: Text + , problemRelatedIds :: [Word64] + } + deriving stock (Eq, Show, Generic) diff --git a/haskell/state-migrator/src/BD2/State/Framing.hs b/haskell/state-migrator/src/BD2/State/Framing.hs new file mode 100644 index 0000000..461cd42 --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Framing.hs @@ -0,0 +1,46 @@ +module BD2.State.Framing + ( decodeDelimited + , encodeDelimited + ) where + +import Data.Bits ((.&.), (.|.), shiftL, shiftR) +import qualified Data.ByteString as BS +import Data.ProtoLens (Message, decodeMessage, encodeMessage) +import Data.Word (Word64, Word8) + +maxFrameBytes :: Word64 +maxFrameBytes = 16 * 1024 * 1024 + +decodeDelimited :: Message a => BS.ByteString -> Either String a +decodeDelimited input = do + (size, prefixLength) <- decodeLength input + if size > maxFrameBytes + then Left "frame exceeds 16 MiB" + else do + let payload = BS.take (fromIntegral size) (BS.drop prefixLength input) + trailing = BS.drop (prefixLength + fromIntegral size) input + if BS.length payload /= fromIntegral size + then Left "truncated protobuf frame" + else if not (BS.null trailing) + then Left "trailing bytes after protobuf frame" + else decodeMessage payload + +encodeDelimited :: Message a => a -> BS.ByteString +encodeDelimited value = + let payload = encodeMessage value + in BS.pack (encodeLength (fromIntegral (BS.length payload))) <> payload + +decodeLength :: BS.ByteString -> Either String (Word64, Int) +decodeLength = go 0 0 0 . BS.unpack + where + go :: Word64 -> Int -> Int -> [Word8] -> Either String (Word64, Int) + go _ _ _ [] = Left "missing frame length" + go value shift count (byte : rest) + | count >= 9 = Left "frame length varint is too long" + | byte .&. 0x80 == 0 = Right (value .|. (fromIntegral byte `shiftL` shift), count + 1) + | otherwise = go (value .|. (fromIntegral (byte .&. 0x7f) `shiftL` shift)) (shift + 7) (count + 1) rest + +encodeLength :: Word64 -> [Word8] +encodeLength value + | value < 0x80 = [fromIntegral value] + | otherwise = (fromIntegral (value .&. 0x7f) .|. 0x80) : encodeLength (value `shiftR` 7) diff --git a/haskell/state-migrator/src/BD2/State/Migrate/V1ToV2.hs b/haskell/state-migrator/src/BD2/State/Migrate/V1ToV2.hs new file mode 100644 index 0000000..1d2d2e0 --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Migrate/V1ToV2.hs @@ -0,0 +1,218 @@ +{-# LANGUAGE OverloadedStrings #-} + +module BD2.State.Migrate.V1ToV2 + ( migrateV1ToV2 + , migrateV1ToV2ForRepair + , migrateCurrentV2 + , validateV2 + ) where + +import BD2.State.Domain +import BD2.State.Validate (validateSnapshot) +import Data.Foldable (toList) +import Data.List (group, sort) +import Data.ProtoLens (defMessage) +import qualified Data.Set as Set +import Data.Text (Text) +import qualified Data.Text as Text +import Data.Word (Word64) +import Lens.Family2 ((&), (.~), (^.)) +import qualified Proto.Bd2.State.V1.State as V1 +import qualified Proto.Bd2.State.V1.State_Fields as V1Fields +import qualified Proto.Bd2.State.V2.State as V2 +import qualified Proto.Bd2.State.V2.State_Fields as V2Fields + +-- | Pure schema migration. It does not repair or allocate identities: an +-- invalid V1 snapshot is rejected, and every durable entity/index is copied. +migrateV1ToV2 :: V1.Snapshot -> Either [Problem] V2.Snapshot +migrateV1ToV2 source = + case validateSnapshot source of + (problems, Nothing) -> Left problems + (_, Just valid) -> + let target = buildTarget valid + targetProblems = validateV2 target + in if null targetProblems then Right target else Left targetProblems + +-- Repair entry accepts only stale allocator cursors; every other V1 error is +-- still fatal. The repair pipeline must advance these cursors before V2 is +-- validated or returned. +migrateV1ToV2ForRepair :: V1.Snapshot -> Either [Problem] V2.Snapshot +migrateV1ToV2ForRepair source = + let (problems, _) = validateSnapshot source + fatal = filter (not . repairableV1Problem . problemCode) problems + collection = source ^. V1Fields.collection + valid = ValidSnapshot source + (toList (source ^. V1Fields.characters . V1Fields.characters)) + (toList (collection ^. V1Fields.characters)) + (toList (collection ^. V1Fields.costumes)) + in if null fatal then Right (buildTarget valid) else Left fatal + +repairableV1Problem :: Text -> Bool +repairableV1Problem code = code `elem` + [ "inventory.next_index" + , "inventory.grant_invalid_index" + , "equipment.next_index" + , "collection.next_character_index" + , "collection.next_costume_index" + ] + +-- | The current-version migration is deliberately the identity after +-- validation. This is the executable idempotence law used at startup later. +migrateCurrentV2 :: V2.Snapshot -> Either [Problem] V2.Snapshot +migrateCurrentV2 snapshot = + case validateV2 snapshot of + [] -> Right snapshot + problems -> Left problems + +buildTarget :: ValidSnapshot -> V2.Snapshot +buildTarget valid = + let source = validatedSource valid + oldCollection = source ^. V1Fields.collection + baseCharacters = map (characterRecord V2.CHARACTER_ORIGIN_BASE) (validatedBaseCharacters valid) + acquiredCharacters = map (characterRecord V2.CHARACTER_ORIGIN_ACQUIRED) (validatedAcquiredCharacters valid) + roster = + defMessage + & V2Fields.nextAcquiredCharacterIndex .~ (oldCollection ^. V1Fields.nextCharacterIndex) + & V2Fields.nextCostumeIndex .~ (oldCollection ^. V1Fields.nextCostumeIndex) + & V2Fields.characters .~ (baseCharacters <> acquiredCharacters) + & V2Fields.costumes .~ validatedCostumes valid + collection = + defMessage + & V2Fields.latestPreview .~ toList (oldCollection ^. V1Fields.latestPreview) + & V2Fields.previewEventIndex .~ (oldCollection ^. V1Fields.previewEventIndex) + & V2Fields.previewLocked .~ (oldCollection ^. V1Fields.previewLocked) + & V2Fields.baseCostumeLevels .~ toList (oldCollection ^. V1Fields.baseCostumeLevels) + & V2Fields.gachaSelections .~ toList (oldCollection ^. V1Fields.gachaSelections) + & V2Fields.stepUpProgress .~ toList (oldCollection ^. V1Fields.stepUpProgress) + & V2Fields.gachaUsers .~ toList (oldCollection ^. V1Fields.gachaUsers) + & V2Fields.gachaFixed .~ toList (oldCollection ^. V1Fields.gachaFixed) + & V2Fields.gachaApplied .~ toList (oldCollection ^. V1Fields.gachaApplied) + & V2Fields.gachaPointExchanges .~ toList (oldCollection ^. V1Fields.gachaPointExchanges) + & V2Fields.gachaCountCorrected .~ (oldCollection ^. V1Fields.gachaCountCorrected) + & V2Fields.grants .~ toList (oldCollection ^. V1Fields.grants) + in defMessage + & V2Fields.formatVersion .~ 2 + & V2Fields.clientVersion .~ (source ^. V1Fields.clientVersion) + & V2Fields.gameDataVersion .~ (source ^. V1Fields.gameDataVersion) + & V2Fields.progress .~ (source ^. V1Fields.progress) + & V2Fields.deck .~ (source ^. V1Fields.deck) + & V2Fields.inventory .~ (source ^. V1Fields.inventory) + & V2Fields.equipment .~ (source ^. V1Fields.equipment) + & V2Fields.roster .~ roster + & V2Fields.collection .~ collection + & V2Fields.wallet .~ (source ^. V1Fields.wallet) + & V2Fields.mail .~ (source ^. V1Fields.mail) + & V2Fields.missions .~ (source ^. V1Fields.missions) + +characterRecord :: V2.CharacterOrigin -> V1.Character -> V2.CharacterRecord +characterRecord origin character = + defMessage & V2Fields.character .~ character & V2Fields.origin .~ origin + +validateV2 :: V2.Snapshot -> [Problem] +validateV2 snapshot = + let roster = snapshot ^. V2Fields.roster + records = toList (roster ^. V2Fields.characters) + projectionProblems = fst (validateSnapshot (projectV1ForSharedValidation snapshot)) + characterIndices = map (^. V2Fields.character . V1Fields.inventoryIndex) records + acquiredIndices = + [ record ^. V2Fields.character . V1Fields.inventoryIndex + | record <- records + , record ^. V2Fields.origin == V2.CHARACTER_ORIGIN_ACQUIRED + ] + costumeIndices = map (^. V1Fields.inventoryIndex) (toList (roster ^. V2Fields.costumes)) + ownedCharacters = Set.fromList characterIndices + collection = snapshot ^. V2Fields.collection + in projectionProblems <> concat + [ requireV2 (snapshot ^. V2Fields.formatVersion == 2) "snapshot.format_version" ["format_version"] "snapshot format version must be 2" [] + , requireV2 (snapshot ^. V2Fields.clientVersion == "2.34.13") "snapshot.client_version" ["client_version"] "unsupported client version" [] + , requireV2 (not (Text.null (snapshot ^. V2Fields.gameDataVersion))) "snapshot.game_data_version" ["game_data_version"] "game data version is required" [] + , duplicateV2 "roster.duplicate_character_index" ["roster", "characters"] characterIndices + , duplicateV2 "roster.duplicate_costume_index" ["roster", "costumes"] costumeIndices + , [ Problem "roster.unspecified_origin" Error ["roster", "characters"] "character origin must be explicit" [index] + | record <- records + , record ^. V2Fields.origin /= V2.CHARACTER_ORIGIN_BASE + && record ^. V2Fields.origin /= V2.CHARACTER_ORIGIN_ACQUIRED + , let index = record ^. V2Fields.character . V1Fields.inventoryIndex + ] + , nextIndexV2 "roster.next_acquired_character_index" ["roster", "next_acquired_character_index"] + (roster ^. V2Fields.nextAcquiredCharacterIndex) acquiredIndices + , nextIndexV2 "roster.next_costume_index" ["roster", "next_costume_index"] + (roster ^. V2Fields.nextCostumeIndex) costumeIndices + , [ Problem "roster.costume_unknown_character" Error ["roster", "costumes"] + "costume owner must be in the unified roster" [costume ^. V1Fields.inventoryIndex, owner] + | costume <- toList (roster ^. V2Fields.costumes) + , let owner = costume ^. V1Fields.useChar + , owner /= 0 && Set.notMember owner ownedCharacters + ] + , requireV2 (not (collection ^. V2Fields.previewLocked) || not (null (toList (collection ^. V2Fields.latestPreview)))) + "collection.locked_preview_missing" ["collection", "latest_preview"] "locked preview must retain its result" [] + ] + +-- Reuse all V1 structural and unchanged-domain checks without making V2's +-- storage layout depend on the old split. This projection is validation-only. +projectV1ForSharedValidation :: V2.Snapshot -> V1.Snapshot +projectV1ForSharedValidation snapshot = + let roster = snapshot ^. V2Fields.roster + records = toList (roster ^. V2Fields.characters) + baseCharacters = + [ record ^. V2Fields.character + | record <- records + , record ^. V2Fields.origin == V2.CHARACTER_ORIGIN_BASE + ] + acquiredCharacters = + [ record ^. V2Fields.character + | record <- records + , record ^. V2Fields.origin == V2.CHARACTER_ORIGIN_ACQUIRED + ] + ledger = snapshot ^. V2Fields.collection + characters = + defMessage + & V1Fields.clientVersion .~ (snapshot ^. V2Fields.clientVersion) + & V1Fields.characters .~ baseCharacters + collection = + defMessage + & V1Fields.clientVersion .~ (snapshot ^. V2Fields.clientVersion) + & V1Fields.nextCharacterIndex .~ (roster ^. V2Fields.nextAcquiredCharacterIndex) + & V1Fields.nextCostumeIndex .~ (roster ^. V2Fields.nextCostumeIndex) + & V1Fields.latestPreview .~ toList (ledger ^. V2Fields.latestPreview) + & V1Fields.previewEventIndex .~ (ledger ^. V2Fields.previewEventIndex) + & V1Fields.previewLocked .~ (ledger ^. V2Fields.previewLocked) + & V1Fields.characters .~ acquiredCharacters + & V1Fields.costumes .~ toList (roster ^. V2Fields.costumes) + & V1Fields.baseCostumeLevels .~ toList (ledger ^. V2Fields.baseCostumeLevels) + & V1Fields.gachaSelections .~ toList (ledger ^. V2Fields.gachaSelections) + & V1Fields.stepUpProgress .~ toList (ledger ^. V2Fields.stepUpProgress) + & V1Fields.gachaUsers .~ toList (ledger ^. V2Fields.gachaUsers) + & V1Fields.gachaFixed .~ toList (ledger ^. V2Fields.gachaFixed) + & V1Fields.gachaApplied .~ toList (ledger ^. V2Fields.gachaApplied) + & V1Fields.gachaPointExchanges .~ toList (ledger ^. V2Fields.gachaPointExchanges) + & V1Fields.gachaCountCorrected .~ (ledger ^. V2Fields.gachaCountCorrected) + & V1Fields.grants .~ toList (ledger ^. V2Fields.grants) + in defMessage + & V1Fields.formatVersion .~ 1 + & V1Fields.clientVersion .~ (snapshot ^. V2Fields.clientVersion) + & V1Fields.gameDataVersion .~ (snapshot ^. V2Fields.gameDataVersion) + & V1Fields.progress .~ (snapshot ^. V2Fields.progress) + & V1Fields.deck .~ (snapshot ^. V2Fields.deck) + & V1Fields.inventory .~ (snapshot ^. V2Fields.inventory) + & V1Fields.equipment .~ (snapshot ^. V2Fields.equipment) + & V1Fields.characters .~ characters + & V1Fields.collection .~ collection + & V1Fields.wallet .~ (snapshot ^. V2Fields.wallet) + & V1Fields.mail .~ (snapshot ^. V2Fields.mail) + & V1Fields.missions .~ (snapshot ^. V2Fields.missions) + +requireV2 :: Bool -> Text -> [Text] -> Text -> [Word64] -> [Problem] +requireV2 condition code path message related = + [Problem code Error path message related | not condition] + +duplicates :: Ord a => [a] -> [a] +duplicates = map head . filter ((> 1) . length) . group . sort + +duplicateV2 :: Text -> [Text] -> [Word64] -> [Problem] +duplicateV2 code path values = + [Problem code Error path "duplicate inventory identity" [value] | value <- duplicates values] + +nextIndexV2 :: Text -> [Text] -> Word64 -> [Word64] -> [Problem] +nextIndexV2 code path nextIndex existing = + requireV2 (null existing || nextIndex > maximum existing) code path "next index must be greater than every existing index" [nextIndex] diff --git a/haskell/state-migrator/src/BD2/State/Repair/CostumeOverflow.hs b/haskell/state-migrator/src/BD2/State/Repair/CostumeOverflow.hs new file mode 100644 index 0000000..3d4d30c --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Repair/CostumeOverflow.hs @@ -0,0 +1,187 @@ +{-# LANGUAGE OverloadedStrings #-} + +module BD2.State.Repair.CostumeOverflow + ( CostumeFact (..) + , BaseCostumeFact (..) + , repairCostumeOverflow + ) where + +import BD2.State.Domain +import Data.Foldable (toList) +import Data.List (find) +import qualified Data.Map.Strict as Map +import qualified Data.Set as Set +import Data.Text (Text) +import qualified Data.Text as Text +import Data.Word (Word64) +import Lens.Family2 ((&), (.~), (^.)) +import Data.ProtoLens (defMessage) +import qualified Proto.Bd2.State.V1.State as V1 +import qualified Proto.Bd2.State.V1.State_Fields as V1Fields +import qualified Proto.Bd2.State.V2.State as V2 +import qualified Proto.Bd2.State.V2.State_Fields as V2Fields + +data CostumeFact = CostumeFact + { factCostumeId :: Word64 + , factMaxLevel :: Word64 + , factOverflowItemType :: Word64 + , factOverflowItemId :: Word64 + , factOverflowItemCount :: Word64 + } + deriving stock (Eq, Show) + +data BaseCostumeFact = BaseCostumeFact + { baseInventoryIndex :: Word64 + , baseCostumeId :: Word64 + , baseDefaultLevel :: Word64 + } + deriving stock (Eq, Show) + +repairCostumeOverflow :: [CostumeFact] -> [BaseCostumeFact] -> V2.Snapshot -> Either [Problem] V2.Snapshot +repairCostumeOverflow facts baseFacts snapshot = do + designs <- uniqueFacts facts + bases <- uniqueBases baseFacts + costumes <- traverse (capCostume designs) (toList (snapshot ^. V2Fields.roster . V2Fields.costumes)) + baseLevels <- repairBaseLevels designs bases (toList (snapshot ^. V2Fields.collection . V2Fields.baseCostumeLevels)) + grants <- traverse (repairGrant designs bases costumes) (toList (snapshot ^. V2Fields.collection . V2Fields.grants)) + wallet <- applyMileage grants (snapshot ^. V2Fields.wallet) + pure $ snapshot + & V2Fields.roster . V2Fields.costumes .~ costumes + & V2Fields.collection . V2Fields.baseCostumeLevels .~ baseLevels + & V2Fields.collection . V2Fields.grants .~ grants + & V2Fields.wallet .~ wallet + +capCostume :: Map.Map Word64 CostumeFact -> V1.Costume -> Either [Problem] V1.Costume +capCostume designs costume = do + design <- requireDesign "repair.overflow_missing_design" designs (costume ^. V1Fields.id) + pure $ costume & V1Fields.level .~ min (costume ^. V1Fields.level) (factMaxLevel design) + +repairBaseLevels :: Map.Map Word64 CostumeFact -> Map.Map Word64 BaseCostumeFact -> [V1.NamedCount] -> Either [Problem] [V1.NamedCount] +repairBaseLevels designs bases entries = do + repaired <- traverse repair entries + foldl appendMissing (Right repaired) (Map.elems bases) + where + repair entry = case readIndex (entry ^. V1Fields.identity) >>= (`Map.lookup` bases) of + Nothing -> Right entry + Just base -> case Map.lookup (baseCostumeId base) designs of + Nothing -> Right entry -- starter costume outside active gacha catalog + Just design + | factMaxLevel design == 0 -> Left [overflowProblem "repair.overflow_zero_max_level" "costume max level is zero" [baseCostumeId base]] + | otherwise -> Right (entry & V1Fields.value .~ min (entry ^. V1Fields.value) (factMaxLevel design)) + appendMissing current base = do + values <- current + let key = Text.pack (show (baseInventoryIndex base)) + if any ((== key) . (^. V1Fields.identity)) values + then Right values + else case Map.lookup (baseCostumeId base) designs of + Nothing -> Right values + Just design + | factMaxLevel design == 0 -> Left [overflowProblem "repair.overflow_zero_max_level" "costume max level is zero" [baseCostumeId base]] + | baseDefaultLevel base > factMaxLevel design -> Right (values <> [defMessage & V1Fields.identity .~ key & V1Fields.value .~ factMaxLevel design]) + | otherwise -> Right values + +repairGrant :: Map.Map Word64 CostumeFact -> Map.Map Word64 BaseCostumeFact -> [V1.Costume] -> V1.CollectionGrant -> Either [Problem] V1.CollectionGrant +repairGrant designs bases costumes grant = do + (kept, generated) <- foldl repairUpgrade (Right ([], [])) (toList (grant ^. V1Fields.upgrades)) + filled <- traverse fillExchange (toList (grant ^. V1Fields.exchanges) <> generated) + let completedUpgrades = foldl ensureUpgrade kept filled + pure $ grant & V1Fields.upgrades .~ completedUpgrades & V1Fields.exchanges .~ filled + where + repairUpgrade accumulated upgrade = do + (kept, generated) <- accumulated + design <- requireDesign "repair.overflow_missing_upgrade_design" designs (upgrade ^. V1Fields.costumeId) + if upgrade ^. V1Fields.after > factMaxLevel design + then do + exchange <- makeExchange design (upgrade ^. V1Fields.inventoryIndex) (upgrade ^. V1Fields.sortId) + Right (kept, generated <> [exchange]) + else Right (kept <> [upgrade], generated) + fillExchange exchange + | exchange ^. V1Fields.inventoryIndex /= 0 = Right exchange + | otherwise = case findInventory (exchange ^. V1Fields.originalItemId) of + Nothing -> Left [overflowProblem "repair.overflow_missing_instance" "overflow costume has no inventory instance" [exchange ^. V1Fields.originalItemId]] + Just index -> Right (exchange & V1Fields.inventoryIndex .~ index) + findInventory costumeId = + case find ((== costumeId) . (^. V1Fields.id)) costumes of + Just costume -> Just (costume ^. V1Fields.inventoryIndex) + Nothing -> baseInventoryIndex <$> find ((== costumeId) . baseCostumeId) (Map.elems bases) + ensureUpgrade upgrades exchange + | any (sameExchange exchange) upgrades = upgrades + | otherwise = case Map.lookup (exchange ^. V1Fields.originalItemId) designs of + Nothing -> upgrades + Just design -> upgrades <> [defMessage + & V1Fields.inventoryIndex .~ (exchange ^. V1Fields.inventoryIndex) + & V1Fields.costumeId .~ (exchange ^. V1Fields.originalItemId) + & V1Fields.before .~ factMaxLevel design + & V1Fields.after .~ factMaxLevel design + & V1Fields.sortId .~ (exchange ^. V1Fields.sortId)] + sameExchange exchange upgrade = upgrade ^. V1Fields.costumeId == exchange ^. V1Fields.originalItemId + && upgrade ^. V1Fields.sortId == exchange ^. V1Fields.sortId + +makeExchange :: CostumeFact -> Word64 -> Word64 -> Either [Problem] V1.CostumeExchange +makeExchange design inventoryIndex sortId + | factOverflowItemType design == 0 || factOverflowItemCount design == 0 = + Left [overflowProblem "repair.overflow_missing_exchange" "costume has no overflow exchange design" [factCostumeId design]] + | otherwise = Right $ defMessage + & V1Fields.inventoryIndex .~ inventoryIndex + & V1Fields.originalItemType .~ 11 + & V1Fields.originalItemId .~ factCostumeId design + & V1Fields.originalCount .~ 1 + & V1Fields.exchangeItemType .~ factOverflowItemType design + & V1Fields.exchangeItemId .~ factOverflowItemId design + & V1Fields.exchangeCount .~ factOverflowItemCount design + & V1Fields.sortId .~ sortId + +applyMileage :: [V1.CollectionGrant] -> V1.Wallet -> Either [Problem] V1.Wallet +applyMileage grants wallet = foldl apply (Right wallet) grants + where + apply current grant = do + value <- current + let identity = grant ^. V1Fields.identity + alreadyGranted = Set.member identity (Set.fromList (toList (value ^. V1Fields.grantedIdentities))) + exchanges = toList (grant ^. V1Fields.exchanges) + amount <- foldl addExchange (Right 0) exchanges + if amount == 0 || alreadyGranted + then Right value + else if maxBound - value ^. V1Fields.mileage < amount + then Left [overflowProblem "repair.overflow_mileage_overflow" "mileage repair would overflow uint64" [amount]] + else Right $ value + & V1Fields.mileage .~ (value ^. V1Fields.mileage) + amount + & V1Fields.grantedIdentities .~ (toList (value ^. V1Fields.grantedIdentities) <> [identity]) + addExchange current exchange = do + total <- current + if exchange ^. V1Fields.exchangeItemType /= 20 + then Left [overflowProblem "repair.overflow_unsupported_currency" "overflow repair supports only mileage currency" [exchange ^. V1Fields.exchangeItemType]] + else if maxBound - total < exchange ^. V1Fields.exchangeCount + then Left [overflowProblem "repair.overflow_exchange_sum" "overflow exchange sum exceeds uint64" []] + else Right (total + exchange ^. V1Fields.exchangeCount) + +uniqueFacts :: [CostumeFact] -> Either [Problem] (Map.Map Word64 CostumeFact) +uniqueFacts = uniqueBy factCostumeId "repair.overflow_duplicate_design" + +uniqueBases :: [BaseCostumeFact] -> Either [Problem] (Map.Map Word64 BaseCostumeFact) +uniqueBases = uniqueBy baseInventoryIndex "repair.overflow_duplicate_base" + +uniqueBy :: Ord key => (value -> key) -> Text -> [value] -> Either [Problem] (Map.Map key value) +uniqueBy keyOf code = foldl add (Right Map.empty) + where + add current value = do + values <- current + let key = keyOf value + if Map.member key values + then Left [overflowProblem code "duplicate validation fact" []] + else Right (Map.insert key value values) + +requireDesign :: Text -> Map.Map Word64 CostumeFact -> Word64 -> Either [Problem] CostumeFact +requireDesign code designs costumeId = case Map.lookup costumeId designs of + Nothing -> Left [overflowProblem code "costume has no validation fact" [costumeId]] + Just design + | factMaxLevel design == 0 -> Left [overflowProblem "repair.overflow_zero_max_level" "costume max level is zero" [costumeId]] + | otherwise -> Right design + +readIndex :: Text -> Maybe Word64 +readIndex text = case reads (Text.unpack text) of + [(value, "")] -> Just value + _ -> Nothing + +overflowProblem :: Text -> Text -> [Word64] -> Problem +overflowProblem code message related = Problem code Error ["collection", "costume_overflow"] message related diff --git a/haskell/state-migrator/src/BD2/State/Repair/DuplicateCharacters.hs b/haskell/state-migrator/src/BD2/State/Repair/DuplicateCharacters.hs new file mode 100644 index 0000000..7915578 --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Repair/DuplicateCharacters.hs @@ -0,0 +1,75 @@ +{-# LANGUAGE OverloadedStrings #-} + +module BD2.State.Repair.DuplicateCharacters + ( repairDuplicateCharacters + ) where + +import BD2.State.Domain +import Data.Foldable (toList) +import qualified Data.Map.Strict as Map +import Data.Maybe (mapMaybe) +import Data.Word (Word64) +import Lens.Family2 ((&), (.~), (^.)) +import qualified Proto.Bd2.State.V1.State_Fields as V1Fields +import qualified Proto.Bd2.State.V2.State as V2 +import qualified Proto.Bd2.State.V2.State_Fields as V2Fields + +-- | Port of CollectionStore.AttachBaseCharacters as a total pure transform. +-- Base characters are authoritative. Repeated acquired characters are +-- removed by design ID; all live cross-state owner references are redirected, +-- while historical grant character lists drop removed instance identities. +repairDuplicateCharacters :: V2.Snapshot -> Either [Problem] (V2.Snapshot, [CharacterRedirect]) +repairDuplicateCharacters snapshot = do + let roster = snapshot ^. V2Fields.roster + records = toList (roster ^. V2Fields.characters) + base = filter ((== V2.CHARACTER_ORIGIN_BASE) . (^. V2Fields.origin)) records + acquired = filter ((== V2.CHARACTER_ORIGIN_ACQUIRED) . (^. V2Fields.origin)) records + canonical <- baseCanonical base + let (keptAcquired, redirects) = acquiredCanonical canonical acquired + redirectMap = Map.fromList [(redirectFromIndex r, redirectToIndex r) | r <- redirects] + redirect index = Map.findWithDefault index index redirectMap + repairCostume costume = costume & V1Fields.useChar .~ redirect (costume ^. V1Fields.useChar) + repairSlot slot = slot & V1Fields.characterIndex .~ redirect (slot ^. V1Fields.characterIndex) + repairEquipment entry = entry & V1Fields.useChar .~ redirect (entry ^. V1Fields.useChar) + repairGrant grant = grant & V1Fields.characterIndices .~ mapMaybe (keepHistorical redirectMap) (toList (grant ^. V1Fields.characterIndices)) + deck = snapshot ^. V2Fields.deck + equipment = snapshot ^. V2Fields.equipment + ledger = snapshot ^. V2Fields.collection + repaired = + snapshot + & V2Fields.roster . V2Fields.characters .~ (base <> keptAcquired) + & V2Fields.roster . V2Fields.costumes .~ map repairCostume (toList (roster ^. V2Fields.costumes)) + & V2Fields.deck . V1Fields.battle .~ map repairSlot (toList (deck ^. V1Fields.battle)) + & V2Fields.deck . V1Fields.field .~ map repairSlot (toList (deck ^. V1Fields.field)) + & V2Fields.equipment . V1Fields.equipment .~ map repairEquipment (toList (equipment ^. V1Fields.equipment)) + & V2Fields.collection . V2Fields.grants .~ map repairGrant (toList (ledger ^. V2Fields.grants)) + pure (repaired, redirects) + +baseCanonical :: [V2.CharacterRecord] -> Either [Problem] (Map.Map Word64 Word64) +baseCanonical = go Map.empty + where + go canonical [] = Right canonical + go canonical (record : rest) = + let character = record ^. V2Fields.character + designId = character ^. V1Fields.id + index = character ^. V1Fields.inventoryIndex + in case Map.lookup designId canonical of + Just existing -> Left [Problem "repair.duplicate_base_character" Error ["roster", "characters"] "base character design is not unique" [designId, existing, index]] + Nothing -> go (Map.insert designId index canonical) rest + +acquiredCanonical :: Map.Map Word64 Word64 -> [V2.CharacterRecord] -> ([V2.CharacterRecord], [CharacterRedirect]) +acquiredCanonical initial = go initial [] [] + where + go _ kept redirects [] = (reverse kept, reverse redirects) + go canonical kept redirects (record : rest) = + let character = record ^. V2Fields.character + designId = character ^. V1Fields.id + index = character ^. V1Fields.inventoryIndex + in case Map.lookup designId canonical of + Just existing -> go canonical kept (CharacterRedirect designId index existing : redirects) rest + Nothing -> go (Map.insert designId index canonical) (record : kept) redirects rest + +keepHistorical :: Map.Map Word64 Word64 -> Word64 -> Maybe Word64 +keepHistorical redirects index + | Map.member index redirects = Nothing + | otherwise = Just index diff --git a/haskell/state-migrator/src/BD2/State/Repair/GachaProgress.hs b/haskell/state-migrator/src/BD2/State/Repair/GachaProgress.hs new file mode 100644 index 0000000..010efab --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Repair/GachaProgress.hs @@ -0,0 +1,163 @@ +{-# LANGUAGE OverloadedStrings #-} + +module BD2.State.Repair.GachaProgress + ( GachaGroupFact (..) + , CostumeGradeFact (..) + , repairGachaProgress + ) where + +import BD2.State.Domain +import Data.Foldable (toList) +import Data.List (find, sortOn) +import qualified Data.Map.Strict as Map +import qualified Data.Set as Set +import Data.Text (Text) +import qualified Data.Text as Text +import Data.Word (Word64) +import Lens.Family2 ((&), (.~), (^.)) +import Data.ProtoLens (defMessage) +import qualified Proto.Bd2.State.V1.State as V1 +import qualified Proto.Bd2.State.V1.State_Fields as V1Fields +import qualified Proto.Bd2.State.V2.State as V2 +import qualified Proto.Bd2.State.V2.State_Fields as V2Fields +import Text.Read (readMaybe) + +data GachaGroupFact = GachaGroupFact + { factGachaId :: Word64 + , factGroupId :: Word64 + , factFixedId :: Word64 + , factPointCount :: Word64 + } + deriving stock (Eq, Show) + +data CostumeGradeFact = CostumeGradeFact + { gradeCostumeId :: Word64 + , costumeGrade :: Word64 + } + deriving stock (Eq, Show) + +data Draw = Draw + { drawIdentity :: Text + , drawSession :: Text + , drawSequence :: Word64 + , drawFact :: GachaGroupFact + , drawGrant :: V1.CollectionGrant + } + +repairGachaProgress :: [GachaGroupFact] -> [CostumeGradeFact] -> V2.Snapshot -> Either [Problem] V2.Snapshot +repairGachaProgress groups grades snapshot = do + groupMap <- uniqueMap "repair.gacha_duplicate_id" factGachaId groups + gradeMap <- uniqueMap "repair.gacha_duplicate_costume" gradeCostumeId grades + let ledger = snapshot ^. V2Fields.collection + users = toList (ledger ^. V2Fields.gachaUsers) + fixed = toList (ledger ^. V2Fields.gachaFixed) + applied = Set.fromList (toList (ledger ^. V2Fields.gachaApplied)) + grants = toList (ledger ^. V2Fields.grants) + initialized = not (null users) || not (null fixed) || not (Set.null applied) + if initialized + then repairCorrectedCounts groupMap snapshot + else replayLegacy groupMap gradeMap grants snapshot + +repairCorrectedCounts :: Map.Map Word64 GachaGroupFact -> V2.Snapshot -> Either [Problem] V2.Snapshot +repairCorrectedCounts groupMap snapshot + | snapshot ^. V2Fields.collection . V2Fields.gachaCountCorrected = Right snapshot + | otherwise = do + let ledger = snapshot ^. V2Fields.collection + applied = Set.fromList (toList (ledger ^. V2Fields.gachaApplied)) + grants = toList (ledger ^. V2Fields.grants) + countGrant accumulated grant = case parseIdentity groupMap (grant ^. V1Fields.identity) of + Just (_, _, _, fact) + | Set.member (grant ^. V1Fields.identity) applied -> + Map.insertWith (+) (factGroupId fact) (fromIntegral (length (toList (grant ^. V1Fields.viewCostumeIds)))) accumulated + _ -> accumulated + counts = foldl countGrant Map.empty grants + users = toList (ledger ^. V2Fields.gachaUsers) + repairedUsers = foldl setCount users (Map.toList counts) + setCount current (groupId, count) = upsertUser groupId (\user -> user & V1Fields.totalBuyCount .~ count) current + Right $ snapshot + & V2Fields.collection . V2Fields.gachaUsers .~ repairedUsers + & V2Fields.collection . V2Fields.gachaCountCorrected .~ True + +replayLegacy :: Map.Map Word64 GachaGroupFact -> Map.Map Word64 CostumeGradeFact -> [V1.CollectionGrant] -> V2.Snapshot -> Either [Problem] V2.Snapshot +replayLegacy groupMap gradeMap grants snapshot = do + let draws = sortOn (\draw -> (sessionOrder (drawSession draw), drawSequence draw)) + [ Draw identity session sequenceNumber fact grant + | grant <- grants + , let identity = grant ^. V1Fields.identity + , Just (_, session, sequenceNumber, fact) <- [parseIdentity groupMap identity] + ] + if null draws + then Right snapshot + else do + (users, fixed, applied) <- foldl step (Right ([], [], [])) draws + Right $ snapshot + & V2Fields.collection . V2Fields.gachaUsers .~ users + & V2Fields.collection . V2Fields.gachaFixed .~ fixed + & V2Fields.collection . V2Fields.gachaApplied .~ applied + & V2Fields.collection . V2Fields.gachaCountCorrected .~ True + where + step accumulated draw = do + (users, fixed, applied) <- accumulated + let fact = drawFact draw + costumeIds = toList (drawGrant draw ^. V1Fields.viewCostumeIds) + count = fromIntegral (length costumeIds) + addUser user = user + & V1Fields.groupId .~ factGroupId fact + & V1Fields.totalBuyCount .~ (user ^. V1Fields.totalBuyCount) + count + & V1Fields.point .~ (user ^. V1Fields.point) + factPointCount fact * count + nextUsers = upsertUser (factGroupId fact) addUser users + nextFixed <- if factFixedId fact == 0 then Right fixed else foldl (applyGrade (factFixedId fact)) (Right fixed) costumeIds + Right (nextUsers, nextFixed, applied <> [drawIdentity draw]) + applyGrade fixedId current costumeId = do + states <- current + grade <- case Map.lookup costumeId gradeMap of + Nothing -> Left [Problem "repair.gacha_missing_grade" Error ["collection", "grants"] "gacha result has no costume grade fact" [costumeId]] + Just value -> Right (costumeGrade value) + let fourKey = Text.pack (show fixedId <> ":0") + fiveKey = Text.pack (show fixedId <> ":1") + updateFour state = state & V1Fields.fixedId .~ fixedId & V1Fields.type' .~ 0 & V1Fields.applySortId .~ (-1) + & V1Fields.count .~ case grade of 5 -> 0; 4 -> 0; _ -> state ^. V1Fields.count + 1 + updateFive state = state & V1Fields.fixedId .~ fixedId & V1Fields.type' .~ 1 & V1Fields.applySortId .~ (-1) + & V1Fields.count .~ case grade of 5 -> 0; _ -> state ^. V1Fields.count + 1 + Right (upsertFixed fiveKey updateFive (upsertFixed fourKey updateFour states)) + +sessionOrder :: Text -> (Int, Text) +sessionOrder session + | Text.null session = (0, "") + | otherwise = (1, session) + +parseIdentity :: Map.Map Word64 GachaGroupFact -> Text -> Maybe (Word64, Text, Word64, GachaGroupFact) +parseIdentity groups identity = do + let parts = Text.splitOn ":" identity + if length parts < 4 || head parts /= "regular-gacha" then Nothing else pure () + gachaId <- readWord (parts !! 1) + fact <- Map.lookup gachaId groups + sequenceNumber <- readWord (last parts) + let session = if length parts >= 6 && parts !! 2 == "session" && parts !! 4 == "seq" then parts !! 3 else "" + pure (gachaId, session, sequenceNumber, fact) + +readWord :: Text -> Maybe Word64 +readWord = readMaybe . Text.unpack + +upsertUser :: Word64 -> (V1.GachaUser -> V1.GachaUser) -> [V1.NamedGachaUser] -> [V1.NamedGachaUser] +upsertUser groupId update entries = + let key = Text.pack (show groupId) + in case find ((== key) . (^. V1Fields.identity)) entries of + Nothing -> entries <> [defMessage & V1Fields.identity .~ key & V1Fields.user .~ update defMessage] + Just _ -> map (\entry -> if entry ^. V1Fields.identity == key then entry & V1Fields.user .~ update (entry ^. V1Fields.user) else entry) entries + +upsertFixed :: Text -> (V1.GachaFixed -> V1.GachaFixed) -> [V1.NamedGachaFixed] -> [V1.NamedGachaFixed] +upsertFixed key update entries = + case find ((== key) . (^. V1Fields.identity)) entries of + Nothing -> entries <> [defMessage & V1Fields.identity .~ key & V1Fields.fixed .~ update defMessage] + Just _ -> map (\entry -> if entry ^. V1Fields.identity == key then entry & V1Fields.fixed .~ update (entry ^. V1Fields.fixed) else entry) entries + +uniqueMap :: Ord key => Text -> (value -> key) -> [value] -> Either [Problem] (Map.Map key value) +uniqueMap code keyOf = foldl insert (Right Map.empty) + where + insert accumulated value = do + current <- accumulated + let key = keyOf value + if Map.member key current + then Left [Problem code Error ["validation_context"] "duplicate validation fact" []] + else Right (Map.insert key value current) diff --git a/haskell/state-migrator/src/BD2/State/Repair/NextIndices.hs b/haskell/state-migrator/src/BD2/State/Repair/NextIndices.hs new file mode 100644 index 0000000..94526d7 --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Repair/NextIndices.hs @@ -0,0 +1,52 @@ +{-# LANGUAGE OverloadedStrings #-} + +module BD2.State.Repair.NextIndices + ( repairNextIndices + ) where + +import BD2.State.Domain +import Data.Foldable (toList) +import Data.Text (Text) +import Data.Word (Word64) +import Lens.Family2 ((&), (.~), (^.)) +import qualified Proto.Bd2.State.V1.State_Fields as V1Fields +import qualified Proto.Bd2.State.V2.State as V2 +import qualified Proto.Bd2.State.V2.State_Fields as V2Fields + +-- | Repair only stale allocator cursors; never renumber or mutate an entity. +-- Historical item issuance indices count even when the item was consumed. +-- Minimum cursors are the defaults used by the Go stores. Counter overflow is +-- rejected instead of wrapping to zero or inventing a new identity. +repairNextIndices :: V2.Snapshot -> Either [Problem] V2.Snapshot +repairNextIndices source = do + let inventory = source ^. V2Fields.inventory + equipment = source ^. V2Fields.equipment + roster = source ^. V2Fields.roster + itemIndices = + map (^. V1Fields.inventoryIndex) (toList (inventory ^. V1Fields.items)) + <> concatMap (toList . (^. V1Fields.inventoryIndices)) (toList (inventory ^. V1Fields.grantItems)) + equipmentIndices = map (^. V1Fields.inventoryIndex) (toList (equipment ^. V1Fields.equipment)) + acquiredIndices = + [ record ^. V2Fields.character . V1Fields.inventoryIndex + | record <- toList (roster ^. V2Fields.characters) + , record ^. V2Fields.origin == V2.CHARACTER_ORIGIN_ACQUIRED + ] + costumeIndices = map (^. V1Fields.inventoryIndex) (toList (roster ^. V2Fields.costumes)) + itemNext <- advance "inventory.next_index_overflow" ["inventory", "next_index"] 900000001 (inventory ^. V1Fields.nextIndex) itemIndices + equipmentNext <- advance "equipment.next_index_overflow" ["equipment", "next_index"] 910000001 (equipment ^. V1Fields.nextIndex) equipmentIndices + characterNext <- advance "roster.next_character_index_overflow" ["roster", "next_acquired_character_index"] 920000001 + (roster ^. V2Fields.nextAcquiredCharacterIndex) acquiredIndices + costumeNext <- advance "roster.next_costume_index_overflow" ["roster", "next_costume_index"] 930000001 + (roster ^. V2Fields.nextCostumeIndex) costumeIndices + pure $ source + & V2Fields.inventory . V1Fields.nextIndex .~ itemNext + & V2Fields.equipment . V1Fields.nextIndex .~ equipmentNext + & V2Fields.roster . V2Fields.nextAcquiredCharacterIndex .~ characterNext + & V2Fields.roster . V2Fields.nextCostumeIndex .~ costumeNext + +advance :: Text -> [Text] -> Word64 -> Word64 -> [Word64] -> Either [Problem] Word64 +advance code path initial current used = + let highest = maximum (0 : used) + in if highest == maxBound + then Left [Problem code Error path "allocator cannot advance past the maximum uint64 index" [highest]] + else Right (maximum [initial, current, highest + 1]) diff --git a/haskell/state-migrator/src/BD2/State/Repair/Pipeline.hs b/haskell/state-migrator/src/BD2/State/Repair/Pipeline.hs new file mode 100644 index 0000000..e13ac99 --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Repair/Pipeline.hs @@ -0,0 +1,77 @@ +{-# LANGUAGE OverloadedStrings #-} + +module BD2.State.Repair.Pipeline + ( repairV1ToCurrent + ) where + +import BD2.State.Domain +import BD2.State.Migrate.V1ToV2 +import BD2.State.Repair.CostumeOverflow +import BD2.State.Repair.DuplicateCharacters +import BD2.State.Repair.GachaProgress +import BD2.State.Repair.NextIndices +import BD2.State.Repair.StepUpProgress +import Data.Foldable (toList) +import qualified Data.Text as Text +import Lens.Family2 ((^.)) +import qualified Proto.Bd2.State.Control.V2.Control as Control +import qualified Proto.Bd2.State.Control.V2.Control_Fields as ControlFields +import qualified Proto.Bd2.State.V1.State_Fields as V1Fields +import qualified Proto.Bd2.State.V2.State as V2 + +repairV1ToCurrent :: Control.RepairRequest -> Either [Problem] (V2.Snapshot, Bool) +repairV1ToCurrent request = do + let context = request ^. ControlFields.context + source = request ^. ControlFields.source + contextVersion = context ^. ControlFields.gameDataVersion + if Text.null contextVersion || contextVersion /= source ^. V1Fields.gameDataVersion + then Left [Problem "repair.game_data_version" Error ["context", "game_data_version"] "context and source GameData versions differ" []] + else pure () + migrated <- migrateV1ToV2ForRepair source + (deduplicated, _) <- repairDuplicateCharacters migrated + indexed <- repairNextIndices deduplicated + stepped <- foldl repairStep (Right indexed) (toList (context ^. ControlFields.stepUps)) + replayed <- repairGachaProgress + (map groupFact (toList (context ^. ControlFields.gachaGroups))) + (map gradeFact (toList (context ^. ControlFields.costumes))) + stepped + overflow <- repairCostumeOverflow + (map costumeFact (toList (context ^. ControlFields.costumes))) + (map baseFact (toList (context ^. ControlFields.baseCostumes))) + replayed + case validateV2 overflow of + [] -> Right (overflow, overflow /= migrated) + problems -> Left problems + where + repairStep current fact = do + snapshot <- current + fst <$> repairStepUpProgress + (StepUpFact + (request ^. ControlFields.context . ControlFields.gameDataVersion) + (fact ^. ControlFields.groupId) + (toList (fact ^. ControlFields.orderedGachaIds))) + snapshot + +groupFact :: Control.GachaGroupFact -> GachaGroupFact +groupFact fact = GachaGroupFact + (fact ^. ControlFields.gachaId) + (fact ^. ControlFields.groupId) + (fact ^. ControlFields.fixedId) + (fact ^. ControlFields.pointCount) + +gradeFact :: Control.CostumeFact -> CostumeGradeFact +gradeFact fact = CostumeGradeFact (fact ^. ControlFields.costumeId) (fact ^. ControlFields.grade) + +costumeFact :: Control.CostumeFact -> CostumeFact +costumeFact fact = CostumeFact + (fact ^. ControlFields.costumeId) + (fact ^. ControlFields.maxLevel) + (fact ^. ControlFields.overflowItemType) + (fact ^. ControlFields.overflowItemId) + (fact ^. ControlFields.overflowItemCount) + +baseFact :: Control.BaseCostumeFact -> BaseCostumeFact +baseFact fact = BaseCostumeFact + (fact ^. ControlFields.inventoryIndex) + (fact ^. ControlFields.costumeId) + (fact ^. ControlFields.level) diff --git a/haskell/state-migrator/src/BD2/State/Repair/StepUpProgress.hs b/haskell/state-migrator/src/BD2/State/Repair/StepUpProgress.hs new file mode 100644 index 0000000..86abe1a --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Repair/StepUpProgress.hs @@ -0,0 +1,65 @@ +{-# LANGUAGE OverloadedStrings #-} + +module BD2.State.Repair.StepUpProgress + ( StepUpFact (..) + , repairStepUpProgress + ) where + +import BD2.State.Domain +import Data.Foldable (toList) +import Data.List (find) +import qualified Data.Set as Set +import Data.Text (Text) +import qualified Data.Text as Text +import Data.Word (Word64) +import Data.ProtoLens (defMessage) +import Lens.Family2 ((&), (.~), (^.)) +import qualified Proto.Bd2.State.V1.State as V1 +import qualified Proto.Bd2.State.V1.State_Fields as V1Fields +import qualified Proto.Bd2.State.V2.State as V2 +import qualified Proto.Bd2.State.V2.State_Fields as V2Fields + +-- The caller extracts this ordered, versioned fact from its installed +-- GameData. Neither this module nor the Haskell executable reads the catalog. +data StepUpFact = StepUpFact + { stepUpGameDataVersion :: Text + , stepUpGroupId :: Word64 + , orderedGachaIds :: [Word64] + } + +repairStepUpProgress :: StepUpFact -> V2.Snapshot -> Either [Problem] (V2.Snapshot, Word64) +repairStepUpProgress fact snapshot + | Text.null (stepUpGameDataVersion fact) || stepUpGameDataVersion fact /= snapshot ^. V2Fields.gameDataVersion = + Left [problem "repair.step_up_game_data_version" "step-up design and snapshot GameData versions differ" []] + | stepUpGroupId fact == 0 || null (orderedGachaIds fact) || any (== 0) (orderedGachaIds fact) = + Left [problem "repair.step_up_invalid_fact" "ordered step-up design must have nonzero IDs" []] + | Set.size (Set.fromList (orderedGachaIds fact)) /= length (orderedGachaIds fact) = + Left [problem "repair.step_up_duplicate_gacha" "step-up design contains duplicate gacha IDs" (orderedGachaIds fact)] + | otherwise = + let ledger = snapshot ^. V2Fields.collection + groupKey = Text.pack (show (stepUpGroupId fact)) + stored = toList (ledger ^. V2Fields.stepUpProgress) + current = maybe 0 (^. V1Fields.value) (find ((== groupKey) . (^. V1Fields.identity)) stored) + grantIds = map (^. V1Fields.identity) (toList (ledger ^. V2Fields.grants)) + matches gachaId = any (Text.isPrefixOf ("regular-gacha:" <> Text.pack (show gachaId) <> ":")) grantIds + derived = fromIntegral (length (takeWhile matches (orderedGachaIds fact))) + repairedCount = max current derived + adjust entry + | entry ^. V1Fields.identity == groupKey = entry & V1Fields.value .~ repairedCount + | otherwise = entry + repairedEntries + | repairedCount == current = stored + | any ((== groupKey) . (^. V1Fields.identity)) stored = map adjust stored + | otherwise = stored <> [newCount groupKey repairedCount] + in if length (filter ((== groupKey) . (^. V1Fields.identity)) stored) > 1 + then Left [problem "repair.step_up_duplicate_progress" "step-up group has multiple progress records" [stepUpGroupId fact]] + else if current > fromIntegral (length (orderedGachaIds fact)) + then Left [problem "repair.step_up_out_of_range" "stored step count exceeds the ordered design" [current]] + else Right (snapshot & V2Fields.collection . V2Fields.stepUpProgress .~ repairedEntries, repairedCount) + where + problem :: Text -> Text -> [Word64] -> Problem + problem code message related = Problem code Error ["collection", "step_up_progress"] message related + +newCount :: Text -> Word64 -> V1.NamedCount +newCount identity count = + defMessage & V1Fields.identity .~ identity & V1Fields.value .~ count diff --git a/haskell/state-migrator/src/BD2/State/Validate.hs b/haskell/state-migrator/src/BD2/State/Validate.hs new file mode 100644 index 0000000..60058d5 --- /dev/null +++ b/haskell/state-migrator/src/BD2/State/Validate.hs @@ -0,0 +1,132 @@ +{-# LANGUAGE OverloadedStrings #-} + +module BD2.State.Validate + ( validateRequest + , validateSnapshot + ) where + +import BD2.State.Domain +import qualified Data.ByteString as BS +import Data.Foldable (toList) +import Data.List (group, sort) +import qualified Data.Set as Set +import Data.Text (Text) +import qualified Data.Text as Text +import Data.Word (Word32, Word64) +import Lens.Family2 ((^.)) +import qualified Proto.Bd2.State.Control.V1.Control as Control +import qualified Proto.Bd2.State.Control.V1.Control_Fields as ControlFields +import qualified Proto.Bd2.State.V1.State as State +import qualified Proto.Bd2.State.V1.State_Fields as StateFields + +validateRequest :: Control.CheckRequest -> ([Problem], Maybe ValidSnapshot) +validateRequest request = + let requestProblems = + require (request ^. ControlFields.bridgeApiVersion == 1) "bridge.version" ["bridge_api_version"] "bridge API version must be 1" [] + ++ require (BS.length (request ^. ControlFields.sourceSha256) == 32) "bridge.source_hash" ["source_sha256"] "source SHA-256 must contain 32 bytes" [] + (snapshotProblems, valid) = validateSnapshot (request ^. ControlFields.snapshot) + allProblems = requestProblems <> snapshotProblems + in (allProblems, if any ((== Error) . problemSeverity) allProblems then Nothing else valid) + +validateSnapshot :: State.Snapshot -> ([Problem], Maybe ValidSnapshot) +validateSnapshot snapshot = + let inventory = snapshot ^. StateFields.inventory + equipment = snapshot ^. StateFields.equipment + collection = snapshot ^. StateFields.collection + progress = snapshot ^. StateFields.progress + baseCharacters = toList (snapshot ^. StateFields.characters . StateFields.characters) + acquiredCharacters = toList (collection ^. StateFields.characters) + costumes = toList (collection ^. StateFields.costumes) + itemIndices = map (^. StateFields.inventoryIndex) (toList (inventory ^. StateFields.items)) + equipmentIndices = map (^. StateFields.inventoryIndex) (toList (equipment ^. StateFields.equipment)) + characterIndices = map (^. StateFields.inventoryIndex) (baseCharacters <> acquiredCharacters) + costumeIndices = map (^. StateFields.inventoryIndex) costumes + ownedEquipment = Set.fromList equipmentIndices + ownedCharacters = Set.fromList characterIndices + questKeys = map questTuple (toList (progress ^. StateFields.quests)) + clearedKeys = map keyTuple (toList (progress ^. StateFields.clearedQuests)) + -- grant_items is a historical issuance ledger. Consuming a stack + -- removes it from items but deliberately retains its grant marker. + granted = Set.fromList (toList (inventory ^. StateFields.grantedIdentities)) + grantProblems = concatMap (validateGrant granted (inventory ^. StateFields.nextIndex)) (toList (inventory ^. StateFields.grantItems)) + problems = concat + [ require (snapshot ^. StateFields.formatVersion == 1) "snapshot.format_version" ["format_version"] "snapshot format version must be 1" [] + , require (snapshot ^. StateFields.clientVersion == "2.34.13") "snapshot.client_version" ["client_version"] "unsupported client version" [] + , require (not (Text.null (snapshot ^. StateFields.gameDataVersion))) "snapshot.game_data_version" ["game_data_version"] "game data version is required" [] + , duplicateProblems "inventory.duplicate_index" ["inventory", "items"] itemIndices + , duplicateProblems "equipment.duplicate_index" ["equipment", "equipment"] equipmentIndices + , duplicateProblems "characters.duplicate_index" ["characters"] characterIndices + , duplicateProblems "costumes.duplicate_index" ["collection", "costumes"] costumeIndices + , nextIndexProblem "inventory.next_index" ["inventory", "next_index"] (inventory ^. StateFields.nextIndex) itemIndices + , nextIndexProblem "equipment.next_index" ["equipment", "next_index"] (equipment ^. StateFields.nextIndex) equipmentIndices + , nextIndexProblem "collection.next_character_index" ["collection", "next_character_index"] (collection ^. StateFields.nextCharacterIndex) characterIndices + , nextIndexProblem "collection.next_costume_index" ["collection", "next_costume_index"] (collection ^. StateFields.nextCostumeIndex) costumeIndices + , [ Problem "equipment.grant_missing_equipment" Error ["equipment", "grants", grant ^. StateFields.identity] + "equipment grant must point to an owned equipment instance" [index] + | grant <- toList (equipment ^. StateFields.grants) + , let index = grant ^. StateFields.inventoryIndex + , index == 0 || Set.notMember index ownedEquipment + ] + , [ Problem "equipment.unknown_user" Error ["equipment", "equipment"] + "equipped character must be owned" [entry ^. StateFields.inventoryIndex, characterIndex] + | entry <- toList (equipment ^. StateFields.equipment) + , let characterIndex = entry ^. StateFields.useChar + , characterIndex /= 0 && Set.notMember characterIndex ownedCharacters + ] + , keyProblems "progress.invalid_quest_key" ["progress", "quests"] questKeys + , keyProblems "progress.invalid_cleared_key" ["progress", "cleared_quests"] clearedKeys + , duplicateKeyProblems "progress.duplicate_quest_key" ["progress", "quests"] questKeys + , duplicateKeyProblems "progress.duplicate_cleared_key" ["progress", "cleared_quests"] clearedKeys + , grantProblems + ] + in ( problems + , if any ((== Error) . problemSeverity) problems + then Nothing + else Just (ValidSnapshot snapshot baseCharacters acquiredCharacters costumes) + ) + +questTuple :: State.QuestProgress -> (Word32, Word32) +questTuple quest = keyTuple (quest ^. StateFields.key) + +keyTuple :: State.QuestKey -> (Word32, Word32) +keyTuple key = (key ^. StateFields.packId, key ^. StateFields.questId) + +validateGrant :: Set.Set Text -> Word64 -> State.IndexedGrant -> [Problem] +validateGrant granted nextIndex grant = + let identity = grant ^. StateFields.identity + indices = toList (grant ^. StateFields.inventoryIndices) + path = ["inventory", "grant_items", identity] + in require (not (Text.null identity) && Set.member identity granted) + "inventory.grant_without_marker" path "historical grant must have an idempotency marker" [] + <> [ Problem "inventory.grant_invalid_index" Error path "issued index must be nonzero and below next_index" [index] + | index <- indices, index == 0 || index >= nextIndex + ] + <> duplicateProblems "inventory.grant_duplicate_index" path indices + +require :: Bool -> Text -> [Text] -> Text -> [Word64] -> [Problem] +require condition code path message related = + [Problem code Error path message related | not condition] + +duplicates :: Ord a => [a] -> [a] +duplicates = map head . filter ((> 1) . length) . group . sort + +duplicateProblems :: Text -> [Text] -> [Word64] -> [Problem] +duplicateProblems code path values = + [Problem code Error path "duplicate inventory identity" [value] | value <- duplicates values] + +nextIndexProblem :: Text -> [Text] -> Word64 -> [Word64] -> [Problem] +nextIndexProblem code path nextIndex existing = + require (null existing || nextIndex > maximum existing) code path "next index must be greater than every existing index" [nextIndex] + +keyProblems :: Text -> [Text] -> [(Word32, Word32)] -> [Problem] +keyProblems code path values = + [ Problem code Error path "pack_id and quest_id must both be positive" [fromIntegral pack, fromIntegral quest] + | (pack, quest) <- values + , pack == 0 || quest == 0 + ] + +duplicateKeyProblems :: Text -> [Text] -> [(Word32, Word32)] -> [Problem] +duplicateKeyProblems code path values = + [ Problem code Error path "duplicate pack/quest pair" [fromIntegral pack, fromIntegral quest] + | (pack, quest) <- duplicates values + ] diff --git a/haskell/state-migrator/state-migrator.cabal b/haskell/state-migrator/state-migrator.cabal new file mode 100644 index 0000000..16929c5 --- /dev/null +++ b/haskell/state-migrator/state-migrator.cabal @@ -0,0 +1,76 @@ +cabal-version: 3.0 +name: bd2-state-migrator +version: 0.1.0.0 +synopsis: Pure validation and migration kernel for BD2 player state +license: LicenseRef-BD2-ARR +build-type: Custom + +custom-setup + setup-depends: + base >=4.18 && <4.19 + , Cabal >=3.11 && <3.16 + , proto-lens-setup >=0.4 && <0.5 + +common warnings + default-language: GHC2021 + default-extensions: DerivingStrategies + ghc-options: -Wall -Wcompat -Widentities -Werror + build-tool-depends: + proto-lens-protoc:proto-lens-protoc + build-depends: + base >=4.18 && <4.19 + , bytestring >=0.11 && <0.12 + , containers >=0.6 && <0.7 + , lens-family >=2.1 && <2.2 + , lens-family-core >=2.1 && <2.2 + , proto-lens >=0.7 && <0.8 + , proto-lens-runtime >=0.7 && <0.8 + , text >=2.0 && <2.1 + +library + import: warnings + hs-source-dirs: src + exposed-modules: + BD2.State.Domain + BD2.State.Framing + BD2.State.Migrate.V1ToV2 + BD2.State.Repair.DuplicateCharacters + BD2.State.Repair.NextIndices + BD2.State.Repair.StepUpProgress + BD2.State.Repair.GachaProgress + BD2.State.Repair.CostumeOverflow + BD2.State.Repair.Pipeline + BD2.State.Validate + Proto.Bd2.State.Control.V1.Control + Proto.Bd2.State.Control.V1.Control_Fields + Proto.Bd2.State.Control.V2.Control + Proto.Bd2.State.Control.V2.Control_Fields + Proto.Bd2.State.V1.State + Proto.Bd2.State.V1.State_Fields + Proto.Bd2.State.V2.State + Proto.Bd2.State.V2.State_Fields + autogen-modules: + Proto.Bd2.State.Control.V1.Control + Proto.Bd2.State.Control.V1.Control_Fields + Proto.Bd2.State.Control.V2.Control + Proto.Bd2.State.Control.V2.Control_Fields + Proto.Bd2.State.V1.State + Proto.Bd2.State.V1.State_Fields + Proto.Bd2.State.V2.State + Proto.Bd2.State.V2.State_Fields + +executable bd2-state + import: warnings + hs-source-dirs: app + main-is: Main.hs + build-depends: + bd2-state-migrator + +test-suite state-migrator-test + import: warnings + type: exitcode-stdio-1.0 + hs-source-dirs: test + main-is: Main.hs + build-depends: + bd2-state-migrator + , QuickCheck >=2.16 && <2.19 diff --git a/haskell/state-migrator/test/Main.hs b/haskell/state-migrator/test/Main.hs new file mode 100644 index 0000000..fd854a9 --- /dev/null +++ b/haskell/state-migrator/test/Main.hs @@ -0,0 +1,405 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Main (main) where + +import BD2.State.Framing +import BD2.State.Domain (Problem (problemCode), CharacterRedirect (..)) +import BD2.State.Migrate.V1ToV2 +import BD2.State.Repair.DuplicateCharacters +import BD2.State.Repair.CostumeOverflow +import BD2.State.Repair.GachaProgress +import BD2.State.Repair.NextIndices +import BD2.State.Repair.Pipeline +import BD2.State.Repair.StepUpProgress +import BD2.State.Validate +import Data.Foldable (toList) +import Data.String (fromString) +import Data.ProtoLens (defMessage) +import Lens.Family2 ((&), (.~), (^.)) +import qualified Proto.Bd2.State.V1.State as State +import qualified Proto.Bd2.State.V1.State_Fields as StateFields +import qualified Proto.Bd2.State.V2.State as StateV2 +import qualified Proto.Bd2.State.V2.State_Fields as StateV2Fields +import qualified Proto.Bd2.State.Control.V2.Control as ControlV2 +import qualified Proto.Bd2.State.Control.V2.Control_Fields as ControlV2Fields +import Control.Monad (unless) +import System.Exit (exitFailure) +import Test.QuickCheck + +main :: IO () +main = do + results <- mapM quickCheckResult + [ property prop_frameRoundTrip + , property prop_validInventoryNextIndex + , property prop_consumedGrantedItemIsValid + , property prop_unissuedGrantIndexIsRejected + , property prop_equipmentGrantRequiresOwnedInstance + , property prop_equippedCharacterRequiresOwnedCharacter + , property prop_migrationProducesValidV2 + , property prop_migrationPreservesIdentityAndOrigin + , property prop_migrationPreservesEconomy + , property prop_v2SchemaRoundTrip + , property prop_migrationRejectsDuplicateCharacter + , property prop_currentMigrationIsIdempotent + , property prop_duplicateRepairRedirectsOwnersAndPreservesEconomy + , property prop_duplicateRepairRejectsAmbiguousBase + , property prop_nextIndexRepairIsMonotoneAndIdempotent + , property prop_nextIndexRepairUsesHistoricalGrants + , property prop_nextIndexRepairRejectsOverflow + , property prop_stepUpRepairUsesConsecutiveGrants + , property prop_stepUpRepairPreservesHigherStoredProgress + , property prop_stepUpRejectsWrongGameData + , property prop_gachaReplayOrdersDrawsAndIsIdempotent + , property prop_costumeOverflowIsAtomicAndIdempotent + , property prop_fullRepairPipelineProducesValidCurrent + ] + unless (all isSuccess results) exitFailure + +prop_frameRoundTrip :: Property +prop_frameRoundTrip = + let snapshot :: State.Snapshot + snapshot = defMessage & StateFields.formatVersion .~ 1 & StateFields.clientVersion .~ "2.34.13" & StateFields.gameDataVersion .~ "test" + in decodeDelimited (encodeDelimited snapshot) === Right snapshot + +prop_validInventoryNextIndex :: Positive Word -> Bool +prop_validInventoryNextIndex (Positive value) = + let index = fromIntegral (value `mod` 1000000 + 1) + item :: State.Item + item = defMessage & StateFields.inventoryIndex .~ index & StateFields.id .~ 1 & StateFields.type' .~ 1 & StateFields.count .~ 1 + inventory :: State.Inventory + inventory = defMessage & StateFields.nextIndex .~ index + 1 & StateFields.items .~ [item] + snapshot :: State.Snapshot + snapshot = defMessage & StateFields.formatVersion .~ 1 & StateFields.clientVersion .~ "2.34.13" & StateFields.gameDataVersion .~ "test" & StateFields.inventory .~ inventory + (problems, valid) = validateSnapshot snapshot + in null problems && case valid of Just _ -> True; Nothing -> False + +-- A mail reward can be spent later: grant_items records issuance, not a +-- foreign key into currently owned items. +prop_consumedGrantedItemIsValid :: Positive Word -> Bool +prop_consumedGrantedItemIsValid (Positive value) = + let index = fromIntegral (value `mod` 1000000 + 1) + grant :: State.IndexedGrant + grant = defMessage & StateFields.identity .~ "mail:test:items" & StateFields.inventoryIndices .~ [index] + inventory :: State.Inventory + inventory = defMessage & StateFields.nextIndex .~ index + 1 + & StateFields.grantedIdentities .~ ["mail:test:items"] & StateFields.grantItems .~ [grant] + snapshot :: State.Snapshot + snapshot = defMessage & StateFields.formatVersion .~ 1 & StateFields.clientVersion .~ "2.34.13" + & StateFields.gameDataVersion .~ "test" & StateFields.inventory .~ inventory + (problems, valid) = validateSnapshot snapshot + in null problems && case valid of Just _ -> True; Nothing -> False + +prop_unissuedGrantIndexIsRejected :: Positive Word -> Bool +prop_unissuedGrantIndexIsRejected (Positive value) = + let index = fromIntegral (value `mod` 1000000 + 1) + grant :: State.IndexedGrant + grant = defMessage & StateFields.identity .~ "mail:test:items" & StateFields.inventoryIndices .~ [index] + inventory :: State.Inventory + inventory = defMessage & StateFields.nextIndex .~ index + & StateFields.grantedIdentities .~ ["mail:test:items"] & StateFields.grantItems .~ [grant] + snapshot :: State.Snapshot + snapshot = defMessage & StateFields.formatVersion .~ 1 & StateFields.clientVersion .~ "2.34.13" + & StateFields.gameDataVersion .~ "test" & StateFields.inventory .~ inventory + (problems, _) = validateSnapshot snapshot + in any ((== "inventory.grant_invalid_index") . problemCode) problems + +prop_equipmentGrantRequiresOwnedInstance :: Positive Word -> Bool +prop_equipmentGrantRequiresOwnedInstance (Positive value) = + let index = fromIntegral (value `mod` 1000000 + 1) + grant :: State.NamedIndex + grant = defMessage & StateFields.identity .~ "quest:test:equip" & StateFields.inventoryIndex .~ index + equipment :: State.EquipmentInventory + equipment = defMessage & StateFields.nextIndex .~ index + 1 & StateFields.grants .~ [grant] + snapshot :: State.Snapshot + snapshot = defMessage & StateFields.formatVersion .~ 1 & StateFields.clientVersion .~ "2.34.13" + & StateFields.gameDataVersion .~ "test" & StateFields.equipment .~ equipment + (problems, _) = validateSnapshot snapshot + in any ((== "equipment.grant_missing_equipment") . problemCode) problems + +prop_equippedCharacterRequiresOwnedCharacter :: Positive Word -> Bool +prop_equippedCharacterRequiresOwnedCharacter (Positive value) = + let index = fromIntegral (value `mod` 1000000 + 1) + entry :: State.Equipment + entry = defMessage & StateFields.inventoryIndex .~ index & StateFields.id .~ 1 + & StateFields.useChar .~ (index + 1000000) + equipment :: State.EquipmentInventory + equipment = defMessage & StateFields.nextIndex .~ index + 1 & StateFields.equipment .~ [entry] + snapshot :: State.Snapshot + snapshot = defMessage & StateFields.formatVersion .~ 1 & StateFields.clientVersion .~ "2.34.13" + & StateFields.gameDataVersion .~ "test" & StateFields.equipment .~ equipment + (problems, _) = validateSnapshot snapshot + in any ((== "equipment.unknown_user") . problemCode) problems + +prop_migrationProducesValidV2 :: Positive Word -> Bool +prop_migrationProducesValidV2 value = + case migrateV1ToV2 (migrationFixture value) of + Left _ -> False + Right target -> null (validateV2 target) + +prop_migrationPreservesIdentityAndOrigin :: Positive Word -> Bool +prop_migrationPreservesIdentityAndOrigin value = + let source = migrationFixture value + baseIndex = source ^. StateFields.characters . StateFields.characters + acquiredIndex = source ^. StateFields.collection . StateFields.characters + in case migrateV1ToV2 source of + Left _ -> False + Right target -> + let records = toList (target ^. StateV2Fields.roster . StateV2Fields.characters) + in map (^. StateV2Fields.character . StateFields.inventoryIndex) records + == map (^. StateFields.inventoryIndex) (toList baseIndex <> toList acquiredIndex) + && map (^. StateV2Fields.origin) records + == [StateV2.CHARACTER_ORIGIN_BASE, StateV2.CHARACTER_ORIGIN_ACQUIRED] + +prop_migrationPreservesEconomy :: Positive Word -> Bool +prop_migrationPreservesEconomy value = + let source = migrationFixture value + in case migrateV1ToV2 source of + Left _ -> False + Right target -> target ^. StateV2Fields.wallet == source ^. StateFields.wallet + +prop_v2SchemaRoundTrip :: Positive Word -> Bool +prop_v2SchemaRoundTrip value = + case migrateV1ToV2 (migrationFixture value) of + Left _ -> False + Right target -> decodeDelimited (encodeDelimited target) == Right target + +prop_migrationRejectsDuplicateCharacter :: Positive Word -> Bool +prop_migrationRejectsDuplicateCharacter value = + let source = migrationFixture value + base = toList (source ^. StateFields.characters . StateFields.characters) + damaged = source & StateFields.characters . StateFields.characters .~ (base <> base) + in case migrateV1ToV2 damaged of + Left problems -> any ((== "characters.duplicate_index") . problemCode) problems + Right _ -> False + +prop_currentMigrationIsIdempotent :: Positive Word -> Bool +prop_currentMigrationIsIdempotent value = + case migrateV1ToV2 (migrationFixture value) of + Left _ -> False + Right target -> migrateCurrentV2 target == Right target + +prop_duplicateRepairRedirectsOwnersAndPreservesEconomy :: Positive Word -> Bool +prop_duplicateRepairRedirectsOwnersAndPreservesEconomy value = + case migrateV1ToV2 (migrationFixture value) of + Left _ -> False + Right original -> + let records = toList (original ^. StateV2Fields.roster . StateV2Fields.characters) + base = head records + acquired = records !! 1 + duplicateIndex = acquired ^. StateV2Fields.character . StateFields.inventoryIndex + baseIndex = base ^. StateV2Fields.character . StateFields.inventoryIndex + duplicate = acquired & StateV2Fields.character . StateFields.id .~ (base ^. StateV2Fields.character . StateFields.id) + grant :: State.CollectionGrant + grant = defMessage & StateFields.identity .~ "old" & StateFields.characterIndices .~ [duplicateIndex] + slot :: State.DeckSlot + slot = defMessage & StateFields.characterIndex .~ duplicateIndex & StateFields.slot .~ 1 + equipment :: State.Equipment + equipment = defMessage & StateFields.inventoryIndex .~ 1 & StateFields.id .~ 1 & StateFields.useChar .~ duplicateIndex + damaged = original + & StateV2Fields.roster . StateV2Fields.characters .~ [base, duplicate] + & StateV2Fields.collection . StateV2Fields.grants .~ [grant] + & StateV2Fields.deck . StateFields.battle .~ [slot] + & StateV2Fields.equipment . StateFields.equipment .~ [equipment] + & StateV2Fields.equipment . StateFields.nextIndex .~ 2 + in case repairDuplicateCharacters damaged of + Left _ -> False + Right (repaired, redirects) -> + length redirects == 1 + && redirectFromIndex (head redirects) == duplicateIndex + && redirectToIndex (head redirects) == baseIndex + && length (toList (repaired ^. StateV2Fields.roster . StateV2Fields.characters)) == 1 + && head (toList (repaired ^. StateV2Fields.roster . StateV2Fields.costumes)) ^. StateFields.useChar == baseIndex + && head (toList (repaired ^. StateV2Fields.deck . StateFields.battle)) ^. StateFields.characterIndex == baseIndex + && head (toList (repaired ^. StateV2Fields.equipment . StateFields.equipment)) ^. StateFields.useChar == baseIndex + && null (toList (head (toList (repaired ^. StateV2Fields.collection . StateV2Fields.grants)) ^. StateFields.characterIndices)) + && repaired ^. StateV2Fields.wallet == original ^. StateV2Fields.wallet + && null (validateV2 repaired) + && case repairDuplicateCharacters repaired of + Right (same, []) -> same == repaired + _ -> False + +prop_duplicateRepairRejectsAmbiguousBase :: Positive Word -> Bool +prop_duplicateRepairRejectsAmbiguousBase value = + case migrateV1ToV2 (migrationFixture value) of + Left _ -> False + Right original -> + let base = head (toList (original ^. StateV2Fields.roster . StateV2Fields.characters)) + damaged = original & StateV2Fields.roster . StateV2Fields.characters .~ [base, base] + in case repairDuplicateCharacters damaged of + Left problems -> any ((== "repair.duplicate_base_character") . problemCode) problems + Right _ -> False + +prop_nextIndexRepairIsMonotoneAndIdempotent :: Positive Word -> Bool +prop_nextIndexRepairIsMonotoneAndIdempotent value = + case migrateV1ToV2 (migrationFixture value) of + Left _ -> False + Right target -> + let stale = target & StateV2Fields.roster . StateV2Fields.nextAcquiredCharacterIndex .~ 1 + & StateV2Fields.roster . StateV2Fields.nextCostumeIndex .~ 1 + in case repairNextIndices stale of + Left _ -> False + Right repaired -> + repaired ^. StateV2Fields.roster . StateV2Fields.nextAcquiredCharacterIndex + == max 920000001 (target ^. StateV2Fields.roster . StateV2Fields.nextAcquiredCharacterIndex) + && repaired ^. StateV2Fields.roster . StateV2Fields.nextCostumeIndex + == max 930000001 (target ^. StateV2Fields.roster . StateV2Fields.nextCostumeIndex) + && repairNextIndices repaired == Right repaired + && repaired ^. StateV2Fields.wallet == stale ^. StateV2Fields.wallet + +prop_nextIndexRepairUsesHistoricalGrants :: Positive Word -> Bool +prop_nextIndexRepairUsesHistoricalGrants (Positive value) = + let issuedIndex = fromIntegral (value `mod` 1000000 + 900000001) + grant :: State.IndexedGrant + grant = defMessage & StateFields.identity .~ "spent-mail" & StateFields.inventoryIndices .~ [issuedIndex] + snapshot :: StateV2.Snapshot + snapshot = defMessage & StateV2Fields.inventory . StateFields.grantItems .~ [grant] + in case repairNextIndices snapshot of + Left _ -> False + Right repaired -> repaired ^. StateV2Fields.inventory . StateFields.nextIndex == issuedIndex + 1 + +prop_nextIndexRepairRejectsOverflow :: Bool +prop_nextIndexRepairRejectsOverflow = + let item :: State.Item + item = defMessage & StateFields.inventoryIndex .~ maxBound + snapshot :: StateV2.Snapshot + snapshot = defMessage & StateV2Fields.inventory . StateFields.items .~ [item] + in case repairNextIndices snapshot of + Left problems -> any ((== "inventory.next_index_overflow") . problemCode) problems + Right _ -> False + +prop_stepUpRepairUsesConsecutiveGrants :: Positive Word -> Bool +prop_stepUpRepairUsesConsecutiveGrants (Positive value) = + let first = fromIntegral (value `mod` 1000000 + 8100118) + design = StepUpFact "test" 29 [first, first + 1, first + 2, first + 3] + grant identity = (defMessage :: State.CollectionGrant) & StateFields.identity .~ identity + fixture :: StateV2.Snapshot + fixture = defMessage & StateV2Fields.gameDataVersion .~ "test" + & StateV2Fields.collection . StateV2Fields.grants .~ + [ grant ("regular-gacha:" <> fromString (show first) <> ":session:a") + , grant ("regular-gacha:" <> fromString (show (first + 1)) <> ":seq:2") + , grant ("regular-gacha:" <> fromString (show (first + 3)) <> ":seq:4") + ] + in case repairStepUpProgress design fixture of + Left _ -> False + Right (repaired, count) -> + count == 2 && case repairStepUpProgress design repaired of + Right (same, countAgain) -> same == repaired && countAgain == 2 + _ -> False + +prop_stepUpRepairPreservesHigherStoredProgress :: Positive Word -> Bool +prop_stepUpRepairPreservesHigherStoredProgress (Positive value) = + let first = fromIntegral (value `mod` 1000000 + 8100118) + design = StepUpFact "test" 29 [first, first + 1, first + 2, first + 3] + stored = (defMessage :: State.NamedCount) & StateFields.identity .~ "29" & StateFields.value .~ 3 + fixture :: StateV2.Snapshot + fixture = defMessage & StateV2Fields.gameDataVersion .~ "test" + & StateV2Fields.collection . StateV2Fields.stepUpProgress .~ [stored] + in repairStepUpProgress design fixture == Right (fixture, 3) + +prop_stepUpRejectsWrongGameData :: Positive Word -> Bool +prop_stepUpRejectsWrongGameData (Positive value) = + let gachaId = fromIntegral (value `mod` 1000000 + 8100118) + fixture :: StateV2.Snapshot + fixture = defMessage & StateV2Fields.gameDataVersion .~ "20260910162539" + in case repairStepUpProgress (StepUpFact "wrong" 29 [gachaId]) fixture of + Left problems -> any ((== "repair.step_up_game_data_version") . problemCode) problems + Right _ -> False + +prop_gachaReplayOrdersDrawsAndIsIdempotent :: Positive Word -> Bool +prop_gachaReplayOrdersDrawsAndIsIdempotent (Positive value) = + let gachaId = fromIntegral (value `mod` 1000000 + 100) + groupId = gachaId + 1000 + fiveId = gachaId + 2000 + threeId = gachaId + 3000 + grant identity costumeId = (defMessage :: State.CollectionGrant) + & StateFields.identity .~ identity & StateFields.viewCostumeIds .~ [costumeId] + fixture :: StateV2.Snapshot + fixture = defMessage & StateV2Fields.collection . StateV2Fields.grants .~ + [ grant ("regular-gacha:" <> fromString (show gachaId) <> ":session:s:seq:2") threeId + , grant ("regular-gacha:" <> fromString (show gachaId) <> ":session:s:seq:1") fiveId + ] + groups = [GachaGroupFact gachaId groupId 8 10] + grades = [CostumeGradeFact fiveId 5, CostumeGradeFact threeId 3] + in case repairGachaProgress groups grades fixture of + Left _ -> False + Right repaired -> + let users = toList (repaired ^. StateV2Fields.collection . StateV2Fields.gachaUsers) + fixed = toList (repaired ^. StateV2Fields.collection . StateV2Fields.gachaFixed) + user = head users ^. StateFields.user + fixedCounts = map ((^. StateFields.count) . (^. StateFields.fixed)) fixed + in length users == 1 + && user ^. StateFields.groupId == groupId + && user ^. StateFields.totalBuyCount == 2 + && user ^. StateFields.point == 20 + && fixedCounts == [1, 1] + && repaired ^. StateV2Fields.collection . StateV2Fields.gachaApplied + == [ "regular-gacha:" <> fromString (show gachaId) <> ":session:s:seq:1" + , "regular-gacha:" <> fromString (show gachaId) <> ":session:s:seq:2" ] + && repairGachaProgress groups grades repaired == Right repaired + +prop_costumeOverflowIsAtomicAndIdempotent :: Positive Word -> Bool +prop_costumeOverflowIsAtomicAndIdempotent value = + case migrateV1ToV2 (migrationFixture value) of + Left _ -> False + Right target -> + let costume = head (toList (target ^. StateV2Fields.roster . StateV2Fields.costumes)) + inventoryIndex = costume ^. StateFields.inventoryIndex + overCostume = costume & StateFields.level .~ 7 + upgrade = (defMessage :: State.CostumeUpgrade) + & StateFields.inventoryIndex .~ inventoryIndex & StateFields.costumeId .~ 20 + & StateFields.before .~ 5 & StateFields.after .~ 6 & StateFields.sortId .~ 3 + grant = (defMessage :: State.CollectionGrant) + & StateFields.identity .~ "overflow:test" & StateFields.upgrades .~ [upgrade] + damaged = target + & StateV2Fields.roster . StateV2Fields.costumes .~ [overCostume] + & StateV2Fields.collection . StateV2Fields.grants .~ [grant] + facts = [CostumeFact 20 5 20 0 10] + in case repairCostumeOverflow facts [] damaged of + Left _ -> False + Right repaired -> + let resultGrant = head (toList (repaired ^. StateV2Fields.collection . StateV2Fields.grants)) + in head (toList (repaired ^. StateV2Fields.roster . StateV2Fields.costumes)) ^. StateFields.level == 5 + && length (toList (resultGrant ^. StateFields.exchanges)) == 1 + && length (toList (resultGrant ^. StateFields.upgrades)) == 1 + && repaired ^. StateV2Fields.wallet . StateFields.mileage == 10 + && repaired ^. StateV2Fields.wallet . StateFields.grantedIdentities == ["overflow:test"] + && repairCostumeOverflow facts [] repaired == Right repaired + +prop_fullRepairPipelineProducesValidCurrent :: Positive Word -> Bool +prop_fullRepairPipelineProducesValidCurrent value = + let source = migrationFixture value + costume = (defMessage :: ControlV2.CostumeFact) + & ControlV2Fields.costumeId .~ 20 & ControlV2Fields.grade .~ 3 + & ControlV2Fields.maxLevel .~ 5 & ControlV2Fields.overflowItemType .~ 20 + & ControlV2Fields.overflowItemCount .~ 2 + context = (defMessage :: ControlV2.ValidationContext) + & ControlV2Fields.gameDataVersion .~ "test" & ControlV2Fields.costumes .~ [costume] + request = (defMessage :: ControlV2.RepairRequest) + & ControlV2Fields.bridgeApiVersion .~ 2 & ControlV2Fields.source .~ source & ControlV2Fields.context .~ context + in case repairV1ToCurrent request of + Left _ -> False + Right (target, _) -> null (validateV2 target) + +migrationFixture :: Positive Word -> State.Snapshot +migrationFixture (Positive value) = + let baseIndex = fromIntegral (value `mod` 1000000 + 1) + acquiredIndex = baseIndex + 1000000 + costumeIndex = acquiredIndex + 1000000 + baseCharacter :: State.Character + baseCharacter = defMessage & StateFields.inventoryIndex .~ baseIndex & StateFields.id .~ 1 + acquiredCharacter :: State.Character + acquiredCharacter = defMessage & StateFields.inventoryIndex .~ acquiredIndex & StateFields.id .~ 2 + costume :: State.Costume + costume = defMessage & StateFields.inventoryIndex .~ costumeIndex & StateFields.id .~ 20 & StateFields.useChar .~ acquiredIndex + characters :: State.CharacterState + characters = defMessage & StateFields.clientVersion .~ "2.34.13" & StateFields.characters .~ [baseCharacter] + collection :: State.Collection + collection = defMessage & StateFields.clientVersion .~ "2.34.13" + & StateFields.nextCharacterIndex .~ acquiredIndex + 1 & StateFields.nextCostumeIndex .~ costumeIndex + 1 + & StateFields.characters .~ [acquiredCharacter] & StateFields.costumes .~ [costume] + wallet :: State.Wallet + wallet = defMessage & StateFields.clientVersion .~ "2.34.13" + & StateFields.gold .~ fromIntegral value & StateFields.freeJewelry .~ fromIntegral value + 7 + in defMessage & StateFields.formatVersion .~ 1 & StateFields.clientVersion .~ "2.34.13" + & StateFields.gameDataVersion .~ "test" & StateFields.characters .~ characters + & StateFields.collection .~ collection & StateFields.wallet .~ wallet diff --git a/plugins/CaptureEnvironment/CaptureEnvironment.csproj b/plugins/CaptureEnvironment/CaptureEnvironment.csproj new file mode 100644 index 0000000..62f9730 --- /dev/null +++ b/plugins/CaptureEnvironment/CaptureEnvironment.csproj @@ -0,0 +1,38 @@ + + + netstandard2.1 + latest + disable + BD2CaptureEnvironment + Bd2CaptureEnvironment + + + + + + + $(GameDir)\BepInEx\core\BepInEx.dll + false + + + $(GameDir)\BepInEx\core\0Harmony.dll + false + + + $(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll + false + + + $(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll + false + + + $(GameDir)\BrownDust II_Data\Managed\UnityEngine.UnityWebRequestModule.dll + false + + + $(GameDir)\BrownDust II_Data\Managed\Google.Protobuf.dll + false + + + diff --git a/plugins/CaptureEnvironment/Plugin.cs b/plugins/CaptureEnvironment/Plugin.cs new file mode 100644 index 0000000..acc0bca --- /dev/null +++ b/plugins/CaptureEnvironment/Plugin.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using BepInEx; +using BepInEx.Logging; +using Google.Protobuf; +using HarmonyLib; +using UnityEngine; +using UnityEngine.Networking; + +namespace Bd2CaptureEnvironment; + +[BepInPlugin(Guid, Name, Version)] +public sealed class Plugin : BaseUnityPlugin +{ + public const string Guid = "bd2.capture.environment"; + public const string Name = "BD2 Capture Environment"; + public const string Version = "0.1.1"; + + private const int MaxBodyBytes = 16 * 1024 * 1024; + private const string PlayerPrefsPrefix = "BD2OfficialCapture23413:"; + private static readonly object FileLock = new object(); + private static ManualLogSource Log; + private static string CaptureDirectory; + private static string JsonlPath; + private static string IsolatedDataDirectory; + private static string SharedGameDataDirectory; + private static long Sequence; + private static long LastApiRequest; + + private void Awake() + { + try + { + Log = Logger; + string root = Paths.GameRootPath; + IsolatedDataDirectory = Path.GetFullPath(Path.Combine(root, "IsolatedUserData")); + SharedGameDataDirectory = ReadRequiredArgument("-bd2SharedGameData"); + if (!Directory.Exists(SharedGameDataDirectory)) + throw new DirectoryNotFoundException( + "Shared GameData directory not found: " + SharedGameDataDirectory); + CaptureDirectory = Path.GetFullPath(Path.Combine( + root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss"))); + JsonlPath = Path.Combine(CaptureDirectory, "capture.jsonl"); + Directory.CreateDirectory(IsolatedDataDirectory); + Directory.CreateDirectory(Path.Combine(CaptureDirectory, "bodies")); + + var harmony = new Harmony(Guid); + InstallStorageIsolation(harmony); + string effectiveDataPath = Application.persistentDataPath; + if (!string.Equals( + Path.GetFullPath(effectiveDataPath).TrimEnd(Path.DirectorySeparatorChar), + IsolatedDataDirectory.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + "persistentDataPath isolation verification failed: " + effectiveDataPath); + InstallRawCapture(harmony); + InstallPlaintextCapture(harmony); + WriteMetadata(); + Logger.LogInfo("Official capture environment active"); + Logger.LogInfo("persistentDataPath => " + IsolatedDataDirectory); + Logger.LogInfo("shared GameData => " + SharedGameDataDirectory); + Logger.LogInfo("capture => " + CaptureDirectory); + } + catch (Exception ex) + { + Logger.LogError("Capture environment setup failed: " + ex); + } + } + + private static string ReadRequiredArgument(string name) + { + string[] arguments = Environment.GetCommandLineArgs(); + for (int i = 0; i + 1 < arguments.Length; i++) + { + if (string.Equals(arguments[i], name, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(arguments[i + 1])) + return Path.GetFullPath(arguments[i + 1]); + } + throw new ArgumentException("Required command-line argument is missing: " + name); + } + + private static void InstallStorageIsolation(Harmony harmony) + { + PropertyInfo persistent = typeof(Application).GetProperty( + "persistentDataPath", BindingFlags.Static | BindingFlags.Public); + MethodInfo getter = persistent?.GetGetMethod(); + if (getter == null) + throw new MissingMethodException("Application.persistentDataPath getter not found"); + harmony.Patch(getter, prefix: new HarmonyMethod( + typeof(Plugin), nameof(PersistentDataPathPrefix))); + + var names = new HashSet(StringComparer.Ordinal) + { + "GetString", "SetString", "GetInt", "SetInt", "GetFloat", "SetFloat", + "HasKey", "DeleteKey" + }; + foreach (MethodInfo method in typeof(PlayerPrefs).GetMethods( + BindingFlags.Static | BindingFlags.Public)) + { + ParameterInfo[] parameters = method.GetParameters(); + if (!names.Contains(method.Name) || parameters.Length == 0 || + parameters[0].ParameterType != typeof(string)) + continue; + harmony.Patch(method, prefix: new HarmonyMethod( + typeof(Plugin), nameof(PlayerPrefsKeyPrefix))); + } + + MethodInfo deleteAll = typeof(PlayerPrefs).GetMethod( + "DeleteAll", BindingFlags.Static | BindingFlags.Public, + null, Type.EmptyTypes, null); + if (deleteAll != null) + harmony.Patch(deleteAll, prefix: new HarmonyMethod( + typeof(Plugin), nameof(BlockPlayerPrefsDeleteAll))); + } + + private static bool PersistentDataPathPrefix(ref string __result) + { + __result = IsolatedDataDirectory; + return false; + } + + private static void PlayerPrefsKeyPrefix(ref string key) + { + if (key != null && !key.StartsWith(PlayerPrefsPrefix, StringComparison.Ordinal)) + key = PlayerPrefsPrefix + key; + } + + private static bool BlockPlayerPrefsDeleteAll() + { + Log?.LogWarning("Blocked PlayerPrefs.DeleteAll to protect the primary client profile"); + return false; + } + + private static void InstallRawCapture(Harmony harmony) + { + MethodInfo send = typeof(UnityWebRequest).GetMethod( + "SendWebRequest", BindingFlags.Instance | BindingFlags.Public, + null, Type.EmptyTypes, null); + if (send == null) + throw new MissingMethodException("UnityWebRequest.SendWebRequest not found"); + harmony.Patch(send, + prefix: new HarmonyMethod(typeof(Plugin), nameof(SendWebRequestPrefix)), + postfix: new HarmonyMethod(typeof(Plugin), nameof(SendWebRequestPostfix))); + } + + private sealed class RequestState + { + public long Id; + public string Method; + public string Host; + public string Path; + public bool Capture; + } + + private static void SendWebRequestPrefix(UnityWebRequest __instance, out RequestState __state) + { + __state = null; + try + { + if (__instance == null || !TrySafeUri(__instance.url, out Uri uri) || + !IsGameApi(uri, __instance.method)) + return; + var state = new RequestState + { + Id = Interlocked.Increment(ref Sequence), + Method = __instance.method ?? "", + Host = uri.Host, + Path = uri.AbsolutePath, + Capture = true + }; + __state = state; + Interlocked.Exchange(ref LastApiRequest, state.Id); + byte[] body = null; + try { body = __instance.uploadHandler?.data; } catch { } + string file = SaveBody(state.Id, "req", state.Path, body); + Emit("req", state.Id, state.Method, state.Host, state.Path, 0, + body?.Length ?? 0, file, null, null); + } + catch (Exception ex) + { + Log?.LogWarning("request capture failed: " + ex.Message); + } + } + + private static void SendWebRequestPostfix( + UnityWebRequest __instance, UnityWebRequestAsyncOperation __result, + RequestState __state) + { + if (__state == null || !__state.Capture || __result == null) + return; + __result.completed += delegate + { + try + { + byte[] body = null; + string error = null; + long responseCode = 0; + string result = null; + try { body = __instance.downloadHandler?.data; } + catch (Exception ex) { error = AppendError(error, ex.Message); } + try { responseCode = (long)__instance.responseCode; } + catch (Exception ex) { error = AppendError(error, ex.Message); } + try { result = __instance.result.ToString(); } + catch (Exception ex) { error = AppendError(error, ex.Message); } + try { error = AppendError(error, __instance.error); } + catch (Exception ex) { error = AppendError(error, ex.Message); } + string file = SaveBody(__state.Id, "resp", __state.Path, body); + Emit("resp", __state.Id, __state.Method, __state.Host, __state.Path, + responseCode, body?.Length ?? 0, file, result, error); + } + catch (Exception ex) + { + Log?.LogWarning("response capture failed: " + ex.Message); + } + }; + } + + private static string AppendError(string current, string next) + { + if (string.IsNullOrWhiteSpace(next)) return current; + return string.IsNullOrWhiteSpace(current) ? next : current + "; " + next; + } + + private static void InstallPlaintextCapture(Harmony harmony) + { + Type manager = FindType("BDNetwork.NetworkManager"); + MethodInfo gameDataPath = manager?.GetMethod("GetPachedGameDataPath", + BindingFlags.Instance | BindingFlags.Public); + if (gameDataPath == null) + throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found"); + harmony.Patch(gameDataPath, prefix: new HarmonyMethod( + typeof(Plugin), nameof(GameDataPathPrefix))); + Log?.LogInfo("Shared GameData path hook installed"); + + MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public) + .FirstOrDefault(method => method.Name == "Send" && + method.GetParameters().Length == 6 && + typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType)); + if (send != null) + { + harmony.Patch(send, prefix: new HarmonyMethod( + typeof(Plugin), nameof(GameSendPrefix))); + Log?.LogInfo("Plaintext request capture installed"); + } + else + { + Log?.LogWarning("NetworkManager.Send plaintext hook not found"); + } + + MethodInfo decrypt = manager?.GetMethod("AESDecrypt256", + BindingFlags.Instance | BindingFlags.Public); + if (decrypt != null) + { + harmony.Patch(decrypt, postfix: new HarmonyMethod( + typeof(Plugin), nameof(DecryptPostfix))); + Log?.LogInfo("Plaintext response capture installed"); + } + else + { + Log?.LogWarning("NetworkManager.AESDecrypt256 hook not found"); + } + } + + private static bool GameDataPathPrefix(ref string __result) + { + __result = SharedGameDataDirectory; + return false; + } + + private static void GameSendPrefix(IMessage __0) + { + try + { + if (__0 == null) return; + byte[] body = __0.ToByteArray(); + long id = Interlocked.Increment(ref Sequence); + string type = __0.GetType().FullName ?? __0.GetType().Name; + string file = SaveBody(id, "proto_req", type, body); + Emit("proto_req", id, "", "", "", 0, body.Length, file, null, type); + } + catch (Exception ex) + { + Log?.LogWarning("plaintext request capture failed: " + ex.Message); + } + } + + private static void DecryptPostfix(string __result) + { + try + { + if (string.IsNullOrEmpty(__result)) return; + byte[] body = Convert.FromBase64String(__result); + long id = Interlocked.Read(ref LastApiRequest); + string file = SaveBody(id, "proto_resp", "protobuf", body); + Emit("proto_resp", id, "", "", "", 0, body.Length, file, null, null); + } + catch (FormatException) { } + catch (Exception ex) + { + Log?.LogWarning("plaintext response capture failed: " + ex.Message); + } + } + + private static bool IsGameApi(Uri uri, string method) + { + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + return false; + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase)) + return false; + string host = uri.Host ?? ""; + return host.Equals("bd2.pmang.cloud", StringComparison.OrdinalIgnoreCase) || + host.EndsWith(".bd2.pmang.cloud", StringComparison.OrdinalIgnoreCase); + } + + private static bool TrySafeUri(string value, out Uri uri) + { + return Uri.TryCreate(value, UriKind.Absolute, out uri); + } + + private static string SaveBody(long id, string phase, string path, byte[] body) + { + if (body == null || body.Length == 0 || body.Length > MaxBodyBytes) + return null; + string safe = new string((path ?? "body") + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray()).Trim('_'); + if (safe.Length == 0) safe = "body"; + if (safe.Length > 70) safe = safe.Substring(safe.Length - 70); + string stem = id.ToString("D6") + "_" + phase + "_" + safe; + string name; + lock (FileLock) + { + name = stem + ".bin"; + int duplicate = 2; + while (File.Exists(Path.Combine(CaptureDirectory, "bodies", name))) + name = stem + "_" + duplicate++ + ".bin"; + File.WriteAllBytes(Path.Combine(CaptureDirectory, "bodies", name), body); + } + return "bodies/" + name; + } + + private static void Emit(string ev, long id, string method, string host, + string path, long status, int length, string rawFile, string result, + string note) + { + string json = "{" + + "\"ts\":\"" + Escape(DateTime.Now.ToString("O")) + "\"," + + "\"ev\":\"" + Escape(ev) + "\"," + + "\"id\":" + id + "," + + "\"method\":\"" + Escape(method) + "\"," + + "\"host\":\"" + Escape(host) + "\"," + + "\"path\":\"" + Escape(path) + "\"," + + "\"status\":" + status + "," + + "\"length\":" + length + "," + + "\"raw_file\":" + JsonString(rawFile) + "," + + "\"result\":" + JsonString(result) + "," + + "\"note\":" + JsonString(note) + "}"; + lock (FileLock) + File.AppendAllText(JsonlPath, json + Environment.NewLine, new UTF8Encoding(false)); + } + + private static string JsonString(string value) + { + return value == null ? "null" : "\"" + Escape(value) + "\""; + } + + private static string Escape(string value) + { + if (value == null) return ""; + return value.Replace("\\", "\\\\").Replace("\"", "\\\"") + .Replace("\r", "\\r").Replace("\n", "\\n"); + } + + private static Type FindType(string fullName) + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type type = assembly.GetType(fullName, false); + if (type != null) return type; + } + try { return Assembly.Load("Assembly-CSharp")?.GetType(fullName, false); } + catch { return null; } + } + + private static void WriteMetadata() + { + File.WriteAllText(Path.Combine(CaptureDirectory, "README.txt"), + "BD2 2.34.13 official API capture.\r\n" + + "No Cookie/Authorization headers or URL query strings are recorded.\r\n" + + "Raw protobuf/account bodies can still contain private account data. Do not share this directory.\r\n" + + "persistentDataPath=" + IsolatedDataDirectory + "\r\n", + new UTF8Encoding(false)); + } +} diff --git a/plugins/CaptureEnvironment/README.md b/plugins/CaptureEnvironment/README.md new file mode 100644 index 0000000..8f7837c --- /dev/null +++ b/plugins/CaptureEnvironment/README.md @@ -0,0 +1,40 @@ +# BD2 official capture environment plugin + +This BepInEx plugin is only for the separately extracted official 2.34.13 client. + +- redirects `Application.persistentDataPath` to `/IsolatedUserData`; +- prefixes game `PlayerPrefs` keys with `BD2OfficialCapture23413:`; +- keeps account/profile/Neo/Intro data isolated while patching + `NetworkManager.GetPachedGameDataPath()` to the existing `Data/t` and while + the launcher validates junctions for shared `SoundData`, Addressables, + VideoData and the localized `StreamingAssets/aa` bundle store; +- captures BD2 API request/response bodies without a proxy or TLS certificate; +- captures protobuf requests before encryption and responses after AES decryption; +- omits Cookie/Authorization headers and URL query strings; +- skips GET/static resources and bodies larger than 16 MiB. + +HTTP events and protobuf events have independent IDs. A Batch response can +decrypt many protobuf messages under one HTTP request ID; repeated bodies get a +numeric filename suffix and are never overwritten. + +The raw body files may still contain private account data. Do not publish the +generated `Capture` directory. This plugin does not redirect the game API and +must not be installed together with `BD2LocalIdentity.dll` in the official +comparison client. + +Unity starts writing its default Player log before BepInEx can patch the data +path. Always launch through `tools/python/start-official-capture.ps1`; it supplies an +explicit isolated `-logFile` path, passes the shared GameData location to the +plugin and validates all static-data junctions. The launcher requires explicit +official-client and local-client directory arguments. + +Pass them explicitly, for example: + +```powershell +.\tools\python\start-official-capture.ps1 ` + -GameDir "" ` + -LocalClientDir "" +``` + +The launcher derives the shared user-data directory for the current Windows +account; `-SharedDataDir` overrides it when the game uses another location. diff --git a/plugins/LocalIdentity/LocalIdentity.csproj b/plugins/LocalIdentity/LocalIdentity.csproj new file mode 100644 index 0000000..5ec072d --- /dev/null +++ b/plugins/LocalIdentity/LocalIdentity.csproj @@ -0,0 +1,30 @@ + + + netstandard2.1 + latest + disable + BD2LocalIdentity + Bd2LocalIdentity + + + + + + + $(GameDir)\BepInEx\core\BepInEx.dll + false + + + $(GameDir)\BepInEx\core\0Harmony.dll + false + + + $(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll + false + + + $(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll + false + + + diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs new file mode 100644 index 0000000..a7ed0d6 --- /dev/null +++ b/plugins/LocalIdentity/Plugin.cs @@ -0,0 +1,318 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; +using BepInEx; +using BepInEx.Logging; +using HarmonyLib; +using UnityEngine; + +namespace Bd2LocalIdentity; + +[BepInPlugin(Guid, Name, Version)] +public sealed class Plugin : BaseUnityPlugin +{ + public const string Guid = "bd2.localidentity"; + public const string Name = "BD2 Local Identity"; + public const string Version = "0.4.0"; + private const string LocalServerURL = "http://127.0.0.1:8080/game/"; + private static ManualLogSource Log; + + private void Awake() + { + try + { + Log = Logger; + // The non-SDK branch creates/uses this local token and calls + // SendMaintenanceInfo directly, bypassing Neon account UI. + PlayerPrefs.SetString("AccessToken", "bd2-local-development-user"); + PlayerPrefs.Save(); + + Type appManager = FindType("AppManager"); + PropertyInfo useSdk = appManager?.GetProperty( + "ὬὡὬὢὩὬὬὧὨὩὦ", + BindingFlags.Instance | BindingFlags.Public); + MethodInfo getter = useSdk?.GetGetMethod(); + if (getter == null) + { + throw new MissingMethodException("AppManager.UseSdk getter was not found (client version mismatch)"); + } + + MethodInfo prefix = typeof(Plugin).GetMethod( + nameof(UseSdkPrefix), + BindingFlags.Static | BindingFlags.NonPublic); + Harmony harmony = new Harmony(Guid); + harmony.Patch(getter, prefix: new HarmonyMethod(prefix)); + + Type introUI = FindType("IntroUI"); + MethodInfo sendMaintenance = introUI?.GetMethod( + "SendMaintenanceInfo", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(bool) }, + null); + MethodInfo maintenancePrefix = typeof(Plugin).GetMethod( + nameof(SendMaintenancePrefix), + BindingFlags.Static | BindingFlags.NonPublic); + if (sendMaintenance == null || maintenancePrefix == null) + { + throw new MissingMethodException("IntroUI.SendMaintenanceInfo(bool) was not found"); + } + harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(maintenancePrefix)); + + MethodInfo finishMaintenance = introUI?.GetMethod( + "OnFinishMaintenanceRequest", + BindingFlags.Instance | BindingFlags.Public); + if (finishMaintenance == null) + { + throw new MissingMethodException("IntroUI.OnFinishMaintenanceRequest was not found"); + } + harmony.Patch( + finishMaintenance, + postfix: new HarmonyMethod(typeof(Plugin), nameof(OnFinishMaintenancePostfix))); + + MethodInfo falseTimeoutTelemetry = introUI?.GetMethod( + "ὭὭὦὫὤὡὪὪὡὨὧ", + BindingFlags.Instance | BindingFlags.NonPublic); + if (falseTimeoutTelemetry == null) + { + throw new MissingMethodException("IntroUI maintenance timeout telemetry method was not found"); + } + harmony.Patch( + falseTimeoutTelemetry, + prefix: new HarmonyMethod(typeof(Plugin), nameof(SkipLocalTimeoutTelemetry))); + + InstallLocalPurchaseBypass(harmony); + + InstallDatabaseDiagnostics(harmony); + Logger.LogInfo("Local identity active: AppManager.UseSdk => false"); + } + catch (Exception ex) + { + Logger.LogError("Local identity patch failed: " + ex); + } + } + + private static bool UseSdkPrefix(ref bool __result) + { + __result = false; + return false; + } + + private static void SendMaintenancePrefix() + { + Type serverURLInfo = FindType("ὫὢὮὢὣὥὯὪὡὦὯ"); + FieldInfo maintenanceUri = serverURLInfo?.GetField( + "ὢὭὪὨὣὮὧὦὠὮὦ", + BindingFlags.Static | BindingFlags.Public); + if (maintenanceUri == null) + { + throw new MissingFieldException("BDNetwork.ServerURLInfo.MaintenanceUri was not found"); + } + maintenanceUri.SetValue(null, new Uri(LocalServerURL)); + Log?.LogInfo("MaintenanceUri => " + LocalServerURL); + } + + private static void OnFinishMaintenancePostfix(object __instance) + { + // 2.34.13's CancelMaintenanceTimeout cancels the active CTS and then + // immediately stores a fresh CTS. Depending on async scheduling, the + // timeout task can capture that fresh token after the successful + // response and emit a false 10-second timeout. Cancel the replacement + // token only after OnFinishMaintenanceRequest has completed. + FieldInfo timeout = __instance.GetType().GetField( + "ὬὭὥὪὢὭὫὩὭὭὬ", + BindingFlags.Instance | BindingFlags.NonPublic); + CancellationTokenSource source = timeout?.GetValue(__instance) as CancellationTokenSource; + source?.Cancel(); + Log?.LogInfo("Maintenance timeout guard cancelled after successful response"); + } + + private static bool SkipLocalTimeoutTelemetry() + { + // This method only emits intro_server_info_timeout telemetry after ten + // seconds. Network request failures have their own callbacks. In + // 2.34.13 it can outlive a successful local maintenance response. + return false; + } + + private static void InstallLocalPurchaseBypass(Harmony harmony) + { + Type platformRuler = FindType("ὮὮὫὭὢὩὭὢὦὪὠ"); + MethodInfo getProducts = platformRuler? + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .FirstOrDefault(method => + method.Name == "ὠὨὯὣὧὨὭὦὥὪὠ" && + method.GetParameters().Length == 6); + if (getProducts == null) + { + throw new MissingMethodException("PlatformRuler.GetProductAsync was not found"); + } + harmony.Patch( + getProducts, + prefix: new HarmonyMethod(typeof(Plugin), nameof(GetProductsPrefix))); + + Type platformManager = FindType("gamfs.Platform.PlatformManager"); + MethodInfo purchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public) + .FirstOrDefault(method => method.Name == "Purchase" && method.GetParameters().Length == 3); + MethodInfo finishPurchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public) + .FirstOrDefault(method => method.Name == "FinishPurchase" && method.GetParameters().Length == 2); + if (purchase == null || finishPurchase == null) + throw new MissingMethodException("PlatformManager purchase methods were not found"); + harmony.Patch(purchase, prefix: new HarmonyMethod( + typeof(Plugin), nameof(LocalPurchasePrefix))); + harmony.Patch(finishPurchase, prefix: new HarmonyMethod( + typeof(Plugin), 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 2.34.13 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") + { + Type purchaseData = FindType("ὥὮὯὪὯὫὯὤὩὬὤ"); + object result = Activator.CreateInstance(purchaseData, new object[] + { + __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; + } + + private static void InstallDatabaseDiagnostics(Harmony harmony) + { + Type rawDataManager = FindType("RawDataManager"); + MethodInfo dbLoad = rawDataManager?.GetMethod( + "DBLoad", + BindingFlags.Instance | BindingFlags.Public); + if (dbLoad == null) + { + throw new MissingMethodException("RawDataManager.DBLoad was not found"); + } + + harmony.Patch( + dbLoad, + prefix: new HarmonyMethod(typeof(Plugin), nameof(DBLoadPrefix))); + + Type clientLocalInfo = FindType("Proto.Local.ClientLocalInfo"); + MethodInfo loadDB = clientLocalInfo?.GetMethod( + "LoadDB", + BindingFlags.Static | BindingFlags.Public); + if (loadDB == null) + { + throw new MissingMethodException("ClientLocalInfo.LoadDB was not found"); + } + + harmony.Patch( + loadDB, + prefix: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadPrefix)), + postfix: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadPostfix)), + finalizer: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadFinalizer))); + + Log?.LogInfo("Database diagnostics active"); + } + + private static void DBLoadPrefix(object __0, ref Action __1) + { + string dbName = ReadFirstStringField(__0) ?? ""; + Action original = __1; + Stopwatch elapsed = Stopwatch.StartNew(); + Log?.LogInfo("DBLoad start: " + dbName); + __1 = delegate + { + Log?.LogInfo("DBLoad callback enter: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)"); + try + { + original?.Invoke(); + } + finally + { + Log?.LogInfo("DBLoad callback exit: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)"); + } + }; + } + + private static void ClientLocalLoadPrefix(out Stopwatch __state) + { + __state = Stopwatch.StartNew(); + Log?.LogInfo("ClientLocalInfo.LoadDB enter"); + } + + private static void ClientLocalLoadPostfix(Stopwatch __state) + { + Log?.LogInfo("ClientLocalInfo.LoadDB exit (" + __state.ElapsedMilliseconds + " ms)"); + } + + private static Exception ClientLocalLoadFinalizer(Exception __exception, Stopwatch __state) + { + if (__exception != null) + { + Log?.LogError( + "ClientLocalInfo.LoadDB threw after " + __state.ElapsedMilliseconds + " ms: " + __exception); + } + return __exception; + } + + private static string ReadFirstStringField(object value) + { + if (value == null) + { + return null; + } + FieldInfo field = value.GetType() + .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .FirstOrDefault(candidate => candidate.FieldType == typeof(string)); + return field?.GetValue(value) as string; + } + + private static Type FindType(string name) + { + Assembly assembly = Assembly.Load("Assembly-CSharp"); + Type type = assembly?.GetType(name); + if (type != null) + { + return type; + } + foreach (Assembly loaded in AppDomain.CurrentDomain.GetAssemblies()) + { + type = loaded.GetType(name); + if (type != null) + { + return type; + } + } + return null; + } +} diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..05c0882 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,23 @@ +# 客户端插件工程 + +- `LocalIdentity/`:连接本地服务器的客户端使用;构建产物为 `BD2LocalIdentity.dll`。 +- `CaptureEnvironment/`:独立原版对照客户端抓包使用;构建产物为 `BD2CaptureEnvironment.dll`。 + +两个插件用于不同客户端环境。原版抓包环境不得安装 `BD2LocalIdentity.dll`。 + +构建时必须显式传入对应客户端目录: + +```powershell +dotnet build .\plugins\LocalIdentity\LocalIdentity.csproj -c Release -p:GameDir="<本地服客户端目录>" +dotnet build .\plugins\CaptureEnvironment\CaptureEnvironment.csproj -c Release -p:GameDir="<原版客户端目录>" +``` + +启动原版抓包环境: + +```powershell +.\tools\python\start-official-capture.ps1 ` + -GameDir "<原版客户端目录>" ` + -LocalClientDir "<本地服客户端目录>" +``` + +用户数据共享目录默认从当前 Windows 用户目录推导;特殊环境可另外传入 `-SharedDataDir`。 diff --git a/proto/bd2/state/control/v1/control.proto b/proto/bd2/state/control/v1/control.proto new file mode 100644 index 0000000..e09ff1d --- /dev/null +++ b/proto/bd2/state/control/v1/control.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package bd2.state.control.v1; +option go_package = "bd2server/gen/state/control/v1;controlv1"; + +import "bd2/state/v1/state.proto"; + +message CheckRequest { + uint32 bridge_api_version = 1; + bytes source_sha256 = 2; + bd2.state.v1.Snapshot snapshot = 10; +} + +enum Severity { + SEVERITY_UNSPECIFIED = 0; + SEVERITY_WARNING = 1; + SEVERITY_ERROR = 2; +} + +message Violation { + string code = 1; + Severity severity = 2; + repeated string path = 3; + string message = 4; + repeated uint64 related_ids = 5; +} + +message CheckResponse { + uint32 bridge_api_version = 1; + bytes source_sha256 = 2; + repeated Violation violations = 10; +} diff --git a/proto/bd2/state/control/v2/control.proto b/proto/bd2/state/control/v2/control.proto new file mode 100644 index 0000000..99d9bc3 --- /dev/null +++ b/proto/bd2/state/control/v2/control.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; + +package bd2.state.control.v2; +option go_package = "bd2server/gen/state/control/v2;controlv2"; + +import "bd2/state/control/v1/control.proto"; +import "bd2/state/v1/state.proto"; +import "bd2/state/v2/state.proto"; + +message MigrateV1ToV2Request { + uint32 bridge_api_version = 1; + bytes source_sha256 = 2; + bd2.state.v1.Snapshot source = 10; +} + +message MigrateV1ToV2Response { + uint32 bridge_api_version = 1; + bytes source_sha256 = 2; + repeated bd2.state.control.v1.Violation violations = 3; + bool migrated = 10; + bd2.state.v2.Snapshot target = 11; +} + +message GachaGroupFact { + uint64 gacha_id = 1; + uint64 group_id = 2; + uint64 fixed_id = 3; + uint64 point_count = 4; +} + +message CostumeFact { + uint64 costume_id = 1; + uint64 grade = 2; + uint64 max_level = 3; + uint64 overflow_item_type = 4; + uint64 overflow_item_id = 5; + uint64 overflow_item_count = 6; +} + +message BaseCostumeFact { + uint64 inventory_index = 1; + uint64 costume_id = 2; + uint64 level = 3; +} + +message StepUpFact { + uint64 group_id = 1; + repeated uint64 ordered_gacha_ids = 2; +} + +message ValidationContext { + string game_data_version = 1; + repeated GachaGroupFact gacha_groups = 2; + repeated CostumeFact costumes = 3; + repeated BaseCostumeFact base_costumes = 4; + repeated StepUpFact step_ups = 5; +} + +message RepairRequest { + uint32 bridge_api_version = 1; + bytes source_sha256 = 2; + bd2.state.v1.Snapshot source = 10; + ValidationContext context = 11; +} + +message RepairResponse { + uint32 bridge_api_version = 1; + bytes source_sha256 = 2; + repeated bd2.state.control.v1.Violation violations = 3; + bool repaired = 10; + bool changed = 11; + bd2.state.v2.Snapshot target = 12; +} diff --git a/proto/bd2/state/v1/state.proto b/proto/bd2/state/v1/state.proto new file mode 100644 index 0000000..c4a63c1 --- /dev/null +++ b/proto/bd2/state/v1/state.proto @@ -0,0 +1,304 @@ +syntax = "proto3"; + +package bd2.state.v1; +option go_package = "bd2server/gen/state/v1;statev1"; + +// Frozen bridge DTO for the nine existing JSON persistence files. Version 1 +// describes today's save; changes require state/v2, never editing old tags. +message Snapshot { + uint32 format_version = 1; + string client_version = 2; + string game_data_version = 3; + + Progress progress = 10; + Deck deck = 11; + Inventory inventory = 12; + EquipmentInventory equipment = 13; + CharacterState characters = 14; + Collection collection = 15; + Wallet wallet = 16; + MailState mail = 17; + MissionState missions = 18; +} + +message QuestKey { + uint32 pack_id = 1; + uint32 quest_id = 2; +} + +message Vector3 { + double x = 1; + double y = 2; + double z = 3; +} + +message Position { + uint32 pack_id = 1; + uint32 map_id = 2; + Vector3 player = 3; + // Client JSON must be echoed without accidental normalization. + bytes original_json = 10; + bytes colleague_positions_json = 11; +} + +message QuestProgress { + QuestKey key = 1; + repeated uint32 values = 2; +} + +message Progress { + uint32 source_format_version = 1; + Position position = 2; + repeated uint32 cleared_tutorial_ids = 3; + repeated QuestProgress quests = 4; + repeated QuestKey cleared_quests = 5; +} + +message DeckSlot { + uint64 character_index = 1; + uint64 costume_index = 2; + uint64 slot = 3; +} + +message Pair { + uint64 key = 1; + uint64 value = 2; +} + +message Deck { + string client_version = 1; + repeated DeckSlot battle = 2; + repeated DeckSlot field = 3; + uint64 field_control_type = 4; + repeated Pair waypoints = 5; + repeated Pair selected_costumes = 6; + repeated Pair bought_packs = 7; + uint64 highest_battle_power = 8; + uint64 portrait_costume_id = 9; + uint64 auto_revive_catalyst = 10; +} + +message Pictorial { + uint64 id = 1; + uint64 group_id = 2; +} + +message Item { + uint64 inventory_index = 1; + uint64 id = 2; + uint64 type = 3; + uint64 count = 4; + uint64 keep_flag = 5; + uint64 time_value = 6; + Pictorial pictorial = 7; + uint64 sort_id = 8; + uint64 use_count = 9; +} + +message IndexedGrant { + string identity = 1; + repeated uint64 inventory_indices = 2; +} + +message Inventory { + string client_version = 1; + uint64 next_index = 2; + repeated Item items = 3; + repeated string granted_identities = 4; + repeated IndexedGrant grant_items = 5; +} + +message EquipmentOption { + uint64 group_id = 1; + uint64 id = 2; +} + +message Equipment { + uint64 inventory_index = 1; + uint64 id = 2; + uint64 level = 3; + uint64 use_char = 4; + uint64 keep_flag = 5; + uint64 lock_flag = 6; + uint64 sort_id = 7; + repeated EquipmentOption main_options = 8; + repeated EquipmentOption sub_options = 9; + EquipmentOption private_option = 10; + repeated uint64 ranks = 11; +} + +message NamedIndex { + string identity = 1; + uint64 inventory_index = 2; +} + +message EquipmentInventory { + string client_version = 1; + uint64 next_index = 2; + repeated Equipment equipment = 3; + repeated NamedIndex grants = 4; +} + +message Character { + uint64 inventory_index = 1; + uint64 id = 2; + uint64 hp = 3; + uint64 level = 4; + uint64 costume_id = 5; + uint64 exp = 6; + uint64 use_costume = 7; + uint64 talent_level = 8; + uint64 talent_exp = 9; + uint64 solidarity_reward = 10; + uint64 expiry_time = 11; + uint64 connect_potential_costume = 12; + repeated Pictorial pictorial = 13; +} + +message Costume { + uint64 inventory_index = 1; + uint64 id = 2; + uint64 level = 3; + uint64 use_char = 4; + uint64 sort_id = 5; + uint64 potential_id = 6; + uint64 design_id = 7; + uint64 burst_level = 8; + uint64 time_value = 9; + repeated Pictorial pictorial = 10; +} + +message CharacterState { + string client_version = 1; + repeated Character characters = 2; +} + +message NamedCount { + string identity = 1; + uint64 value = 2; +} + +message GachaSelection { + uint64 group_id = 1; + uint64 slot = 2; + uint64 item_id = 3; +} + +message NamedSelections { + string identity = 1; + repeated GachaSelection selections = 2; +} + +message GachaUser { + uint64 group_id = 1; + uint64 point = 2; + uint64 total_buy_count = 3; + uint64 one_free_pick_count = 4; + uint64 one_cash_pick_count = 5; + uint64 ten_free_pick_count = 6; + uint64 ten_cash_pick_count = 7; + uint64 exchange_item_count = 8; + uint64 exchange_mileage_count = 9; +} + +message NamedGachaUser { + string identity = 1; + GachaUser user = 2; +} + +message GachaFixed { + uint64 fixed_id = 1; + uint64 type = 2; + uint64 count = 3; + int32 apply_sort_id = 4; +} + +message NamedGachaFixed { + string identity = 1; + GachaFixed fixed = 2; +} + +message GachaPointExchange { + uint64 group_id = 1; + uint64 count = 2; +} + +message NamedGachaPointExchange { + string identity = 1; + GachaPointExchange exchange = 2; +} + +message CostumeUpgrade { + uint64 inventory_index = 1; + uint64 costume_id = 2; + uint64 before = 3; + uint64 after = 4; + uint64 sort_id = 5; +} + +message CostumeExchange { + uint64 inventory_index = 1; + uint64 original_item_type = 2; + uint64 original_item_id = 3; + uint64 original_count = 4; + uint64 exchange_item_type = 5; + uint64 exchange_item_id = 6; + uint64 exchange_count = 7; + uint64 sort_id = 8; +} + +message CollectionGrant { + string identity = 1; + repeated uint64 character_indices = 2; + repeated uint64 costume_indices = 3; + repeated CostumeUpgrade upgrades = 4; + repeated CostumeExchange exchanges = 5; + repeated uint64 view_costume_ids = 6; + uint64 gacha_group_id = 7; + uint64 gacha_point = 8; + repeated GachaFixed gacha_fixed = 9; + repeated uint64 selection_apply_sort_ids = 10; +} + +message Collection { + string client_version = 1; + uint64 next_character_index = 2; + uint64 next_costume_index = 3; + repeated uint64 latest_preview = 4; + uint64 preview_event_index = 5; + bool preview_locked = 6; + repeated Character characters = 7; + repeated Costume costumes = 8; + repeated NamedCount base_costume_levels = 9; + repeated NamedSelections gacha_selections = 10; + repeated NamedCount step_up_progress = 11; + repeated NamedGachaUser gacha_users = 12; + repeated NamedGachaFixed gacha_fixed = 13; + repeated string gacha_applied = 14; + repeated NamedGachaPointExchange gacha_point_exchanges = 15; + bool gacha_count_corrected = 16; + repeated CollectionGrant grants = 17; +} + +message Wallet { + string client_version = 1; + uint64 gold = 2; + uint64 free_jewelry = 3; + uint64 jewelry = 4; + uint64 mileage = 5; + uint64 hope_powder = 6; + repeated string granted_identities = 7; + repeated string spent_identities = 8; +} + +message MailState { + string client_version = 1; + repeated uint64 opened_mail_ids = 2; +} + +message MissionState { + string client_version = 1; + repeated string completed = 2; + repeated string claimed = 3; + repeated NamedCount progress = 4; +} diff --git a/proto/bd2/state/v2/state.proto b/proto/bd2/state/v2/state.proto new file mode 100644 index 0000000..73a3f80 --- /dev/null +++ b/proto/bd2/state/v2/state.proto @@ -0,0 +1,63 @@ +syntax = "proto3"; + +package bd2.state.v2; +option go_package = "bd2server/gen/state/v2;statev2"; + +import "bd2/state/v1/state.proto"; + +// Version 2 is an incremental semantic schema. Unchanged frozen V1 value +// messages are reused deliberately; the ownership layout below is V2-owned. +message Snapshot { + uint32 format_version = 1; + string client_version = 2; + string game_data_version = 3; + + bd2.state.v1.Progress progress = 10; + bd2.state.v1.Deck deck = 11; + bd2.state.v1.Inventory inventory = 12; + bd2.state.v1.EquipmentInventory equipment = 13; + CharacterRoster roster = 14; + CollectionLedger collection = 15; + bd2.state.v1.Wallet wallet = 16; + bd2.state.v1.MailState mail = 17; + bd2.state.v1.MissionState missions = 18; +} + +enum CharacterOrigin { + CHARACTER_ORIGIN_UNSPECIFIED = 0; + // Character supplied by story/bootstrap state (V1 CharacterState). + CHARACTER_ORIGIN_BASE = 1; + // Durable account-owned character (V1 Collection.characters). + CHARACTER_ORIGIN_ACQUIRED = 2; +} + +message CharacterRecord { + bd2.state.v1.Character character = 1; + CharacterOrigin origin = 2; +} + +message CharacterRoster { + // These allocators apply only to acquired characters and costumes. Base + // character identities are allocated by the bootstrap source. + uint64 next_acquired_character_index = 1; + uint64 next_costume_index = 2; + repeated CharacterRecord characters = 3; + repeated bd2.state.v1.Costume costumes = 4; +} + +// Collection no longer owns character entities. It contains only preview, +// gacha accounting and idempotent grant history. +message CollectionLedger { + repeated uint64 latest_preview = 1; + uint64 preview_event_index = 2; + bool preview_locked = 3; + repeated bd2.state.v1.NamedCount base_costume_levels = 4; + repeated bd2.state.v1.NamedSelections gacha_selections = 5; + repeated bd2.state.v1.NamedCount step_up_progress = 6; + repeated bd2.state.v1.NamedGachaUser gacha_users = 7; + repeated bd2.state.v1.NamedGachaFixed gacha_fixed = 8; + repeated string gacha_applied = 9; + repeated bd2.state.v1.NamedGachaPointExchange gacha_point_exchanges = 10; + bool gacha_count_corrected = 11; + repeated bd2.state.v1.CollectionGrant grants = 12; +} diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 0000000..b4d4e47 --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1 @@ +version: v2 diff --git a/tools/python/README.md b/tools/python/README.md new file mode 100644 index 0000000..144190c --- /dev/null +++ b/tools/python/README.md @@ -0,0 +1,103 @@ +# 离线开发工具 + +这里的程序用于查表、转换种子和维护测试存档。它们不属于游戏服务端运行时,不会进入 `bd2server.exe`。 + +Python 版本为 3.11 以上;GameData 分页 AES 解密需要 `pycryptodome`: + +```powershell +python -m pip install pycryptodome +``` + +## 查询 GameData + +无需运行 `go test` 或构建服务端: + +```powershell +# 列出任务库中的表 +python .\tools\python\gamedata_db.py tables --root "" --version "<版本>" --db quest --match QuestTable + +# 查看表结构 +python .\tools\python\gamedata_db.py schema --root "" --version "<版本>" --db quest --table QuestTable22 + +# 展开一行 protobuf 的指定字段 +python .\tools\python\gamedata_db.py row --root "" --version "<版本>" --db quest --table PackTable --id 21 --fields 45 + +# 查看任务真实奖励(五个难度槽分别显示) +python .\tools\python\gamedata_db.py rewards --root "" --version "<版本>" --pack 21 --quest 38 + +# 沿真实 PackTable.NextPackId 查看故事包链 +python .\tools\python\gamedata_db.py chain --root "" --version "<版本>" --start-pack 21 + +# 任意只读 SQL;参数用重复的 --param 传入 +python .\tools\python\gamedata_db.py sql --root "" --version "<版本>" --db pack21 ` + --query "SELECT id,ProtoBuf FROM BattleDeckTable WHERE id=?" --param 1 + +# 必要时导出普通 SQLite,默认拒绝覆盖 +python .\tools\python\gamedata_db.py extract --root "" --version "<版本>" --db quest --output .\tmp\quest.db +``` + +GameData 根目录和版本没有机器相关默认值,所有查询都必须显式传入 `--root` 和 `--version`。 + +## 种子转换 + +输入必须是已经解密、解包后的原始 protobuf,不接受 HTTP 信封或 AES 密文: + +```powershell +python .\tools\python\import_seed.py login .\decoded\LoginUser.pb ` + --packet-code 11 --output .\go\seed\v2_34_13\login_user.json + +python .\tools\python\import_seed.py starter ` + --items .\decoded\ItemInfo.pb ` + --costumes .\decoded\CostumeInfo.pb ` + --characters .\decoded\CharInfo.pb ` + --output .\go\seed\v2_34_13\starter_player.json + +python .\tools\python\import_seed.py mail .\decoded\MailInfo.pb ` + --output .\go\seed\v2_34_13\mail.json +``` + +输出已存在时默认拒绝覆盖;审阅输入和预期差异后才传 `--force`。 + +`readonly` 子命令接收一个 JSON 清单,键是接口路径,值含 `packet_code` 和相对于清单的 protobuf 文件路径: + +```json +{ + "/SkyWayScheduleInfo": { + "packet_code": 162, + "protobuf": "SkyWayScheduleInfo.pb" + } +} +``` + +## 存档检查点 + +检查点包含九份账号状态和 SHA-256 清单: + +```powershell +python .\tools\python\save_checkpoint.py create --label before-test +python .\tools\python\save_checkpoint.py verify .\data\state\checkpoints\某检查点 + +# 第一次只演练;不会写入 +python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检查点 + +# 确认客户端和服务端均停止后才真正恢复 +python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检查点 --apply +``` + +## 任务 38 定向回档 + +该工具只撤销 pack21 quest38 的进度、四项物品和 1500 金币,保留其他账号资产;兼容旧存档和 v2 `pack:quest` 键: + +```powershell +# 只读预览 +python .\tools\python\rollback_quest38.py .\data\state .\data\state\checkpoints\参考检查点 + +# 停止客户端和服务端后应用;工具会先备份九份状态并校验哈希 +python .\tools\python\rollback_quest38.py .\data\state .\data\state\checkpoints\参考检查点 --apply +``` + +## 工具测试 + +```powershell +python -m unittest discover -s .\tools\python\tests -p 'test_*.py' -v +``` diff --git a/tools/python/bd2_dump.py b/tools/python/bd2_dump.py new file mode 100644 index 0000000..1d89df9 --- /dev/null +++ b/tools/python/bd2_dump.py @@ -0,0 +1,548 @@ +#!/usr/bin/env python3 +"""bd2_dump.py — mitmproxy 抓包插件:**全抓**,能解的就解开。 + +## 启动 + + mitmdump -s bd2_dump.py --listen-port 8080 --set stream_large_bodies=1m + +`stream_large_bodies=1m` **必须加** —— 资源包单个最大 270MB,不流式的话 +mitmproxy 会把它们全缓冲进内存。加了之后超过 1MB 的响应不缓冲, +`raw_content` 为 None,脚本只记大小。 + +让客户端走代理的三种办法: + + a) 系统代理 → 127.0.0.1:8080(最省事,但别的程序也会走) + b) hosts 把目标域名指到本机 + 监听 443 + c) 只给游戏进程设 HTTP_PROXY 环境变量 + +装根证书(HTTPS 才解得开): + + Windows: certutil -user -addstore Root %USERPROFILE%\\.mitmproxy\\mitmproxy-ca-cert.cer + Kali: cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/ + update-ca-certificates + +## 抓什么 + +**所有经过代理的流量都记**,一条不落。每条带一个 `game` 标记: + +- `game=true` —— 命中 BD2 相关域名,或 UA 是 Unity/游戏的 +- `game=false` —— 其它(浏览器、系统更新……) + +`game=true` 的会在控制台实时打印;**全部**写进 jsonl。 + +## 解开什么 + +我们在服务端逆向时摸清的**三层编码**: + +1. **外层**:JSON 信封 `{"errorType":0,"data":"","length":N,...}` +2. **AES 层**:`base64(AES-256-CBC(PKCS7(明文)))`,IV = 16 个 0 字节 +3. **内层**:明文本身又是 `base64(protobuf)` —— **两层 base64**, + 少一层客户端就报「不是合法的 Base-64 字符串」 + +批量另有花样:`PUT /BatchRequest` 的 body 是 `base64(AES(JSON数组))`, +**只有一层 base64**(数组里每个 `requestData` 才各自 base64)。 + +## 密钥 + +- `LoginUser` / `JoinUser` 固定 `abcdefghijkrstuv024680wxyzlmnopq` +- 之后用登录响应里下发的 `user_key`(32 位十六进制),脚本会自动认出来并接管 + +## 输出 + +- 控制台:`game=true` 的实时打印 +- `capture/2.34.13/<时间戳>/bd2_dump.jsonl`:全部流量,每行一条 +- `capture/2.34.13/<时间戳>/bd2_dump.log`:便于人工检查的文本日志 +- `capture/2.34.13/<时间戳>/bodies/`:`game=true` 的原始 body 原样存一份 + (服务端实现要按 schema 解,原字节比文本更值钱) + +可用环境变量 `BD2_CAPTURE_DIR` 覆盖输出根目录。 + +只观察,不修改任何流量。 +""" + +import base64 +import json +import os +import re +import time + +from mitmproxy import http, ctx + +# ---------------------------------------------------------------- 加密 + +FIX_KEY = b"abcdefghijkrstuv024680wxyzlmnopq" +ZERO_IV = b"\x00" * 16 + +SESS = None +TAG = time.strftime("%Y%m%d-%H%M%S") +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_DIR = os.path.dirname(os.path.dirname(SCRIPT_DIR)) +CAPTURE_ROOT = os.path.abspath( + os.environ.get("BD2_CAPTURE_DIR", os.path.join(PROJECT_DIR, "data", "capture", "2.34.13")) +) +RUN_DIR = os.path.join(CAPTURE_ROOT, TAG) +LOG_PATH = os.path.join(RUN_DIR, "bd2_dump.jsonl") +TEXT_PATH = os.path.join(RUN_DIR, "bd2_dump.log") +BODY_DIR = os.path.join(RUN_DIR, "bodies") + +_AES = None +_AES_IMPL = "" + + +def _load_aes(): + global _AES, _AES_IMPL + if _AES is not None: + return _AES + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + def dec(key, data): + c = Cipher(algorithms.AES(key), modes.CBC(ZERO_IV)).decryptor() + return c.update(data) + c.finalize() + + _AES, _AES_IMPL = dec, "cryptography" + return _AES + except Exception: + pass + try: + from Crypto.Cipher import AES as _C + + def dec(key, data): + return _C.new(key, _C.MODE_CBC, ZERO_IV).decrypt(data) + + _AES, _AES_IMPL = dec, "pycryptodome" + return _AES + except Exception: + _AES_IMPL = "" + return None + + +def strip_pkcs7(b: bytes) -> bytes: + if not b: + return b + n = b[-1] + if n == 0 or n > 16 or n > len(b): + return b + if b[-n:] != bytes([n]) * n: + return b + return b[:-n] + + +def aes_unwrap(s: str, key: bytes): + """base64 -> AES -> 去 PKCS7。解不开返回 None。""" + dec = _load_aes() + if dec is None or not s: + return None + try: + raw = base64.b64decode(s, validate=False) + except Exception: + return None + if not raw or len(raw) % 16: + return None + try: + return strip_pkcs7(dec(key, raw)) + except Exception: + return None + + +# ---------------------------------------------------------------- protobuf 转储 + +PRINTABLE = re.compile(rb"^[\x20-\x7e\n\r\t]*$") + + +def pb_dump(b: bytes, indent: int = 0, depth: int = 0, limit: int = 60): + """没有 schema 也能看:按 varint 拆字段号 + 值,嵌套递归一层层往下。""" + out = [] + i, n = 0, len(b) + pad = " " * indent + while i < n and depth < 6 and len(out) < limit: + tag = shift = 0 + while i < n: + c = b[i] + i += 1 + tag |= (c & 0x7F) << shift + shift += 7 + if not c & 0x80: + break + else: + break + fn, wt = tag >> 3, tag & 7 + if fn == 0: + break + if wt == 0: + v = shift = 0 + while i < n: + c = b[i] + i += 1 + v |= (c & 0x7F) << shift + shift += 7 + if not c & 0x80: + break + out.append("%s#%d varint %d" % (pad, fn, v)) + elif wt == 2: + ln = shift = 0 + while i < n: + c = b[i] + i += 1 + ln |= (c & 0x7F) << shift + shift += 7 + if not c & 0x80: + break + if i + ln > n: + break + chunk = b[i : i + ln] + i += ln + if chunk and PRINTABLE.match(chunk): + try: + out.append("%s#%d str %r" % (pad, fn, chunk.decode("utf-8"))) + except Exception: + out.append("%s#%d bytes(%d)" % (pad, fn, ln)) + else: + sub = pb_dump(chunk, indent + 1, depth + 1, limit - len(out)) + if sub: + out.append("%s#%d 嵌套 {" % (pad, fn)) + out.extend(sub) + out.append("%s}" % pad) + else: + out.append("%s#%d bytes(%d)" % (pad, fn, ln)) + elif wt == 5: + i += 4 + out.append("%s#%d fixed32" % (pad, fn)) + elif wt == 1: + i += 8 + out.append("%s#%d fixed64" % (pad, fn)) + else: + break + if len(out) >= limit: + out.append("%s…(截断)" % pad) + return out + + +# ---------------------------------------------------------------- 输出 + +_fh = None +_tf = None +_body_seq = [0] + + +def _ensure_fh(): + global _fh, _tf + os.makedirs(RUN_DIR, exist_ok=True) + if _fh is None: + _fh = open(LOG_PATH, "a", encoding="utf-8") + if _tf is None: + _tf = open(TEXT_PATH, "a", encoding="utf-8") + return _fh + + +def render_text(rec: dict) -> str: + """把一条记录渲染成人能读的多行文本。proto 字段缩进展开,不塞成一行。""" + L = [] + ts = rec.get("ts", "") + ev = rec.get("ev", "?") + + if ev == "session": + return "\n[%s] ★ 会话密钥 user_key=%s\n" % (ts, rec.get("user_key")) + if ev == "set_cookie": + return "[%s] Set-Cookie: %s\n" % (ts, rec.get("value")) + + flag = "" if rec.get("game") else " (非游戏)" + if ev == "req": + L.append("\n%s[%s] >>> %s %s%s" % ("", ts, rec.get("method"), rec.get("path"), flag)) + if rec.get("shape"): + L.append(" 形状=%s raw=%dB" % (rec["shape"], rec.get("len", 0))) + if rec.get("cookie"): + L.append(" cookie=%s" % rec["cookie"]) + if rec.get("raw_file"): + L.append(" 原始=%s" % rec["raw_file"]) + if rec.get("batch_count"): + L.append(" 批量 %d 条:" % rec["batch_count"]) + for e in rec.get("batch", []): + L.append(" · %-32s req=%dB" % (e.get("path"), e.get("req_len", 0))) + for line in e.get("pb", []): + L.append(" " + line) + for line in rec.get("pb", []): + L.append(" " + line) + else: + L.append("\n[%s] <<< %s HTTP %s%s" % (ts, rec.get("path"), rec.get("status"), flag)) + if rec.get("errorType") is not None: + L.append(" errorType=%s length=%s %s" % ( + rec.get("errorType"), rec.get("length"), + ("msg=" + rec["errorMessage"]) if rec.get("errorMessage") else "")) + if rec.get("raw_file"): + L.append(" 原始=%s" % rec["raw_file"]) + for line in rec.get("pb", []): + L.append(" " + line) + return "\n".join(L) + "\n" + + +def save_body(kind: str, path: str, raw: bytes) -> str: + """原字节存一份 —— 后面要按 schema 解,字符比文本值钱。""" + if not raw: + return "" + os.makedirs(BODY_DIR, exist_ok=True) + _body_seq[0] += 1 + safe = re.sub(r"[^A-Za-z0-9_.-]", "_", path.strip("/"))[:60] or "root" + fn = os.path.join(BODY_DIR, "%05d_%s_%s.bin" % (_body_seq[0], kind, safe)) + with open(fn, "wb") as f: + f.write(raw) + return os.path.relpath(fn, RUN_DIR) + + +def header_pairs(headers) -> list[list[str]]: + """保留重复头部(尤其 Set-Cookie),便于后续按真实响应回放。""" + try: + return [[str(k), str(v)] for k, v in headers.items(multi=True)] + except TypeError: + return [[str(k), str(v)] for k, v in headers.items()] + + +def emit(rec: dict, console: bool): + rec["ts"] = time.strftime("%H:%M:%S") + _ensure_fh() + + # JSONL:给脚本用的,字段完整 + _fh.write(json.dumps(rec, ensure_ascii=False) + "\n") + _fh.flush() + + # 文本日志:给人看的,proto 逐字段展开 + _tf.write(render_text(rec)) + _tf.flush() + + if console: + # 控制台只打一行摘要,细节都在文本日志里 —— 否则 proto dump 会把屏幕刷爆 + if rec.get("ev") == "req": + head = ">>> %s %s [%s]" % (rec.get("method"), rec.get("path"), rec.get("shape", "")) + if rec.get("batch_count"): + head += " 批量%d条" % rec["batch_count"] + elif rec.get("ev") == "resp": + head = "<<< %s HTTP %s errorType=%s" % ( + rec.get("path"), rec.get("status"), rec.get("errorType")) + else: + head = json.dumps(rec, ensure_ascii=False) + ctx.log.info(head) + + +# ---------------------------------------------------------------- 会话 + +USER_KEY = [None] + + +def sniff_user_key(plain: bytes): + if USER_KEY[0] or not plain: + return + m = re.search(rb"[0-9a-f]{32}", plain) + if m: + USER_KEY[0] = m.group(0).decode() + emit({"ev": "session", "user_key": USER_KEY[0], + "note": "从登录响应认出 user_key,后续包用它解密"}, True) + + +def cur_key() -> bytes: + return USER_KEY[0].encode() if USER_KEY[0] else FIX_KEY + + +# ---------------------------------------------------------------- 判定 + +GAME_HOSTS = ( + "bd2.pmang.cloud", + "akamaized.net", + "neonapi.com", + "neowizplay.com", + "pmangplus", + "neon-file", + "browndust", + "gamfs", +) + +# 资源包域名:只记元信息,不存 body +RESOURCE_HOSTS = ("dl.bd2.pmang.cloud", "cdn.bd2.pmang.cloud", "bd2-cdn.akamaized.net") + + +def is_game(host: str, ua: str) -> bool: + h, u = host.lower(), (ua or "").lower() + if any(g in h for g in GAME_HOSTS): + return True + return "unity" in u + + +def is_resource(host: str) -> bool: + return any(r in host.lower() for r in RESOURCE_HOSTS) + + +# ---------------------------------------------------------------- 解码 + +BATCH_KEYS = ("path", "requestData") + + +def looks_like_batch(b: bytes) -> bool: + try: + arr = json.loads(b) + except Exception: + return False + return ( + isinstance(arr, list) + and bool(arr) + and isinstance(arr[0], dict) + and all(k in arr[0] for k in BATCH_KEYS) + ) + + +def decode_request(body: bytes): + if not body: + return "空", b"", None + if looks_like_batch(body): + try: + return "批量(明文JSON)", body, json.loads(body) + except Exception: + return "批量(明文JSON)", body, None + + txt = body.decode("ascii", "ignore").strip() + plain = aes_unwrap(txt, cur_key()) + if plain is not None: + if looks_like_batch(plain): + try: + return "批量(加密JSON)", plain, json.loads(plain) + except Exception: + return "批量(加密JSON)", plain, None + try: + return "加密包", base64.b64decode(plain, validate=False), None + except Exception: + return "加密包(内层非base64)", plain, None + + try: + return "明文包", base64.b64decode(body, validate=False), None + except Exception: + return "未识别", body, None + + +def decode_response(body: bytes): + try: + env = json.loads(body) + except Exception: + return None, None + if not isinstance(env, dict) or "errorType" not in env: + return None, None + data = env.get("data") or "" + if not data: + return env, b"" + plain = aes_unwrap(data, cur_key()) + if plain is not None: + try: + return env, base64.b64decode(plain, validate=False) + except Exception: + return env, plain + try: + return env, base64.b64decode(data, validate=False) + except Exception: + return env, b"" + + +# ---------------------------------------------------------------- 钩子 + + +def running(): + _load_aes() + _ensure_fh() + ctx.log.info("━" * 60) + ctx.log.info("bd2_dump 已加载 AES=%s" % (_AES_IMPL or "不可用(只dump不解)")) + ctx.log.info("文本日志(看这个): %s" % TEXT_PATH) + ctx.log.info("JSONL(喂脚本): %s" % LOG_PATH) + ctx.log.info("原始 body: %s/" % BODY_DIR) + ctx.log.info("━" * 60) + + +def request(flow: http.HTTPFlow): + host = flow.request.pretty_host + ua = flow.request.headers.get("user-agent", "") + game = is_game(host, ua) + resource = is_resource(host) + + if resource: + emit({"ev": "req", "flow_id": flow.id, + "game": True, "resource": True, + "method": flow.request.method, "host": host, + "path": flow.request.path, "url": flow.request.pretty_url, + "headers": header_pairs(flow.request.headers), + "len": len(flow.request.raw_content or b"")}, False) + return + + body = flow.request.raw_content or b"" + rec = {"ev": "req", "flow_id": flow.id, + "game": game, "method": flow.request.method, + "host": host, "path": flow.request.path, + "url": flow.request.pretty_url, + "headers": header_pairs(flow.request.headers), "len": len(body)} + if not game: + emit(rec, False) + return + + rec["ua"] = ua[:80] + ck = flow.request.headers.get("cookie") + if ck: + rec["cookie"] = ck + + shape, plain, batch = decode_request(body) + rec["shape"] = shape + rec["raw_file"] = save_body("req", flow.request.path, body) + + if batch: + rec["batch_count"] = len(batch) + rec["batch"] = [ + {"path": e.get("path"), + "req_len": len(base64.b64decode(e.get("requestData") or "") or b""), + "pb": pb_dump(base64.b64decode(e.get("requestData") or "") or b"")} + for e in batch[:100] + ] + elif plain: + rec["pb"] = pb_dump(plain) + + emit(rec, True) + + +def response(flow: http.HTTPFlow): + host = flow.request.pretty_host + resource = is_resource(host) + body = flow.response.raw_content or b"" + + if resource: + emit({"ev": "resp", "flow_id": flow.id, + "game": True, "resource": True, + "method": flow.request.method, "host": host, + "path": flow.request.path, + "url": flow.request.pretty_url, + "status": flow.response.status_code, + "headers": header_pairs(flow.response.headers), + "len": len(body), + "streamed": flow.response.raw_content is None}, False) + return + + ua = flow.request.headers.get("user-agent", "") + game = is_game(host, ua) + rec = {"ev": "resp", "flow_id": flow.id, + "game": game, "method": flow.request.method, + "host": host, "path": flow.request.path, + "url": flow.request.pretty_url, + "status": flow.response.status_code, + "headers": header_pairs(flow.response.headers), "len": len(body)} + if not game: + emit(rec, False) + return + + sc = flow.response.headers.get("set-cookie") + if sc: + emit({"ev": "set_cookie", "value": sc}, True) + + env, plain = decode_response(body) + if env is not None: + rec["errorType"] = env.get("errorType") + rec["length"] = env.get("length") + if env.get("errorMessage"): + rec["errorMessage"] = env["errorMessage"] + rec["raw_file"] = save_body("resp", flow.request.path, body) + if plain: + if re.search(r"LoginUser|JoinUser", flow.request.path): + sniff_user_key(plain) + rec["pb"] = pb_dump(plain) + + emit(rec, True) diff --git a/tools/python/gamedata_db.py b/tools/python/gamedata_db.py new file mode 100644 index 0000000..1ec9ae1 --- /dev/null +++ b/tools/python/gamedata_db.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +"""Read and inspect Brown Dust II 2.34.13 GameData without building Go. + +The tool opens common-dbdata.bin as a ZIP, extracts one encrypted SQLite +member, decrypts each 4096-byte AES-CBC page independently, and exposes only +read-only SQLite operations. It is intentionally outside the server runtime. + +Examples: + py tools/gamedata_db.py tables --db quest --match QuestTable + py tools/gamedata_db.py schema --db quest --table QuestTable22 + py tools/gamedata_db.py row --db quest --table PackTable --id 21 --fields 45 + py tools/gamedata_db.py rewards --pack 21 --quest 38 + py tools/gamedata_db.py chain --start-pack 21 + py tools/gamedata_db.py sql --db pack21 --query "SELECT id FROM BattleDeckTable" +""" + +from __future__ import annotations + +import argparse +import contextlib +import hashlib +import json +import os +from pathlib import Path +import sqlite3 +import struct +import sys +import tempfile +import zipfile + +try: + from Crypto.Cipher import AES +except ImportError as exc: # pragma: no cover - environment diagnostic + raise SystemExit("PyCryptodome is required: py -m pip install pycryptodome") from exc + + +PAGE_SIZE = 4096 +HEADER = b"SQLite format 3\x00" +PASSWORD_SOURCE = b"spdhdnlwmrpavmtm" +ITERATIONS = 2010 +ARCHIVE_NAME = "common-dbdata.bin" +QUEST_ENTRY = "9F251C63BC72551C681EE75D328FA090D56E444B" + + +def derive_key() -> bytes: + password = hashlib.sha1(PASSWORD_SOURCE).hexdigest().upper().encode("ascii") + return hashlib.pbkdf2_hmac("sha1", password, HEADER, ITERATIONS, dklen=32) + + +def decrypt_pages(encrypted: bytes) -> bytes: + if not encrypted or len(encrypted) % PAGE_SIZE: + raise ValueError( + f"encrypted database length {len(encrypted)} is not a non-zero " + f"multiple of {PAGE_SIZE}" + ) + key = derive_key() + plain = bytearray(len(encrypted)) + for start in range(0, len(encrypted), PAGE_SIZE): + cipher = AES.new(key, AES.MODE_CBC, iv=HEADER) + plain[start : start + PAGE_SIZE] = cipher.decrypt( + encrypted[start : start + PAGE_SIZE] + ) + if not plain.startswith(HEADER): + raise ValueError("decrypted member does not have a SQLite header") + return bytes(plain) + + +def database_entry(database: str) -> str: + if database == "quest": + return QUEST_ENTRY + if not database or any(char in database for char in "/\\."): + raise ValueError(f"invalid logical database name: {database!r}") + return hashlib.sha1(f"{database}_v1".encode()).hexdigest().upper() + + +def read_database(root: Path, version: str, database: str) -> bytes: + archive = root / version / "release" / ARCHIVE_NAME + entry_name = database_entry(database) + with zipfile.ZipFile(archive, "r") as bundle: + names = {name.upper(): name for name in bundle.namelist()} + actual = names.get(entry_name.upper()) + if actual is None: + raise FileNotFoundError( + f"database {database!r} member {entry_name} is absent from {archive}" + ) + return decrypt_pages(bundle.read(actual)) + + +@contextlib.contextmanager +def open_database(args: argparse.Namespace): + plain = read_database(Path(args.root), args.version, args.db) + handle = tempfile.NamedTemporaryFile(prefix="bd2-gamedata-", suffix=".db", delete=False) + path = Path(handle.name) + try: + handle.write(plain) + handle.flush() + handle.close() + uri = path.resolve().as_uri() + "?mode=ro" + connection = sqlite3.connect(uri, uri=True) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA query_only=ON") + try: + yield connection + finally: + connection.close() + finally: + try: + handle.close() + except Exception: + pass + path.unlink(missing_ok=True) + + +def read_varint(data: bytes, offset: int) -> tuple[int, int]: + value = 0 + shift = 0 + for index in range(offset, len(data)): + byte = data[index] + value |= (byte & 0x7F) << shift + if byte < 0x80: + return value, index + 1 + shift += 7 + if shift >= 70: + break + raise ValueError(f"invalid varint at offset {offset}") + + +def walk_wire(data: bytes): + offset = 0 + while offset < len(data): + tag, offset = read_varint(data, offset) + number, wire_type = tag >> 3, tag & 7 + if number == 0: + raise ValueError("protobuf field number zero") + if wire_type == 0: + value, offset = read_varint(data, offset) + elif wire_type == 1: + if offset + 8 > len(data): + raise ValueError("truncated fixed64") + value, offset = data[offset : offset + 8], offset + 8 + elif wire_type == 2: + size, offset = read_varint(data, offset) + if offset + size > len(data): + raise ValueError("truncated bytes field") + value, offset = data[offset : offset + size], offset + size + elif wire_type == 5: + if offset + 4 > len(data): + raise ValueError("truncated fixed32") + value, offset = data[offset : offset + 4], offset + 4 + else: + raise ValueError(f"unsupported protobuf wire type {wire_type}") + yield number, wire_type, value + + +def packed_varints(data: bytes) -> list[int] | None: + result: list[int] = [] + offset = 0 + try: + while offset < len(data): + value, offset = read_varint(data, offset) + result.append(value) + except ValueError: + return None + return result + + +def wire_fields(data: bytes, selected: set[int] | None = None) -> dict[int, list[dict]]: + result: dict[int, list[dict]] = {} + for number, wire_type, value in walk_wire(data): + if selected is not None and number not in selected: + continue + rendered: dict[str, object] = {"wire_type": wire_type} + if wire_type == 0: + rendered["varint"] = value + elif wire_type == 1: + rendered["hex"] = value.hex() + rendered["fixed64"] = int.from_bytes(value, "little") + rendered["double"] = struct.unpack(" set[int] | None: + if not text or text.lower() == "all": + return None + result: set[int] = set() + for part in text.split(","): + part = part.strip() + if "-" in part: + left, right = part.split("-", 1) + result.update(range(int(left), int(right) + 1)) + else: + result.add(int(part)) + if not result or min(result) <= 0: + raise ValueError("protobuf fields must be positive integers") + return result + + +def json_value(value): + if isinstance(value, bytes): + return {"length": len(value), "hex": value.hex()} + return value + + +def print_rows(cursor: sqlite3.Cursor) -> None: + rows = cursor.fetchall() + output = [ + {key: json_value(row[key]) for key in row.keys()} + for row in rows + ] + print(json.dumps(output, ensure_ascii=False, indent=2)) + + +def cmd_tables(args: argparse.Namespace) -> None: + with open_database(args) as db: + pattern = f"%{args.match}%" if args.match else "%" + rows = db.execute( + "SELECT name, sql FROM sqlite_master " + "WHERE type='table' AND name LIKE ? ORDER BY name", + (pattern,), + ) + print_rows(rows) + + +def quote_identifier(identifier: str) -> str: + if not identifier or "\x00" in identifier: + raise ValueError("invalid SQLite identifier") + return '"' + identifier.replace('"', '""') + '"' + + +def cmd_schema(args: argparse.Namespace) -> None: + with open_database(args) as db: + table = quote_identifier(args.table) + print_rows(db.execute(f"PRAGMA table_info({table})")) + + +def parse_parameter(value: str): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def cmd_sql(args: argparse.Namespace) -> None: + first = args.query.lstrip().split(None, 1)[0].upper() if args.query.strip() else "" + if first not in {"SELECT", "PRAGMA", "WITH", "EXPLAIN"}: + raise ValueError("only read-only SELECT/PRAGMA/WITH/EXPLAIN queries are allowed") + with open_database(args) as db: + print_rows(db.execute(args.query, tuple(map(parse_parameter, args.param)))) + + +def cmd_row(args: argparse.Namespace) -> None: + fields = parse_field_selection(args.fields) + with open_database(args) as db: + table = quote_identifier(args.table) + column = quote_identifier(args.id_column) + rows = db.execute( + f"SELECT * FROM {table} WHERE {column}=? ORDER BY rowid", (args.id,) + ).fetchall() + output = [] + for row in rows: + rendered = {key: json_value(row[key]) for key in row.keys() if key != args.proto_column} + proto = row[args.proto_column] + if not isinstance(proto, bytes): + raise ValueError(f"{args.table}.{args.proto_column} is not a blob") + rendered[args.proto_column] = wire_fields(proto, fields) + output.append(rendered) + print(json.dumps(output, ensure_ascii=False, indent=2)) + + +def scalar_packed(fields: dict[int, list[dict]], number: int) -> list[int]: + values: list[int] = [] + for occurrence in fields.get(number, []): + if "varint" in occurrence: + values.append(int(occurrence["varint"])) + else: + values.extend(int(v) for v in occurrence.get("packed_varints", [])) + return values + + +def cmd_rewards(args: argparse.Namespace) -> None: + table_name = f"QuestTable{args.pack}" + with open_database(args) as db: + rows = db.execute( + f"SELECT id, ProtoBuf FROM {quote_identifier(table_name)} " + "WHERE (? IS NULL OR id=?) ORDER BY id", + (args.quest, args.quest), + ).fetchall() + output = [] + for row in rows: + fields = wire_fields(row["ProtoBuf"]) + slots = [] + for slot in range(5): + slots.append( + { + "slot": slot, + "types": scalar_packed(fields, 56 + slot), + "ids": scalar_packed(fields, 51 + slot), + "counts": scalar_packed(fields, 46 + slot), + "display_types": scalar_packed(fields, 24 + slot), + "display_ids": scalar_packed(fields, 19 + slot), + "display_counts": scalar_packed(fields, 14 + slot), + } + ) + output.append({"pack": args.pack, "quest": row["id"], "rewards": slots}) + print(json.dumps(output, ensure_ascii=False, indent=2)) + + +def cmd_chain(args: argparse.Namespace) -> None: + with open_database(args) as db: + current = args.start_pack + visited: set[int] = set() + output = [] + while current: + if current in visited: + raise ValueError(f"cyclic PackTable.NextPackId at {current}") + visited.add(current) + row = db.execute("SELECT ProtoBuf FROM PackTable WHERE id=?", (current,)).fetchone() + if row is None: + raise ValueError(f"PackTable {current} does not exist") + fields = wire_fields(row["ProtoBuf"]) + next_values = scalar_packed(fields, 45) + next_pack = next_values[0] if next_values else 0 + table = f"QuestTable{current}" + count, minimum, maximum = db.execute( + f"SELECT COUNT(*), MIN(id), MAX(id) FROM {quote_identifier(table)}" + ).fetchone() + output.append( + { + "pack": current, + "next_pack": next_pack, + "quest_count": count, + "min_quest": minimum, + "max_quest": maximum, + } + ) + current = next_pack + if len(visited) > args.limit: + raise ValueError(f"pack chain exceeds safety limit {args.limit}") + print(json.dumps(output, ensure_ascii=False, indent=2)) + + +def cmd_extract(args: argparse.Namespace) -> None: + target = Path(args.output).resolve() + if target.exists() and not args.force: + raise FileExistsError(f"refusing to overwrite {target}; pass --force") + plain = read_database(Path(args.root), args.version, args.db) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp") + try: + temporary.write_bytes(plain) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + print(f"wrote {target} ({len(plain)} bytes)") + + +def add_database_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--root", required=True, help="GameData root") + parser.add_argument("--version", required=True, help="GameData version") + parser.add_argument( + "--db", + default="quest", + help="quest for the shared DB, or a logical DB name such as pack21", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + command = sub.add_parser("tables", help="list tables and schemas") + add_database_options(command) + command.add_argument("--match", help="substring filter") + command.set_defaults(handler=cmd_tables) + + command = sub.add_parser("schema", help="show PRAGMA table_info") + add_database_options(command) + command.add_argument("--table", required=True) + command.set_defaults(handler=cmd_schema) + + command = sub.add_parser("sql", help="execute one read-only query") + add_database_options(command) + command.add_argument("--query", required=True) + command.add_argument("--param", action="append", default=[], help="JSON or string parameter") + command.set_defaults(handler=cmd_sql) + + command = sub.add_parser("row", help="show a row and decode its ProtoBuf blob") + add_database_options(command) + command.add_argument("--table", required=True) + command.add_argument("--id", required=True, type=int) + command.add_argument("--id-column", default="id") + command.add_argument("--proto-column", default="ProtoBuf") + command.add_argument("--fields", default="all", help="all, 1,3,5-9") + command.set_defaults(handler=cmd_row) + + command = sub.add_parser("rewards", help="decode real QuestTable reward slots") + add_database_options(command) + command.set_defaults(db="quest") + command.add_argument("--pack", required=True, type=int) + command.add_argument("--quest", type=int, help="omit to list every quest") + command.set_defaults(handler=cmd_rewards) + + command = sub.add_parser("chain", help="follow PackTable.NextPackId") + add_database_options(command) + command.set_defaults(db="quest") + command.add_argument("--start-pack", required=True, type=int) + command.add_argument("--limit", default=64, type=int) + command.set_defaults(handler=cmd_chain) + + command = sub.add_parser("extract", help="write a decrypted SQLite file") + add_database_options(command) + command.add_argument("--output", required=True) + command.add_argument("--force", action="store_true") + command.set_defaults(handler=cmd_extract) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + args.handler(args) + except (OSError, ValueError, sqlite3.Error, zipfile.BadZipFile) as exc: + parser.exit(1, f"gamedata_db: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/python/import_seed.py b/tools/python/import_seed.py new file mode 100644 index 0000000..cff99d4 --- /dev/null +++ b/tools/python/import_seed.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Convert decoded 2.34.13 protobuf messages into reviewed JSON seeds. + +This replaces the old Go cmd/import-* programs. Network/capture decryption is +kept separate: inputs here are raw, already-decoded protobuf files. Outputs +are semantic JSON used by the server and never contain HTTP envelopes. + +Examples: + py tools/import_seed.py login LoginUser.pb --packet-code 11 --output login_user.json + py tools/import_seed.py starter --items ItemInfo.pb --costumes CostumeInfo.pb \ + --characters CharInfo.pb --output starter_player.json + py tools/import_seed.py mail MailInfo.pb --output mail.json + py tools/import_seed.py readonly responses.json --output readonly.json + +readonly responses.json: + {"/SkyWayScheduleInfo":{"packet_code":162,"protobuf":"SkyWayScheduleInfo.pb"}} +""" + +from __future__ import annotations + +import argparse +import base64 +from dataclasses import dataclass +import json +import os +from pathlib import Path +import struct + + +VERSION = "2.34.13" + + +@dataclass(frozen=True) +class Field: + number: int + wire_type: int + value: int | bytes + start: int + end: int + + +def read_varint(data: bytes, offset: int) -> tuple[int, int]: + value = 0 + shift = 0 + for index in range(offset, len(data)): + byte = data[index] + value |= (byte & 0x7F) << shift + if byte < 0x80: + return value, index + 1 + shift += 7 + if shift >= 70: + break + raise ValueError(f"invalid protobuf varint at offset {offset}") + + +def fields(data: bytes) -> list[Field]: + result: list[Field] = [] + offset = 0 + while offset < len(data): + start = offset + tag, offset = read_varint(data, offset) + number, wire_type = tag >> 3, tag & 7 + if number <= 0: + raise ValueError("protobuf field number zero") + if wire_type == 0: + value, offset = read_varint(data, offset) + elif wire_type == 1: + if offset + 8 > len(data): + raise ValueError("truncated fixed64") + value, offset = data[offset : offset + 8], offset + 8 + elif wire_type == 2: + length, offset = read_varint(data, offset) + if offset + length > len(data): + raise ValueError("truncated bytes field") + value, offset = data[offset : offset + length], offset + length + elif wire_type == 5: + if offset + 4 > len(data): + raise ValueError("truncated fixed32") + value, offset = data[offset : offset + 4], offset + 4 + else: + raise ValueError(f"unsupported protobuf wire type {wire_type}") + result.append(Field(number, wire_type, value, start, offset)) + return result + + +def encode_varint(value: int) -> bytes: + if value < 0: + value &= (1 << 64) - 1 + result = bytearray() + while value >= 0x80: + result.append((value & 0x7F) | 0x80) + value >>= 7 + result.append(value) + return bytes(result) + + +def encode_field(number: int, wire_type: int, payload: int | bytes) -> bytes: + output = bytearray(encode_varint(number << 3 | wire_type)) + if wire_type == 0: + output.extend(encode_varint(int(payload))) + elif wire_type == 1: + output.extend(payload) + elif wire_type == 2: + output.extend(encode_varint(len(payload))) + output.extend(payload) + elif wire_type == 5: + output.extend(payload) + else: + raise ValueError(f"cannot encode wire type {wire_type}") + return bytes(output) + + +def packed_varints(data: bytes) -> list[int]: + result = [] + offset = 0 + while offset < len(data): + value, offset = read_varint(data, offset) + result.append(value) + return result + + +def decode_varint_message(data: bytes, mapping: dict[int, str]) -> dict: + output = {} + for field in fields(data): + name = mapping.get(field.number) + if name is None: + raise ValueError(f"unsupported field {field.number}") + if field.wire_type != 0: + raise ValueError(f"field {field.number} is not a varint") + if field.value: + output[name] = field.value + return output + + +def atomic_json(path: Path, value: dict, force: bool) -> None: + path = path.resolve() + if path.exists() and not force: + raise FileExistsError(f"refusing to overwrite {path}; pass --force") + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + with temporary.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + print(f"wrote {path}") + + +def import_login(args: argparse.Namespace) -> dict: + proto = args.input.read_bytes() + top = fields(proto) + users = [field for field in top if field.number == 1] + if len(users) != 1 or users[0].wire_type != 2: + raise ValueError("LoginUser must contain exactly one UserDBInfo field") + user = users[0].value + stripped = b"".join( + user[field.start : field.end] + for field in fields(user) + if not (field.number == 3 and field.wire_type == 2) + ) + if not stripped: + raise ValueError("UserDBInfo became empty after removing user_key") + other = b"".join( + proto[field.start : field.end] for field in top if field.number != 1 + ) + return { + "version": VERSION, + "packet_code": args.packet_code, + "user_info_base64": base64.b64encode(stripped).decode("ascii"), + "response_fields_base64": base64.b64encode(other).decode("ascii"), + } + + +ITEM_FIELDS = { + 1: "inven_index", 2: "id", 3: "type", 4: "count", 5: "keep_flag", + 6: "time_value", 9: "sort_id", 10: "use_count", +} +COSTUME_FIELDS = { + 1: "inven_index", 2: "id", 3: "level", 4: "use_char", 6: "sort_id", + 8: "potential_id", 9: "design_id", 10: "burst_level", 12: "time_value", +} +CHARACTER_FIELDS = { + 1: "inven_index", 2: "id", 3: "hp", 4: "level", 5: "costume_id", + 6: "exp", 7: "use_costume", 8: "talent_level", 9: "talent_exp", + 10: "solidarity_reward", 11: "expiry_time", 13: "connect_potential_costume", +} + + +def repeated_messages(data: bytes, response_name: str, allow_mode: bool = False): + messages = [] + mode = 0 + for field in fields(data): + if field.number == 1 and field.wire_type == 2: + messages.append(field.value) + elif allow_mode and field.number == 2 and field.wire_type == 0: + mode = int(field.value) + else: + raise ValueError( + f"unexpected {response_name} response field {field.number}/{field.wire_type}" + ) + return messages, mode + + +def import_starter(args: argparse.Namespace) -> dict: + item_messages, _ = repeated_messages(args.items.read_bytes(), "ItemInfo") + costume_messages, _ = repeated_messages(args.costumes.read_bytes(), "CostumeInfo") + character_messages, mode = repeated_messages( + args.characters.read_bytes(), "CharInfo", allow_mode=True + ) + items = [decode_varint_message(value, ITEM_FIELDS) for value in item_messages] + costumes = [decode_varint_message(value, COSTUME_FIELDS) for value in costume_messages] + characters = [decode_varint_message(value, CHARACTER_FIELDS) for value in character_messages] + for kind, entries in (("item", items), ("costume", costumes), ("character", characters)): + for entry in entries: + if not entry.get("id") or (kind != "item" and not entry.get("inven_index")): + raise ValueError(f"invalid {kind}: {entry}") + if kind == "item" and not entry.get("count"): + raise ValueError(f"invalid item: {entry}") + return { + "version": VERSION, + "items": items, + "costumes": costumes, + "characters": characters, + "field_char_control_deck_type": mode, + } + + +def import_mail_entry(data: bytes) -> dict: + output = {} + scalar = {1: "mail_id", 2: "mail_type", 3: "template_id", 7: "expires_at", 13: "sent_at"} + text = {4: "sender", 5: "title", 6: "body"} + packed = {8: "reward_types", 9: "reward_ids", 10: "reward_counts"} + for field in fields(data): + if field.number in scalar and field.wire_type == 0: + if field.value: + output[scalar[field.number]] = field.value + elif field.number in text and field.wire_type == 2: + value = field.value.decode("utf-8") + if value: + output[text[field.number]] = value + elif field.number in packed and field.wire_type == 2: + values = packed_varints(field.value) + if values: + output[packed[field.number]] = values + else: + raise ValueError(f"unsupported MailDBInfo field {field.number}/{field.wire_type}") + reward_lengths = [len(output.get(name, [])) for name in packed.values()] + if len(set(reward_lengths)) != 1: + raise ValueError(f"mail reward arrays differ: {output}") + if not output.get("mail_id") or not output.get("expires_at") or not output.get("sent_at"): + raise ValueError(f"mail identity/time is incomplete: {output}") + return output + + +def import_mail(args: argparse.Namespace) -> dict: + mails = [] + mail_count = max_mail_id = 0 + for field in fields(args.input.read_bytes()): + if field.number == 1 and field.wire_type == 2: + mails.append(import_mail_entry(field.value)) + elif field.number == 2 and field.wire_type == 0: + mail_count = int(field.value) + elif field.number == 3 and field.wire_type == 0: + max_mail_id = int(field.value) + else: + raise ValueError(f"unsupported MailInfo field {field.number}/{field.wire_type}") + if mail_count != len(mails) + 1: + raise ValueError("mail_count must include the server sentinel") + return {"version": VERSION, "mails": mails, "mail_count": mail_count, "max_mail_id": max_mail_id} + + +def readonly_fields(data: bytes) -> list[dict]: + output = [] + for field in fields(data): + item = {"number": field.number, "type": field.wire_type} + if field.wire_type == 0: + if field.value: + item["varint"] = field.value + elif field.wire_type == 1: + value = struct.unpack(" dict: + manifest_path = args.input.resolve() + with manifest_path.open("r", encoding="utf-8") as stream: + manifest = json.load(stream) + responses = {} + for path, description in manifest.items(): + if not path.startswith("/") or int(description["packet_code"]) < 0: + raise ValueError(f"invalid readonly response {path!r}") + proto_path = (manifest_path.parent / description["protobuf"]).resolve() + responses[path] = { + "packet_code": int(description["packet_code"]), + "fields": readonly_fields(proto_path.read_bytes()), + } + if not responses: + raise ValueError("readonly manifest is empty") + return {"version": VERSION, "responses": responses} + + +def output_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--force", action="store_true") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + command = sub.add_parser("login") + command.add_argument("input", type=Path) + command.add_argument("--packet-code", required=True, type=int) + output_options(command) + command.set_defaults(importer=import_login) + + command = sub.add_parser("starter") + command.add_argument("--items", required=True, type=Path) + command.add_argument("--costumes", required=True, type=Path) + command.add_argument("--characters", required=True, type=Path) + output_options(command) + command.set_defaults(importer=import_starter) + + command = sub.add_parser("mail") + command.add_argument("input", type=Path) + output_options(command) + command.set_defaults(importer=import_mail) + + command = sub.add_parser("readonly") + command.add_argument("input", type=Path, help="JSON mapping path to packet_code/protobuf") + output_options(command) + command.set_defaults(importer=import_readonly) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + value = args.importer(args) + atomic_json(args.output, value, args.force) + except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: + parser.exit(1, f"import_seed: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/python/localize_bundles.py b/tools/python/localize_bundles.py new file mode 100644 index 0000000..58e78b1 --- /dev/null +++ b/tools/python/localize_bundles.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""把远程 bundle 变成本地直读:硬链接进 StreamingAssets/aa + 改写 catalog。 + +## 为什么要用 {RuntimePath} 而不是绝对路径 + +试过把 catalog 里的条目改成 `E:/bd2/dl/.../xxx.bundle` 这种绝对路径,结果带子目录的 +bundle 报: + + RemoteProviderException : Invalid path in AssetBundleProvider + +原因是 Addressables 的 `AssetBundleResource.GetLoadInfo()`: + + if (!(location?.Data is AssetBundleRequestOptions)) { loadType = None; } // ← 落到这 + else if (ShouldPathUseWebRequest(path)) loadType = Web; + else if (UseUnityWebRequestForLocalBundles) loadType = Web; + else loadType = Local; + + ... + default: // LoadType.None + Complete(null, false, new RemoteProviderException( + $"Invalid path in AssetBundleProvider: '{path}'.")); + +绝对路径这种 location 上挂不到 `AssetBundleRequestOptions`,于是 LoadType.None。 + +**客户端自带那 10 个本地 bundle 用的是 `{RuntimePath}\\<包名>`** —— +那是 Addressables 原生的本地形态,一定挂得上 options。照抄它。 + +## 硬链接 + +`RuntimePath` 解析成 `StreamingAssets/aa`,所以 bundle 得摆进去。用 NTFS 硬链接 +**零额外空间**(同一份数据两个路径)。包名里带子目录的(798 个)要建同样的子目录。 + +## 用法 + + python localize_bundles.py +""" +import hashlib +import io +import json +import os +import re +import shutil +import sys + +BS = chr(92) +LOCAL_PREFIX = "{UnityEngine.AddressableAssets.Addressables.RuntimePath}" + BS + + +def strip_prefix(s): + """`{BDNetwork.CdnInfo.Info}\\平台\\{分辨率}\\{版本}\\<包名>` -> `<包名>`""" + parts = s.replace(BS, "/").split("/") + return "/".join(parts[4:]) + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + return 1 + src, aa = sys.argv[1], sys.argv[2] + cat = os.path.join(aa, "catalog.json") + if not os.path.isfile(cat): + print("找不到 %s" % cat) + return 1 + if not os.path.isdir(src): + print("找不到 bundle 目录 %s" % src) + return 1 + + # ---- 1. 备份 ---- + bak = cat + ".bak-before-localize" + if not os.path.exists(bak): + shutil.copy2(cat, bak) + print("备份 -> %s" % os.path.basename(bak)) + else: + print("备份已存在,跳过") + + # ---- 2. 硬链接 ---- + linked = skipped = copied = 0 + for dp, _, fn in os.walk(src): + for f in fn: + if not f.endswith(".bundle"): + continue + s = os.path.join(dp, f) + d = os.path.join(aa, os.path.relpath(s, src)) + os.makedirs(os.path.dirname(d), exist_ok=True) + if os.path.exists(d): + skipped += 1 + continue + try: + os.link(s, d) + linked += 1 + except OSError: + shutil.copy2(s, d) + copied += 1 + print("硬链接 新建%d 已存在%d 退回复制%d" % (linked, skipped, copied)) + + # ---- 3. 改写 internal id ---- + o = json.load(io.open(cat, encoding="utf-8")) + ids = o["m_InternalIds"] + changed = 0 + for i, s in enumerate(ids): + if not s.startswith("{BDNetwork.CdnInfo.Info}"): + continue + name = strip_prefix(s) + if not name or "{" in name: + print("解析异常,中止:%r -> %r" % (s, name)) + return 1 + ids[i] = LOCAL_PREFIX + name + changed += 1 + print("改写 %d 条" % changed) + + # 长度不变,下标引用依然有效 + raw = json.dumps(o, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + io.open(cat, "wb").write(raw) + print("写回 %s(%d 字节)" % (os.path.basename(cat), len(raw))) + print(" md5 = %s" % hashlib.md5(raw).hexdigest()) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/python/rollback_quest38.py b/tools/python/rollback_quest38.py new file mode 100644 index 0000000..a1b5bcc --- /dev/null +++ b/tools/python/rollback_quest38.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Narrowly roll the local account back to the pack21 quest-38 boundary. + +This is a one-off development/recovery tool, not server code. It understands +both legacy progress saves and the v2 ``pack:quest`` schema. By default it only +prints the planned changes; pass --apply after stopping the client and server. +Before applying it copies all nine account files to a timestamped checkpoint +and verifies every SHA-256 hash. +""" + +from __future__ import annotations + +import argparse +import copy +from datetime import datetime +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +STATE_FILES = ( + "characters.json", + "collection.json", + "deck.json", + "equipment.json", + "items.json", + "mail.json", + "missions.json", + "progress.json", + "wallet.json", +) +QUEST_ITEMS = { + 900000033: (122, 19, 1), + 900000034: (11, 19, 1), + 900000035: (501, 27, 6), + 900000036: (101, 27, 3), +} +ITEM_GRANT = "pack21:quest38:items" +WALLET_GRANT = "pack21:quest38" +WORKSPACE_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state" + + +def load_json(path: Path): + with path.open("r", encoding="utf-8") as stream: + return json.load(stream) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest().upper() + + +def quest_key(progress: dict, pack_id: int, quest_id: int) -> str: + version = int(progress.get("version", 0)) + return f"{pack_id}:{quest_id}" if version >= 2 else str(quest_id) + + +def quest_cleared(progress: dict, pack_id: int, quest_id: int) -> bool: + value = progress.get("cleared_quests", {}).get(quest_key(progress, pack_id, quest_id)) + if int(progress.get("version", 0)) >= 2: + return value is True + return value == pack_id + + +def running_game_processes() -> list[str]: + if os.name != "nt": + return [] + result = subprocess.run( + ["tasklist", "/FO", "CSV", "/NH"], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + names = [] + for line in result.stdout.splitlines(): + lowered = line.lower() + if "browndust2" in lowered or "bd2server.exe" in lowered: + names.append(line) + return names + + +def build_changes(state: Path, reference: Path) -> dict[str, dict]: + progress = load_json(state / "progress.json") + prior_progress = load_json(reference / "progress.json") + if not quest_cleared(progress, 21, 38): + raise ValueError("current save has not cleared pack21 quest38") + if not quest_cleared(prior_progress, 21, 37) or quest_cleared(prior_progress, 21, 38): + raise ValueError("reference is not at the quest37-complete/quest38-pending boundary") + + progress = copy.deepcopy(progress) + progress.get("cleared_quests", {}).pop(quest_key(progress, 21, 38), None) + progress.get("quests", {}).pop(quest_key(progress, 21, 38), None) + progress["position"] = copy.deepcopy(prior_progress["position"]) + + items = load_json(state / "items.json") + indices = items.get("grant_items", {}).get(ITEM_GRANT) + if ( + items.get("granted", {}).get(ITEM_GRANT) is not True + or not isinstance(indices, list) + or set(map(int, indices)) != set(QUEST_ITEMS) + ): + raise ValueError("quest38 item grant does not match the expected four instances") + remaining = [] + removed: set[int] = set() + for item in items.get("items", []): + index = int(item.get("inven_index", 0)) + if index not in QUEST_ITEMS: + remaining.append(item) + continue + expected = QUEST_ITEMS[index] + actual = (int(item.get("id", 0)), int(item.get("type", 0)), int(item.get("count", 0))) + if actual != expected: + raise ValueError(f"unexpected quest38 reward item {item!r}; expected {expected}") + removed.add(index) + if removed != set(QUEST_ITEMS): + raise ValueError(f"quest38 reward instances are incomplete: found {sorted(removed)}") + items = copy.deepcopy(items) + items["items"] = remaining + items["granted"].pop(ITEM_GRANT, None) + items["grant_items"].pop(ITEM_GRANT, None) + items["next_index"] = max( + (int(item.get("inven_index", 0)) + 1 for item in remaining), default=1 + ) + + wallet = load_json(state / "wallet.json") + if wallet.get("granted", {}).get(WALLET_GRANT) is not True or int(wallet.get("gold", 0)) < 1500: + raise ValueError("quest38 wallet grant is absent or gold is below 1500") + wallet = copy.deepcopy(wallet) + wallet["gold"] = int(wallet["gold"]) - 1500 + wallet["granted"].pop(WALLET_GRANT, None) + + deck = load_json(state / "deck.json") + prior_deck = load_json(reference / "deck.json") + if "field_char_control_deck_type" not in prior_deck: + raise ValueError("reference deck has no field_char_control_deck_type") + deck = copy.deepcopy(deck) + deck["field_char_control_deck_type"] = prior_deck["field_char_control_deck_type"] + + return { + "progress.json": progress, + "items.json": items, + "wallet.json": wallet, + "deck.json": deck, + } + + +def checkpoint_all(state: Path, target: Path) -> None: + if target.exists(): + raise FileExistsError(f"checkpoint already exists: {target}") + target.mkdir(parents=True) + try: + for name in STATE_FILES: + source = state / name + if not source.is_file(): + raise FileNotFoundError(f"missing state file: {source}") + shutil.copy2(source, target / name) + mismatches = [ + name for name in STATE_FILES if sha256(state / name) != sha256(target / name) + ] + if mismatches: + raise OSError(f"checkpoint SHA-256 mismatch: {mismatches}") + except Exception: + shutil.rmtree(target, ignore_errors=True) + raise + + +def atomic_write_json(path: Path, value: dict) -> None: + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + with temporary.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def describe(changes: dict[str, dict]) -> dict: + progress = changes["progress.json"] + wallet = changes["wallet.json"] + items = changes["items.json"] + deck = changes["deck.json"] + return { + "quest37_cleared": quest_cleared(progress, 21, 37), + "quest38_cleared": quest_cleared(progress, 21, 38), + "position": progress.get("position"), + "gold": wallet.get("gold"), + "next_item_index": items.get("next_index"), + "field_char_control_deck_type": deck.get("field_char_control_deck_type"), + "files_to_modify": sorted(changes), + } + + +def verify_written_state(state: Path) -> dict: + progress = load_json(state / "progress.json") + items = load_json(state / "items.json") + wallet = load_json(state / "wallet.json") + deck = load_json(state / "deck.json") + if not quest_cleared(progress, 21, 37) or quest_cleared(progress, 21, 38): + raise ValueError("written progress is not at the quest38 boundary") + if ITEM_GRANT in items.get("granted", {}) or ITEM_GRANT in items.get("grant_items", {}): + raise ValueError("written inventory still contains the quest38 grant") + if any(int(item.get("inven_index", 0)) in QUEST_ITEMS for item in items.get("items", [])): + raise ValueError("written inventory still contains a quest38 reward instance") + if WALLET_GRANT in wallet.get("granted", {}): + raise ValueError("written wallet still contains the quest38 grant") + return describe( + { + "progress.json": progress, + "items.json": items, + "wallet.json": wallet, + "deck.json": deck, + } + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("state", nargs="?", default=DEFAULT_STATE, type=Path) + parser.add_argument( + "reference", + nargs="?", + default=DEFAULT_STATE / "checkpoints" / "20260920-1801-before-regular-gacha", + type=Path, + ) + parser.add_argument("--apply", action="store_true", help="perform the rollback") + parser.add_argument( + "--backup", + type=Path, + help="checkpoint destination; defaults below state/checkpoints", + ) + args = parser.parse_args() + state = args.state.resolve() + reference = args.reference.resolve() + try: + changes = build_changes(state, reference) + print(json.dumps(describe(changes), ensure_ascii=False, indent=2)) + if not args.apply: + print("dry run only; stop client/server and pass --apply to write", file=sys.stderr) + return 0 + running = running_game_processes() + if running: + raise RuntimeError("client/server is still running:\n" + "\n".join(running)) + backup = args.backup + if backup is None: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = state / "checkpoints" / f"{stamp}-before-quest38-rollback" + backup = backup.resolve() + checkpoint_all(state, backup) + for name, value in changes.items(): + atomic_write_json(state / name, value) + verified = verify_written_state(state) + print(f"rollback applied; verified checkpoint: {backup}") + print(json.dumps(verified, ensure_ascii=False, indent=2)) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + parser.exit(1, f"rollback_quest38: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/python/save_checkpoint.py b/tools/python/save_checkpoint.py new file mode 100644 index 0000000..a129037 --- /dev/null +++ b/tools/python/save_checkpoint.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Create, verify, and restore complete local-account checkpoints. + +Unlike the old tutorial-specific Go command this tool does not synthesize game +state. It copies the nine authoritative JSON files, records SHA-256 hashes, and +requires --apply plus stopped client/server processes before restore. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +STATE_FILES = ( + "characters.json", + "collection.json", + "deck.json", + "equipment.json", + "items.json", + "mail.json", + "missions.json", + "progress.json", + "wallet.json", +) +MANIFEST = "checkpoint.json" +WORKSPACE_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state" + + +def digest(path: Path) -> str: + result = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + result.update(block) + return result.hexdigest().upper() + + +def inspect_files(directory: Path) -> dict[str, dict[str, object]]: + result = {} + for name in STATE_FILES: + path = directory / name + if not path.is_file(): + raise FileNotFoundError(f"missing account state file: {path}") + with path.open("r", encoding="utf-8") as stream: + json.load(stream) + result[name] = {"size": path.stat().st_size, "sha256": digest(path)} + return result + + +def running_processes() -> list[str]: + if os.name != "nt": + return [] + result = subprocess.run( + ["tasklist", "/FO", "CSV", "/NH"], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return [ + line + for line in result.stdout.splitlines() + if "browndust2" in line.lower() or "bd2server.exe" in line.lower() + ] + + +def write_manifest(target: Path, files: dict[str, dict[str, object]], label: str) -> None: + value = { + "format": 1, + "created_at": datetime.now().astimezone().isoformat(), + "label": label, + "files": files, + } + path = target / MANIFEST + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + with temporary.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def create(state: Path, target: Path, label: str) -> dict[str, dict[str, object]]: + source_info = inspect_files(state) + if target.exists(): + raise FileExistsError(f"checkpoint already exists: {target}") + target.mkdir(parents=True) + try: + for name in STATE_FILES: + shutil.copy2(state / name, target / name) + copied_info = inspect_files(target) + if copied_info != source_info: + raise OSError("checkpoint differs from source after SHA-256 verification") + write_manifest(target, copied_info, label) + return copied_info + except Exception: + shutil.rmtree(target, ignore_errors=True) + raise + + +def verify(checkpoint: Path) -> dict[str, dict[str, object]]: + actual = inspect_files(checkpoint) + manifest_path = checkpoint / MANIFEST + if manifest_path.is_file(): + with manifest_path.open("r", encoding="utf-8") as stream: + manifest = json.load(stream) + if manifest.get("format") != 1 or manifest.get("files") != actual: + raise ValueError("checkpoint manifest does not match its files") + return actual + + +def atomic_copy(source: Path, target: Path) -> None: + temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp") + try: + with source.open("rb") as reader, temporary.open("wb") as writer: + shutil.copyfileobj(reader, writer, length=1024 * 1024) + writer.flush() + os.fsync(writer.fileno()) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + + +def restore(state: Path, checkpoint: Path, apply: bool) -> Path | None: + checkpoint_info = verify(checkpoint) + print(json.dumps(checkpoint_info, ensure_ascii=False, indent=2)) + if not apply: + print("dry run only; stop client/server and pass --apply to restore", file=sys.stderr) + return None + running = running_processes() + if running: + raise RuntimeError("client/server is still running:\n" + "\n".join(running)) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = state / "checkpoints" / f"{stamp}-before-restore" + create(state, backup, "automatic backup before restore") + for name in STATE_FILES: + atomic_copy(checkpoint / name, state / name) + if inspect_files(state) != checkpoint_info: + raise OSError(f"restore verification failed; previous state is at {backup}") + return backup + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + command = sub.add_parser("create") + command.add_argument("--state", type=Path, default=DEFAULT_STATE) + command.add_argument("--label", default="manual") + command.add_argument("--output", type=Path) + + command = sub.add_parser("verify") + command.add_argument("checkpoint", type=Path) + + command = sub.add_parser("restore") + command.add_argument("checkpoint", type=Path) + command.add_argument("--state", type=Path, default=DEFAULT_STATE) + command.add_argument("--apply", action="store_true") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + if args.command == "create": + state = args.state.resolve() + output = args.output + if output is None: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + safe_label = "-".join(args.label.strip().split()) or "manual" + output = state / "checkpoints" / f"{stamp}-{safe_label}" + output = output.resolve() + info = create(state, output, args.label) + print(f"created {output}") + print(json.dumps(info, ensure_ascii=False, indent=2)) + elif args.command == "verify": + print(json.dumps(verify(args.checkpoint.resolve()), ensure_ascii=False, indent=2)) + else: + backup = restore(args.state.resolve(), args.checkpoint.resolve(), args.apply) + if backup is not None: + print(f"restored {args.checkpoint.resolve()}; previous state: {backup}") + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + parser.exit(1, f"save_checkpoint: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/python/start-official-capture.ps1 b/tools/python/start-official-capture.ps1 new file mode 100644 index 0000000..af8e20c --- /dev/null +++ b/tools/python/start-official-capture.ps1 @@ -0,0 +1,111 @@ +param( + [Parameter(Mandatory)] + [string]$GameDir, + + [Parameter(Mandatory)] + [string]$LocalClientDir, + + [string]$SharedDataDir, + + [switch]$Wait +) + +$ErrorActionPreference = 'Stop' + +$GameDir = [IO.Path]::GetFullPath($GameDir) +$LocalClientDir = [IO.Path]::GetFullPath($LocalClientDir) +if (-not $SharedDataDir) { + $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) + $SharedDataDir = Join-Path (Split-Path -Parent $localAppData) 'LocalLow\Gamfs\BrownDust II' +} +$SharedDataDir = [IO.Path]::GetFullPath($SharedDataDir) + +$exe = Join-Path $GameDir 'BrownDust II.exe' +$pluginDir = Join-Path $GameDir 'BepInEx\plugins' +$localRes = Join-Path $pluginDir 'PluginLocalRes.dll' +$capturePlugin = Join-Path $pluginDir 'BD2CaptureEnvironment.dll' +$identityPlugin = Join-Path $pluginDir 'BD2LocalIdentity.dll' +$doorstop = Join-Path $GameDir 'doorstop_config.ini' +$isolatedData = Join-Path $GameDir 'IsolatedUserData' +$sharedGameData = Join-Path $SharedDataDir 'Data\t' +$stagedCapturePlugin = Join-Path $PSScriptRoot '..\..\plugins\CaptureEnvironment\bin\Release\netstandard2.1\BD2CaptureEnvironment.dll' + +if (Get-Process -Name 'BrownDust II' -ErrorAction SilentlyContinue) { + throw 'BrownDust II is already running. Exit it before starting the isolated official client.' +} + +foreach ($requiredDirectory in @($GameDir, $LocalClientDir, $SharedDataDir, $sharedGameData)) { + if (-not (Test-Path -LiteralPath $requiredDirectory -PathType Container)) { + throw "Required directory is missing: $requiredDirectory" + } +} + +if (Test-Path -LiteralPath $stagedCapturePlugin -PathType Leaf) { + $installedHash = if (Test-Path -LiteralPath $capturePlugin -PathType Leaf) { + (Get-FileHash -LiteralPath $capturePlugin -Algorithm SHA256).Hash + } + $stagedHash = (Get-FileHash -LiteralPath $stagedCapturePlugin -Algorithm SHA256).Hash + if ($installedHash -ne $stagedHash) { + New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null + Copy-Item -LiteralPath $stagedCapturePlugin -Destination $capturePlugin -Force + Write-Host "Updated capture plugin from staged build ($stagedHash)." + } +} + +foreach ($required in @($exe, $doorstop, $localRes, $capturePlugin)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Required file is missing: $required" + } +} + +if (Test-Path -LiteralPath $identityPlugin) { + throw "Unsafe plugin found in official environment: $identityPlugin" +} + +$unexpectedIdentity = Get-ChildItem -LiteralPath (Join-Path $GameDir 'BepInEx') ` + -Recurse -File -Filter 'BD2LocalIdentity.dll' -ErrorAction SilentlyContinue +if ($unexpectedIdentity) { + throw "BD2LocalIdentity.dll must not exist anywhere in the official environment." +} + +$sharedStaticPaths = @{ + (Join-Path $isolatedData 'SoundData') = (Join-Path $SharedDataDir 'SoundData') + (Join-Path $isolatedData 'com.unity.addressables') = (Join-Path $SharedDataDir 'com.unity.addressables') + (Join-Path $isolatedData 'VideoData') = (Join-Path $SharedDataDir 'VideoData') + (Join-Path $GameDir 'BrownDust II_Data\StreamingAssets\aa') = (Join-Path $LocalClientDir 'BrownDust II_Data\StreamingAssets\aa') +} +foreach ($link in $sharedStaticPaths.GetEnumerator()) { + $item = Get-Item -LiteralPath $link.Key -Force -ErrorAction SilentlyContinue + if (-not $item -or $item.LinkType -ne 'Junction') { + throw "Shared static-data junction is missing: $($link.Key)" + } + $actualTarget = [IO.Path]::GetFullPath([string]$item.Target) + $expectedTarget = [IO.Path]::GetFullPath([string]$link.Value) + if ($actualTarget -ne $expectedTarget) { + throw "Unexpected junction target for $($link.Key): $actualTarget" + } +} + +New-Item -ItemType Directory -Force -Path $isolatedData | Out-Null +$unityLog = Join-Path $isolatedData 'Player.log' +$process = Start-Process -FilePath $exe -WorkingDirectory $GameDir ` + -ArgumentList @( + '-logFile', ('"' + $unityLog + '"'), + '-bd2SharedGameData', ('"' + $sharedGameData + '"') + ) -PassThru +Write-Host "Started isolated official client (PID $($process.Id))." +Write-Host "BepInEx log: $(Join-Path $GameDir 'BepInEx\LogOutput.log')" +Write-Host "Capture root: $(Join-Path $GameDir 'Capture')" +Write-Host "Isolated data: $isolatedData" + +if ($Wait) { + $process.WaitForExit() + Write-Host "Client exited with code $($process.ExitCode)." + $latest = Get-ChildItem -LiteralPath (Join-Path $GameDir 'Capture') ` + -Directory -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($latest) { + Write-Host "Latest capture: $($latest.FullName)" + } +} diff --git a/tools/python/tests/test_offline_tools.py b/tools/python/tests/test_offline_tools.py new file mode 100644 index 0000000..75f1ce4 --- /dev/null +++ b/tools/python/tests/test_offline_tools.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import base64 +import json +from pathlib import Path +import sys +import tempfile +import unittest +import zipfile + +from Crypto.Cipher import AES + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import gamedata_db +import import_seed +import save_checkpoint + + +class Arguments: + pass + + +class GameDataToolTests(unittest.TestCase): + def test_extracts_and_decrypts_logical_database(self): + plain = bytearray(gamedata_db.PAGE_SIZE) + plain[: len(gamedata_db.HEADER)] = gamedata_db.HEADER + key = gamedata_db.derive_key() + encrypted = AES.new(key, AES.MODE_CBC, gamedata_db.HEADER).encrypt(bytes(plain)) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + release = root / "123" / "release" + release.mkdir(parents=True) + with zipfile.ZipFile(release / gamedata_db.ARCHIVE_NAME, "w") as archive: + archive.writestr(gamedata_db.database_entry("pack21"), encrypted) + self.assertEqual(gamedata_db.read_database(root, "123", "pack21"), bytes(plain)) + + def test_wire_field_selection(self): + proto = import_seed.encode_field(1, 0, 42) + import_seed.encode_field(3, 2, b"abc") + decoded = gamedata_db.wire_fields(proto, {1, 3}) + self.assertEqual(decoded[1][0]["varint"], 42) + self.assertEqual(decoded[3][0]["utf8"], "abc") + + +class ImportToolTests(unittest.TestCase): + def test_login_import_removes_captured_key(self): + user = import_seed.encode_field(1, 0, 7) + user += import_seed.encode_field(3, 2, b"captured-secret") + proto = import_seed.encode_field(1, 2, user) + import_seed.encode_field(4, 0, 9) + with tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) / "login.pb" + source.write_bytes(proto) + args = Arguments() + args.input, args.packet_code = source, 11 + result = import_seed.import_login(args) + imported_user = base64.b64decode(result["user_info_base64"]) + self.assertFalse(any(field.number == 3 for field in import_seed.fields(imported_user))) + self.assertEqual(base64.b64decode(result["response_fields_base64"]), import_seed.encode_field(4, 0, 9)) + + def test_starter_and_mail_import(self): + item = b"".join( + import_seed.encode_field(number, 0, value) + for number, value in ((1, 10), (2, 8), (3, 8), (4, 3)) + ) + costume = import_seed.encode_field(1, 0, 20) + import_seed.encode_field(2, 0, 3501) + character = import_seed.encode_field(1, 0, 30) + import_seed.encode_field(2, 0, 350) + mail = b"".join( + import_seed.encode_field(number, 0, value) + for number, value in ((1, 1), (2, 2), (7, 100), (13, 50)) + ) + mail += import_seed.encode_field(8, 2, import_seed.encode_varint(8)) + mail += import_seed.encode_field(9, 2, import_seed.encode_varint(7)) + mail += import_seed.encode_field(10, 2, import_seed.encode_varint(3)) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name, proto in { + "items.pb": import_seed.encode_field(1, 2, item), + "costumes.pb": import_seed.encode_field(1, 2, costume), + "characters.pb": import_seed.encode_field(1, 2, character), + "mail.pb": import_seed.encode_field(1, 2, mail) + + import_seed.encode_field(2, 0, 2) + + import_seed.encode_field(3, 0, 1), + }.items(): + (root / name).write_bytes(proto) + args = Arguments() + args.items, args.costumes, args.characters = ( + root / "items.pb", + root / "costumes.pb", + root / "characters.pb", + ) + starter = import_seed.import_starter(args) + args.input = root / "mail.pb" + mailbox = import_seed.import_mail(args) + self.assertEqual(starter["items"][0]["count"], 3) + self.assertEqual(starter["costumes"][0]["id"], 3501) + self.assertEqual(starter["characters"][0]["id"], 350) + self.assertEqual(mailbox["mails"][0]["reward_counts"], [3]) + + +class CheckpointToolTests(unittest.TestCase): + def test_checkpoint_hash_manifest(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + state = root / "state" + target = root / "checkpoint" + state.mkdir() + for name in save_checkpoint.STATE_FILES: + (state / name).write_text(json.dumps({"name": name}), encoding="utf-8") + created = save_checkpoint.create(state, target, "test") + self.assertEqual(save_checkpoint.verify(target), created) + + +if __name__ == "__main__": + unittest.main()