From d8771caef2680bc27bb37d5b2fdd6b07d46e29b9 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Mon, 5 Oct 2026 11:50:31 +0800 Subject: [PATCH] feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling. Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata. Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks. Improve automatic login and recovery diagnostics; document configuration and release flows. --- .gitignore | 2 +- Dockerfile.server | 1 + Dockerfile.server.dockerignore | 2 + GAME_CONFIGURATION.md | 40 + README.md | 2 +- RELEASE.md | 6 +- build-release.ps1 | 38 +- docs/CLIENT.md | 75 + docs/SCHEDULES.md | 48 + game.json | 7 + go/cmd/bd2client/dev_command.go | 2 + go/cmd/bd2client/dev_command_test.go | 1 + go/cmd/bd2client/dev_sdk_integration_test.go | 2 +- go/cmd/bd2client/main.go | 2 + go/cmd/bd2server/main.go | 324 +- go/cmd/bd2server/preflight.go | 25 +- go/internal/client/app/app.go | 7 +- go/internal/client/config/release.go | 1 + go/internal/client/plugin/install.go | 3 +- go/internal/client/plugin/install_test.go | 8 +- go/internal/client/setup/setup.go | 12 +- go/internal/server/account/login.go | 80 +- go/internal/server/battle/battle.go | 63 +- go/internal/server/battle/event_test.go | 72 + go/internal/server/battle/hunting_test.go | 6 +- go/internal/server/calendar/binary.go | 194 ++ go/internal/server/calendar/binary_test.go | 115 + go/internal/server/calendar/design.go | 130 + go/internal/server/calendar/load.go | 379 ++ go/internal/server/calendar/load_test.go | 122 + go/internal/server/calendar/readonly.go | 91 + go/internal/server/calendar/records.go | 312 ++ go/internal/server/calendar/schema.go | 115 + go/internal/server/commerce/attendance.go | 96 + .../server/commerce/attendance_test.go | 101 + go/internal/server/commerce/catalog.go | 202 ++ go/internal/server/commerce/catalog_test.go | 95 + go/internal/server/commerce/clear_packages.go | 186 + .../server/commerce/clear_packages_test.go | 61 + go/internal/server/commerce/entitlements.go | 323 ++ .../server/commerce/entitlements_test.go | 138 + go/internal/server/commerce/installed_test.go | 80 + go/internal/server/commerce/login_pass.go | 157 + .../server/commerce/login_pass_test.go | 68 + go/internal/server/commerce/pack_info.go | 39 + go/internal/server/commerce/service.go | 695 ++++ go/internal/server/commerce/service_test.go | 316 ++ go/internal/server/eventactions/battle.go | 164 + go/internal/server/eventactions/cafeteria.go | 56 + .../server/eventactions/cafeteria_test.go | 62 + go/internal/server/eventactions/service.go | 551 +++ .../server/eventactions/service_test.go | 139 + go/internal/server/eventactions/voting.go | 321 ++ go/internal/server/eventexchange/service.go | 400 +++ .../server/eventexchange/service_test.go | 131 + go/internal/server/eventgames/SPEC.md | 46 + go/internal/server/eventgames/games.go | 402 +++ go/internal/server/eventgames/games_test.go | 201 ++ go/internal/server/eventgames/play.go | 294 ++ go/internal/server/eventgames/validation.go | 87 + go/internal/server/eventplay/SPEC.md | 40 + go/internal/server/eventplay/battle.go | 175 + .../server/eventplay/hub_calendar_test.go | 53 + go/internal/server/eventplay/play.go | 775 +++++ go/internal/server/eventplay/play_test.go | 219 ++ go/internal/server/eventplay/progress.go | 35 + go/internal/server/eventplay/projection.go | 162 + go/internal/server/eventplay/room.go | 39 + go/internal/server/eventplay/routes.go | 58 + go/internal/server/eventplay/runtime.go | 237 ++ go/internal/server/eventplay/summary.go | 64 + go/internal/server/eventplay/survival.go | 219 ++ go/internal/server/eventplay/survival_test.go | 60 + go/internal/server/eventplay/upgrade.go | 66 + go/internal/server/events/ap_refresh.go | 164 + go/internal/server/events/boxes.go | 86 + go/internal/server/events/economy.go | 435 +++ go/internal/server/events/economy_test.go | 270 ++ go/internal/server/events/owned_items.go | 28 + go/internal/server/events/registry.go | 100 + go/internal/server/events/registry_test.go | 103 + go/internal/server/events/skin_handler.go | 11 + go/internal/server/events/skins.go | 34 + .../eventtasks/calendar_identity_test.go | 81 + go/internal/server/eventtasks/observer.go | 101 + .../server/eventtasks/paid_attendance_test.go | 55 + go/internal/server/eventtasks/service.go | 905 +++++ go/internal/server/eventtasks/service_test.go | 181 + go/internal/server/feature/feature.go | 4 - go/internal/server/feature/feature_test.go | 9 +- go/internal/server/feature/standalone.go | 9 - go/internal/server/gacha/schedule.go | 68 +- go/internal/server/gacha/schedule_test.go | 46 +- go/internal/server/gameconfig/config.go | 32 +- .../server/gameconfig/purchases_test.go | 39 + go/internal/server/gamedata/battle.go | 20 +- .../server/gamedata/battle_difficulty.go | 22 +- go/internal/server/gamedata/battle_phase.go | 19 +- go/internal/server/gamedata/calendar_ids.go | 39 + .../server/gamedata/cash_entitlements.go | 91 + .../server/gamedata/cash_entitlements_test.go | 38 + go/internal/server/gamedata/cash_products.go | 217 ++ .../server/gamedata/cash_products_test.go | 77 + go/internal/server/gamedata/cash_rewards.go | 103 + .../server/gamedata/cash_rewards_test.go | 41 + go/internal/server/gamedata/char_awake.go | 40 +- go/internal/server/gamedata/clear_packages.go | 56 + .../server/gamedata/costume_potential.go | 19 +- go/internal/server/gamedata/database_cache.go | 310 ++ .../server/gamedata/database_cache_test.go | 322 ++ go/internal/server/gamedata/dispatch.go | 13 + go/internal/server/gamedata/dispatch_test.go | 6 +- .../gamedata/equipment_collaboration.go | 19 +- .../server/gamedata/equipment_craft.go | 36 +- .../server/gamedata/equipment_gacha.go | 39 +- .../gamedata/equipment_option_reroll.go | 19 +- .../server/gamedata/equipment_slots.go | 19 +- .../server/gamedata/equipment_smelting.go | 19 +- .../server/gamedata/equipment_upgrade.go | 19 +- go/internal/server/gamedata/event_actions.go | 150 + .../server/gamedata/event_actions_test.go | 24 + go/internal/server/gamedata/event_ap.go | 26 + .../server/gamedata/event_battle_challenge.go | 96 + .../gamedata/event_battle_challenge_test.go | 32 + go/internal/server/gamedata/event_exchange.go | 101 + go/internal/server/gamedata/event_games.go | 213 ++ .../server/gamedata/event_games_test.go | 31 + .../server/gamedata/event_owned_item.go | 31 + go/internal/server/gamedata/event_play.go | 101 + .../server/gamedata/event_play_field.go | 48 + .../server/gamedata/event_play_test.go | 22 + go/internal/server/gamedata/event_tasks.go | 233 ++ .../server/gamedata/event_tasks_test.go | 27 + go/internal/server/gamedata/field_objects.go | 19 +- go/internal/server/gamedata/field_research.go | 225 ++ .../server/gamedata/field_research_test.go | 28 + go/internal/server/gamedata/food.go | 19 +- go/internal/server/gamedata/gacha.go | 85 +- .../gamedata/gacha_infinite_metadata.go | 10 + go/internal/server/gamedata/growth.go | 19 +- go/internal/server/gamedata/hunting_ap.go | 50 + .../server/gamedata/inventory_slots.go | 19 +- .../server/gamedata/limited_costume.go | 36 +- .../server/gamedata/limited_costume_test.go | 41 + go/internal/server/gamedata/login_passes.go | 45 + go/internal/server/gamedata/missions.go | 19 +- go/internal/server/gamedata/pack_detail.go | 19 +- .../server/gamedata/pack_transition.go | 20 +- go/internal/server/gamedata/preset.go | 19 +- go/internal/server/gamedata/promotion.go | 36 +- .../server/gamedata/quest_collections_test.go | 1 + .../server/gamedata/quest_difficulty.go | 23 +- .../server/gamedata/quest_formation.go | 66 +- .../server/gamedata/quest_formation_test.go | 3 + go/internal/server/gamedata/quests.go | 43 +- go/internal/server/gamedata/random_box.go | 20 +- go/internal/server/gamedata/recruit_npc.go | 19 +- go/internal/server/gamedata/reward_costume.go | 73 + .../server/gamedata/reward_costume_test.go | 24 + .../server/gamedata/reward_equipment.go | 38 + .../server/gamedata/reward_equipment_test.go | 25 + go/internal/server/gamedata/reward_graph.go | 239 ++ .../server/gamedata/reward_graph_test.go | 60 + go/internal/server/gamedata/reward_skin.go | 28 + go/internal/server/gamedata/stat_design.go | 22 +- go/internal/server/gamedata/waypoint.go | 19 +- .../gamedata/waypoint_cache_benchmark_test.go | 69 + go/internal/server/hunting/ap_refresh.go | 89 + go/internal/server/hunting/dispatch.go | 49 +- go/internal/server/hunting/hunting.go | 184 +- go/internal/server/hunting/hunting_test.go | 222 +- go/internal/server/hunting/progress.go | 224 ++ go/internal/server/missions/events.go | 21 + go/internal/server/missions/missions.go | 112 + .../server/missions/mixed_update_test.go | 36 + .../server/monsterhunt/calendar_test.go | 76 + go/internal/server/monsterhunt/monsterhunt.go | 92 +- .../server/monsterhunt/monsterhunt_test.go | 9 + go/internal/server/monsterhunt/rewards.go | 7 + go/internal/server/player/commerce_items.go | 76 + .../server/player/commerce_items_test.go | 28 + go/internal/server/player/wallet_exchange.go | 69 + go/internal/server/progress/research.go | 61 + go/internal/server/readonly/readonly_test.go | 9 +- go/internal/server/schedule/schedule.go | 20 - go/internal/server/schedule/schedule_test.go | 46 +- go/internal/server/transport/commerce.go | 23 + go/internal/server/transport/commerce_test.go | 32 + go/internal/server/transport/http.go | 2 + go/internal/server/versionconfig/config.go | 1 + go/internal/server/world/cash_packages.go | 19 + go/internal/server/world/hunting.go | 35 + go/internal/server/world/hunting_test.go | 39 + .../server/world/pack_reward_counts.go | 262 ++ .../server/world/pack_reward_counts_test.go | 83 + go/internal/server/world/world.go | 59 +- go/seed/v2_35_10/gacha_schedule.json | 20 - go/seed/v2_35_10/readonly.json | 3047 ----------------- go/seed/v2_35_10/schedule.json | 163 - plugins/CashShop/AttendanceRewardEnvelope.cs | 35 + plugins/CashShop/CashShop.csproj | 37 + plugins/CashShop/CommerceCatalog.cs | 42 + plugins/CashShop/CommercePayments.cs | 27 + plugins/CashShop/Plugin.cs | 636 ++++ plugins/CashShop/README.md | 17 + plugins/Generate-VersionSource.ps1 | 2 +- plugins/LocalIdentity/LocalAccountPolicy.cs | 80 - plugins/LocalIdentity/Plugin.cs | 1 - plugins/LoginUI/LoginController.cs | 110 +- plugins/LoginUI/Plugin.cs | 2 + plugins/LoginUI/SecureGameRelay.cs | 4 +- plugins/LoginUI/SessionDiagnostics.cs | 144 + plugins/LoginUI/SessionRecovery.cs | 43 +- plugins/README.md | 4 +- schedules/cash_shop.bd2schedule | Bin 0 -> 3673 bytes schedules/event_hubs.bd2schedule | Bin 0 -> 849 bytes schedules/events.bd2schedule | Bin 0 -> 4873 bytes schedules/gacha.bd2schedule | Bin 0 -> 979 bytes schedules/monster_hunt.bd2schedule | Bin 0 -> 1677 bytes schedules/regular.bd2schedule | Bin 0 -> 1621 bytes tools/python/start-official-capture.ps1 | 2 +- versions.json | 3 +- 222 files changed, 19000 insertions(+), 4284 deletions(-) create mode 100644 docs/CLIENT.md create mode 100644 docs/SCHEDULES.md create mode 100644 go/internal/server/battle/event_test.go create mode 100644 go/internal/server/calendar/binary.go create mode 100644 go/internal/server/calendar/binary_test.go create mode 100644 go/internal/server/calendar/design.go create mode 100644 go/internal/server/calendar/load.go create mode 100644 go/internal/server/calendar/load_test.go create mode 100644 go/internal/server/calendar/readonly.go create mode 100644 go/internal/server/calendar/records.go create mode 100644 go/internal/server/calendar/schema.go create mode 100644 go/internal/server/commerce/attendance.go create mode 100644 go/internal/server/commerce/attendance_test.go create mode 100644 go/internal/server/commerce/catalog.go create mode 100644 go/internal/server/commerce/catalog_test.go create mode 100644 go/internal/server/commerce/clear_packages.go create mode 100644 go/internal/server/commerce/clear_packages_test.go create mode 100644 go/internal/server/commerce/entitlements.go create mode 100644 go/internal/server/commerce/entitlements_test.go create mode 100644 go/internal/server/commerce/installed_test.go create mode 100644 go/internal/server/commerce/login_pass.go create mode 100644 go/internal/server/commerce/login_pass_test.go create mode 100644 go/internal/server/commerce/pack_info.go create mode 100644 go/internal/server/commerce/service.go create mode 100644 go/internal/server/commerce/service_test.go create mode 100644 go/internal/server/eventactions/battle.go create mode 100644 go/internal/server/eventactions/cafeteria.go create mode 100644 go/internal/server/eventactions/cafeteria_test.go create mode 100644 go/internal/server/eventactions/service.go create mode 100644 go/internal/server/eventactions/service_test.go create mode 100644 go/internal/server/eventactions/voting.go create mode 100644 go/internal/server/eventexchange/service.go create mode 100644 go/internal/server/eventexchange/service_test.go create mode 100644 go/internal/server/eventgames/SPEC.md create mode 100644 go/internal/server/eventgames/games.go create mode 100644 go/internal/server/eventgames/games_test.go create mode 100644 go/internal/server/eventgames/play.go create mode 100644 go/internal/server/eventgames/validation.go create mode 100644 go/internal/server/eventplay/SPEC.md create mode 100644 go/internal/server/eventplay/battle.go create mode 100644 go/internal/server/eventplay/hub_calendar_test.go create mode 100644 go/internal/server/eventplay/play.go create mode 100644 go/internal/server/eventplay/play_test.go create mode 100644 go/internal/server/eventplay/progress.go create mode 100644 go/internal/server/eventplay/projection.go create mode 100644 go/internal/server/eventplay/room.go create mode 100644 go/internal/server/eventplay/routes.go create mode 100644 go/internal/server/eventplay/runtime.go create mode 100644 go/internal/server/eventplay/summary.go create mode 100644 go/internal/server/eventplay/survival.go create mode 100644 go/internal/server/eventplay/survival_test.go create mode 100644 go/internal/server/eventplay/upgrade.go create mode 100644 go/internal/server/events/ap_refresh.go create mode 100644 go/internal/server/events/boxes.go create mode 100644 go/internal/server/events/economy.go create mode 100644 go/internal/server/events/economy_test.go create mode 100644 go/internal/server/events/owned_items.go create mode 100644 go/internal/server/events/registry.go create mode 100644 go/internal/server/events/registry_test.go create mode 100644 go/internal/server/events/skin_handler.go create mode 100644 go/internal/server/events/skins.go create mode 100644 go/internal/server/eventtasks/calendar_identity_test.go create mode 100644 go/internal/server/eventtasks/observer.go create mode 100644 go/internal/server/eventtasks/paid_attendance_test.go create mode 100644 go/internal/server/eventtasks/service.go create mode 100644 go/internal/server/eventtasks/service_test.go create mode 100644 go/internal/server/gameconfig/purchases_test.go create mode 100644 go/internal/server/gamedata/calendar_ids.go create mode 100644 go/internal/server/gamedata/cash_entitlements.go create mode 100644 go/internal/server/gamedata/cash_entitlements_test.go create mode 100644 go/internal/server/gamedata/cash_products.go create mode 100644 go/internal/server/gamedata/cash_products_test.go create mode 100644 go/internal/server/gamedata/cash_rewards.go create mode 100644 go/internal/server/gamedata/cash_rewards_test.go create mode 100644 go/internal/server/gamedata/clear_packages.go create mode 100644 go/internal/server/gamedata/database_cache.go create mode 100644 go/internal/server/gamedata/database_cache_test.go create mode 100644 go/internal/server/gamedata/event_actions.go create mode 100644 go/internal/server/gamedata/event_actions_test.go create mode 100644 go/internal/server/gamedata/event_ap.go create mode 100644 go/internal/server/gamedata/event_battle_challenge.go create mode 100644 go/internal/server/gamedata/event_battle_challenge_test.go create mode 100644 go/internal/server/gamedata/event_exchange.go create mode 100644 go/internal/server/gamedata/event_games.go create mode 100644 go/internal/server/gamedata/event_games_test.go create mode 100644 go/internal/server/gamedata/event_owned_item.go create mode 100644 go/internal/server/gamedata/event_play.go create mode 100644 go/internal/server/gamedata/event_play_field.go create mode 100644 go/internal/server/gamedata/event_play_test.go create mode 100644 go/internal/server/gamedata/event_tasks.go create mode 100644 go/internal/server/gamedata/event_tasks_test.go create mode 100644 go/internal/server/gamedata/field_research.go create mode 100644 go/internal/server/gamedata/field_research_test.go create mode 100644 go/internal/server/gamedata/hunting_ap.go create mode 100644 go/internal/server/gamedata/limited_costume_test.go create mode 100644 go/internal/server/gamedata/login_passes.go create mode 100644 go/internal/server/gamedata/reward_costume.go create mode 100644 go/internal/server/gamedata/reward_costume_test.go create mode 100644 go/internal/server/gamedata/reward_equipment.go create mode 100644 go/internal/server/gamedata/reward_equipment_test.go create mode 100644 go/internal/server/gamedata/reward_graph.go create mode 100644 go/internal/server/gamedata/reward_graph_test.go create mode 100644 go/internal/server/gamedata/reward_skin.go create mode 100644 go/internal/server/gamedata/waypoint_cache_benchmark_test.go create mode 100644 go/internal/server/hunting/ap_refresh.go create mode 100644 go/internal/server/hunting/progress.go create mode 100644 go/internal/server/missions/mixed_update_test.go create mode 100644 go/internal/server/monsterhunt/calendar_test.go create mode 100644 go/internal/server/player/commerce_items.go create mode 100644 go/internal/server/player/commerce_items_test.go create mode 100644 go/internal/server/player/wallet_exchange.go create mode 100644 go/internal/server/progress/research.go create mode 100644 go/internal/server/transport/commerce.go create mode 100644 go/internal/server/transport/commerce_test.go create mode 100644 go/internal/server/world/cash_packages.go create mode 100644 go/internal/server/world/hunting.go create mode 100644 go/internal/server/world/hunting_test.go create mode 100644 go/internal/server/world/pack_reward_counts.go create mode 100644 go/internal/server/world/pack_reward_counts_test.go delete mode 100644 go/seed/v2_35_10/gacha_schedule.json delete mode 100644 go/seed/v2_35_10/schedule.json create mode 100644 plugins/CashShop/AttendanceRewardEnvelope.cs create mode 100644 plugins/CashShop/CashShop.csproj create mode 100644 plugins/CashShop/CommerceCatalog.cs create mode 100644 plugins/CashShop/CommercePayments.cs create mode 100644 plugins/CashShop/Plugin.cs create mode 100644 plugins/CashShop/README.md create mode 100644 plugins/LoginUI/SessionDiagnostics.cs create mode 100644 schedules/cash_shop.bd2schedule create mode 100644 schedules/event_hubs.bd2schedule create mode 100644 schedules/events.bd2schedule create mode 100644 schedules/gacha.bd2schedule create mode 100644 schedules/monster_hunt.bd2schedule create mode 100644 schedules/regular.bd2schedule diff --git a/.gitignore b/.gitignore index 31497dd..f5b4499 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ plugins/**/Directory.Build.props plugins/Directory.Build.local.props # Project notes and local/runtime data are intentionally outside source control -docs/ +docs/* data/ # Release and language build products diff --git a/Dockerfile.server b/Dockerfile.server index 50acca8..c6896c2 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -19,6 +19,7 @@ WORKDIR /app COPY --from=build /out/bd2server /app/bd2server COPY versions.json authentication.json resources.json game.json /app/ COPY go/seed /app/go/seed +COPY schedules /app/schedules RUN mkdir -p /app/data && chown 10001:10001 /app/data USER 10001:10001 diff --git a/Dockerfile.server.dockerignore b/Dockerfile.server.dockerignore index 564c5d7..be567cb 100644 --- a/Dockerfile.server.dockerignore +++ b/Dockerfile.server.dockerignore @@ -5,3 +5,5 @@ !authentication.json !resources.json !game.json +!schedules/ +!schedules/** diff --git a/GAME_CONFIGURATION.md b/GAME_CONFIGURATION.md index 9486540..928c234 100644 --- a/GAME_CONFIGURATION.md +++ b/GAME_CONFIGURATION.md @@ -14,6 +14,13 @@ }, "gacha": { "include_collaboration_ur_weapons": false + }, + "purchases": { + "diamond_recharge": { + "currency": "free", + "gold_per_paid_diamond": 1000, + "diamonds_per_paid_diamond": 1 + } } } ``` @@ -44,6 +51,39 @@ 后续官方若将旧主线与店长篇重构合并为一条剧情线,再重新评估此限制与迁移方案。重构发布日期尚未确认。 +## 现金商品与付费钻石充值 + +所有原本走平台现金支付的普通商品,包括礼包、皮肤、通行证与付费抽卡商品,都固定消耗付费钻石。这类商品没有免费购买配置开关;`purchases.diamond_recharge` 仅设置付费钻石充值的兑换方式。原本已使用游戏内货币的商品仍走原来的游戏内购买流程。 + +普通现金商品的价格由当前版本 GameData 计算:取最小常规充值档位的现金标价与付费钻石数量作为换算基准,将商品现金标价换算为付费钻石并向上取整。当前 2.35.10 的最小档位为现金标价 `2200`、常规付费钻石 `100`,因此普通商品价格为 `ceil(现金标价 / 22)`。运行时读取 GameData 的实际档位,不把该比值固定写入代码。首次充值的加倍奖励不参与换算基准,也不增加充值兑换费用。 + +`purchases.diamond_recharge.currency` 接受下列值: + +| 值 | 付费钻石充值行为 | +|---|---| +| `"free"`(默认) | 不扣货币,发放所选充值商品的原始奖励。 | +| `"gold"` | 每单位常规付费钻石消耗 `gold_per_paid_diamond` 金币。 | +| `"diamonds"` | 每单位常规付费钻石消耗 `diamonds_per_paid_diamond` 普通钻石。 | +| `"ban"` 或 `""` | 禁止购买付费钻石充值商品。 | + +省略该配置分组或 `currency` 时使用 `"free"`;显式写空字符串 `""` 会禁止充值。`null` 仍属于无效配置。兑换倍率必须是 `1` 到 `1000000000` 之间的整数,默认分别为 `1000` 金币与 `1` 普通钻石。即使选中其他兑换方式,两个倍率也必须合法。配置导致任何实际商品单次费用超出游戏钱包支持的整数范围时,服务端拒绝加载该商品目录;多件购买超过范围时拒绝该次购买。 + +例如,使用金币兑换充值: + +```json +"purchases": { + "diamond_recharge": { + "currency": "gold", + "gold_per_paid_diamond": 1000, + "diamonds_per_paid_diamond": 1 + } +} +``` + +充值的兑换费用按常规充值数量计算,实际发放仍遵守所选商品的奖励和首购限制。因此常规数量为 `100` 的首购加倍商品在默认金币倍率下扣 `100000` 金币,保留原来的加倍奖励;后续常规商品扣相同费用。禁止充值不会免费化其他现金商品,也不会删除玩家已有的付费钻石。 + +客户端从服务端 `/client/commerce` 获取商品价格目录。商品由 `group_id`、`product_id`、`sale_group` 三元组识别,Google 与 Apple 平台商品编号均作为别名返回;同一平台编号对应首购与常规变体时仍保留不同商品键。显示价格和购买校验使用同一服务端规则。 + ## 校验与升级 `schema_version` 当前必须为 `1`。字段拼写错误、未知字段、错误类型(例如字符串 `"true"`)、`null`、格式错误或文件末尾多出的 JSON 都会使服务端拒绝启动,并在错误中给出文件路径。省略 `gacha` 或此布尔开关时使用 `false`。 diff --git a/README.md b/README.md index 62b20f0..c3b68ff 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows - 选择并验证 Brown Dust II 安装目录。 - 填写服务器 origin 和端口,自动写入游戏目录下的 `BepInEx/config/bd2.client.json`。 - 应用带原文件备份的客户端入口补丁。 -- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll`、`BD2LoginUI.dll` 及其共享运行时 `BD2.GameNames.dll`。 +- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll`、`BD2LoginUI.dll`、`BD2CashShop.dll` 及其共享运行时 `BD2.GameNames.dll`。 - 选择官方 CDN、服务器资源源或本地已下载资源。 服主同步官方资源、自建静态 CDN 或配置缓存反代的步骤见 [资源与 CDN 配置指南](RESOURCES.md)。 diff --git a/RELEASE.md b/RELEASE.md index 8bb6cde..6b9b330 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -26,7 +26,7 @@ curl --fail --silent --show-error http://127.0.0.1:8080/readyz ``` -5. 让玩家使用独立的 `bd2client.exe` 选择游戏目录、填写服务器地址、选择 CDN,并安装 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。客户端工具说明见 `README.md`(客户端包)或仓库的 `docs/CLIENT.md`。 +5. 让玩家使用独立的 `bd2client.exe` 选择游戏目录、填写服务器地址、选择 CDN,并安装 `BD2LocalIdentity.dll`、`BD2LoginUI.dll` 与 `BD2CashShop.dll`。客户端工具说明见 `README.md`(客户端包)或仓库的 `docs/CLIENT.md`。 `versions.json` 是服务端和客户端插件共用的版本选择,必须与各自可执行文件保持在同一目录。`game_version` 锁定官方游戏兼容版本;`client_version` 与 `server_version` 分别标识客户端工具和纯服务端发布版本,并以 `游戏版本+组件.X.Y.Z` 组合。服务端从包内 `go/seed` 读取种子,不依赖当前工作目录。需要临时测试另一组版本时可传 `serve --version-config <文件>`。玩家存档位于 `data\state\state.db`;服务端以 Go 迁移器按相邻版本升级,并在同一个 SQLite 事务内完成迁移和最终校验,失败时不会留下部分升级。 @@ -34,4 +34,8 @@ 游戏规则使用同目录的 `game.json`,修改后重启服务端生效。默认关闭联动 UR 专武扩展;需要将 14 种联动角色专武加入 UR 必得装备券池时,按 [服务端游戏规则配置](GAME_CONFIGURATION.md) 设置。升级时保留已修改的文件;旧安装缺少文件时使用默认规则。 +排期由项目维护,存放在 `bd2server.exe` 同目录的 `schedules/` 中,使用真正的自定义二进制格式 `.bd2schedule`。服务端启动时读取其中所有排期文件并统一下发。完整服务端包包含同一套排期;排期文件也可以单独分发,无需更新服务端可执行文件。排期更新时停止服务端,用发布的排期文件完整替换旧 `schedules/` 目录,再启动服务端,避免遗留旧文件继续生效。排期文件只适用于发布目录名称标明的游戏版本。 + +维护者仅发布排期时,在仓库根目录运行 `./build-release.ps1 -SchedulesOnly`,独立文件输出到 `.build/bd2schedules-<游戏版本>-<内容哈希>/`,无需打包 ZIP。此命令无需游戏客户端目录,也不会构建服务端、客户端或插件。服主直接安装项目发布的排期文件。格式与维护说明见 [排期数据说明](docs/SCHEDULES.md)。 + OAuth access token 只在客户端内存中;Windows refresh credential 使用当前用户 DPAPI 加密后写入 PlayerPrefs 注册表,macOS 使用 Keychain Services。服务端 `auth.db` 只保存 token、device secret、OAuth state 及 provider subject 的 HMAC,临时可恢复材料使用 AES-256-GCM。 diff --git a/build-release.ps1 b/build-release.ps1 index 22931ec..42a3911 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -1,7 +1,8 @@ [CmdletBinding()] param( [string]$GameDir, - [switch]$SkipTests + [switch]$SkipTests, + [switch]$SchedulesOnly ) $ErrorActionPreference = 'Stop' @@ -18,6 +19,7 @@ $versionConfig = Join-Path $root 'versions.json' $authenticationConfig = Join-Path $root 'authentication.json' $resourceConfig = Join-Path $root 'resources.json' $gameConfig = Join-Path $root 'game.json' +$scheduleDirectory = Join-Path $root 'schedules' try { $releaseVersions = Get-Content -LiteralPath $versionConfig -Raw | ConvertFrom-Json -ErrorAction Stop @@ -27,6 +29,32 @@ try { $serverArchive = Join-Path $buildRoot ("bd2server-{0}-windows-x64.zip" -f $releaseVersions.server_version) $clientArchive = Join-Path $buildRoot ("bd2client-{0}-windows-x64.zip" -f $releaseVersions.client_version) +# Hash both filenames and contents so schedule releases evolve independently of +# the server executable and distinguish additions, removals and renamed files. +$scheduleFiles = @(Get-ChildItem -LiteralPath $scheduleDirectory -File -Filter '*.bd2schedule' | Sort-Object Name) +if ($scheduleFiles.Count -eq 0) { throw "No project schedule files found at $scheduleDirectory" } +$scheduleManifest = ($scheduleFiles | ForEach-Object { + "{0}:{1}" -f $_.Name, (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash +}) -join "`n" +$scheduleHasher = [Security.Cryptography.SHA256]::Create() +try { + $scheduleHash = ([BitConverter]::ToString($scheduleHasher.ComputeHash([Text.Encoding]::UTF8.GetBytes($scheduleManifest)))).Replace('-', '').ToLowerInvariant().Substring(0, 12) +} finally { $scheduleHasher.Dispose() } +$scheduleRelease = Join-Path $buildRoot ("bd2schedules-{0}-{1}" -f $releaseVersions.game_version, $scheduleHash) + +function Write-ScheduleRelease { + New-Item -ItemType Directory -Force -Path $scheduleRelease | Out-Null + foreach ($scheduleFile in $scheduleFiles) { + Copy-Item -LiteralPath $scheduleFile.FullName -Destination $scheduleRelease -Force + } + Write-Host "Built schedule release files: $scheduleRelease" +} + +if ($SchedulesOnly) { + Write-ScheduleRelease + return +} + if ([string]::IsNullOrWhiteSpace($GameDir)) { $developmentConfigPath = Join-Path $goRoot 'config.json' if (-not [IO.File]::Exists($developmentConfigPath)) { @@ -89,7 +117,8 @@ try { $clientPlugins = @( @{ Name = 'LocalIdentity'; Project = Join-Path $root 'plugins\LocalIdentity\LocalIdentity.csproj'; Output = Join-Path $root 'plugins\LocalIdentity\bin\Release\netstandard2.1\BD2LocalIdentity.dll'; FileName = 'BD2LocalIdentity.dll' }, - @{ Name = 'LoginUI'; Project = Join-Path $root 'plugins\LoginUI\LoginUI.csproj'; Output = Join-Path $root 'plugins\LoginUI\bin\Release\netstandard2.1\BD2LoginUI.dll'; FileName = 'BD2LoginUI.dll' } + @{ Name = 'LoginUI'; Project = Join-Path $root 'plugins\LoginUI\LoginUI.csproj'; Output = Join-Path $root 'plugins\LoginUI\bin\Release\netstandard2.1\BD2LoginUI.dll'; FileName = 'BD2LoginUI.dll' }, + @{ Name = 'CashShop'; Project = Join-Path $root 'plugins\CashShop\CashShop.csproj'; Output = Join-Path $root 'plugins\CashShop\bin\Release\netstandard2.1\BD2CashShop.dll'; FileName = 'BD2CashShop.dll' } ) foreach ($plugin in $clientPlugins) { $pluginBuildArgs = @('build', $plugin.Project, '-c', 'Release', "-p:GameDir=$GameDir", '--nologo') @@ -108,6 +137,11 @@ foreach ($plugin in $clientPlugins) { } Copy-Item -LiteralPath (Join-Path $goRoot 'seed') -Destination $serverGoDir -Recurse -Force +$serverScheduleDirectory = Join-Path $serverPackage 'schedules' +New-Item -ItemType Directory -Force -Path $serverScheduleDirectory | Out-Null +foreach ($scheduleFile in $scheduleFiles) { + Copy-Item -LiteralPath $scheduleFile.FullName -Destination $serverScheduleDirectory -Force +} Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $serverPackage 'versions.json') -Force Copy-Item -LiteralPath $authenticationConfig -Destination (Join-Path $serverPackage 'authentication.json') -Force Copy-Item -LiteralPath $resourceConfig -Destination (Join-Path $serverPackage 'resources.json') -Force diff --git a/docs/CLIENT.md b/docs/CLIENT.md new file mode 100644 index 0000000..56add50 --- /dev/null +++ b/docs/CLIENT.md @@ -0,0 +1,75 @@ +# BD2 Client Studio + +BD2 Client Studio 是独立的 Windows / macOS 客户端配置工具,不是服务端,也不包含玩家存档、`auth.db`、OAuth secret 或 master key。Linux 可以运行服务端,但不提供游戏客户端工具。 + +Windows 使用 Wails 和系统 WebView2 Runtime 创建桌面窗口,macOS 使用 Cocoa 和系统 WKWebView。窗口由客户端进程管理;关闭窗口会退出整个工具,退出工具也会关闭窗口。Windows 首次使用前应确保已安装 Microsoft Edge WebView2 Runtime:。 + +当前本机发布脚本只生成 Windows 包。macOS 源码保留,后续找到测试者或公开发布时,将使用 GitHub Actions 的 macOS runner 进行原生构建、测试和 `.app` 打包;目前未完成 macOS 实机验证。 + +启动后会打开配置工作台: + +1. 选择 Brown Dust II 安装目录。Windows 选择包含 `BrownDust II.exe` 的目录;macOS 可选择 `BrownDust II.app` 或它的父目录。 +2. 填写要进入的服务器 origin,例如 `https://play.example.com` 或 `http://127.0.0.1:8080`。 +3. 选择 CDN: + - **官方 CDN**:玩家直接从版本锁定的官方节点下载资源,适合朋友通过 FRP 联机。 + - **本机已下载资源**:选择同时包含 `ServerData` 和 `GameData` 的 CDN 根目录;LI 插件会在游戏进程内启动仅监听 loopback 的只读资源服务。 + - **服主资源**:通过服务器 `PUT /client/resources` 取得版本锁定的资源 URL;服主自建和反代对玩家客户端是同一种模式。 +4. 点击“应用客户端补丁”和“安装 / 更新插件”。 + +工作台的“连接设置”提供“代理 → 设置代理”入口。默认直接连接,不自动读取系统或终端环境中的代理。需要代理时,选择“手动 HTTP 代理”,填写例如 `http://127.0.0.1:12451` 的地址,点击“保存连接设置”,再通过工作台启动游戏。不要填写 Markdown 链接或 SOCKS 地址。 + +代理地址必须包含端口,不包含账号密码、路径或查询参数。HTTP 代理可通过 CONNECT 转发 HTTPS 请求,证书仍正常验证;本机 loopback 地址始终直连。配置同时用于客户端资源策略请求和新启动游戏的 HTTP 请求。修改代理后,需要正常退出游戏再重新启动;浏览器 OAuth 登录仍使用浏览器自身的网络设置。 + +客户端配置写入: + +```text +Windows: <游戏目录>/BepInEx/config/bd2.client.json +macOS: /BepInEx/config/bd2.client.json +``` + +macOS 同时兼容把 BepInEx 放在 `BrownDust II.app/Contents/BepInEx` 的布局;若两个位置都存在,优先使用 App Bundle 外的标准 BepInEx 目录。 + +工具只写入: + +```json +{ + "schema_version": 2, + "server_origin": "https://play.example.com", + "cdn_mode": "official", + "proxy_url": "http://127.0.0.1:12451" +} +``` + +`proxy_url` 可选,省略或留空表示直接连接。 + +`bd2client.exe` 启动时严格读取同目录的 `versions.json`。`game_version` 是支持的官方游戏版本;`client_version` 与 `server_version` 是分别演进的软件发布版本,格式为 `游戏版本+client.X.Y.Z` 与 `游戏版本+server.X.Y.Z`。资源仍由 `bundle_version` 和 `game_data_version` 锁定: + +- 从所选客户端的 `BrownDust II_Data/globalgamemanagers` 读取 Unity `bundleVersion`,必须精确等于 `game_version`。不能使用 `BrownDust II.exe` 的 Windows `FileVersion`,该值是 Unity 引擎版本。 +- 本机资源必须包含当前 `bundle_version` 对应的 `catalog_alpha.json` / `.hash`,以及当前 `game_data_version` 对应的 `common-dbdata.info` / `.bin`。 +- 服主资源接口返回的 Bundle 和 GameData 版本必须分别精确匹配,不能互相替代。 +- `versions.json` 缺失、字段错误、出现未知字段或尾随 JSON 时,客户端工具拒绝启动。 + +本机资源模式会额外保存玩家自己选择的目录: + +```json +{ + "schema_version": 2, + "server_origin": "http://127.0.0.1:8080", + "cdn_mode": "local", + "local_resource_directory": "E:\\bd2\\dl" +} +``` + +本机路径不会发送给服务器。插件不会直接依赖各加载链对 `file://` 的兼容性,而是在游戏进程内通过随机 loopback 端口提供 `GET`、`HEAD` 和单段 Range;该端口不会暴露给局域网。 + +补丁只把 Intro 中的地址替换成固定的本地占位地址,并保留 `resources.assets.bak`。真实服务器地址由 `BD2LocalIdentity.dll` 从 `bd2.client.json` 读取,因此不受 Unity 内置 27-byte URL 替换限制。 + +选择服主资源时,服务器必须在同一版本的 `resources.json` 中启用统一的 `server` 模式;客户端会使用严格的 `PUT /client/resources` 读取版本锁定 URL。客户端不会接受模式不匹配、版本不匹配、非 HTTPS(loopback 除外)或带凭据的资源地址。 + +界面语言按系统时区自动选择:中国大陆、香港、澳门和台湾地区使用简体中文,其他地区使用英文。诊断日志始终使用英文;Windows 保存在 `bd2client.exe` 同目录的 `logs/bd2client.log`,macOS 保存在 `~/Library/Logs/BD2 Client Studio/bd2client.log`。单文件最大 `2 MiB`,并只保留一个 `bd2client.log.1` 备份;日志不会写入 UI session、OAuth token 或 secret。 + +工具需要玩家事先安装 BepInEx。服务端不再安装客户端插件,也不需要知道玩家的 Windows 游戏目录。关闭游戏后再执行补丁或插件安装。 + +补丁与插件安装均可重复安全执行。Intro 已经指向当前占位地址时不会再次改写 `resources.assets`;两个已安装 DLL 与发布包内容一致时不会再次复制,并会明确提示已经完成或已经是最新版本。连接配置仍会按当前界面设置进行原子保存。 + +工作台右上角的“启动游戏”会再次验证所选客户端及精确版本,然后通过 Windows 进程启动或 macOS Launch Services 打开游戏。macOS 不会打开 Terminal。 diff --git a/docs/SCHEDULES.md b/docs/SCHEDULES.md new file mode 100644 index 0000000..3479063 --- /dev/null +++ b/docs/SCHEDULES.md @@ -0,0 +1,48 @@ +# 项目排期文件 + +服务端从发布目录中的固定 `schedules/` 目录读取全部 `.bd2schedule` 文件,完整校验后合并下发排期。文件采用项目自定义二进制格式;没有 JSON 排期入口或服主自定义配置功能。项目维护者可在游戏版本不变时单独发布排期文件,覆盖相应文件后重启服务端加载。文件中的游戏和 GameData 版本标签表示协议、静态设计兼容性,排期 `revision` 独立表示日历修订。 + +## 二进制格式版本 1 + +整数使用小端编码。文件头固定 46 字节: + +| 偏移 | 长度 | 内容 | +| --- | --- | --- | +| 0 | 8 | 魔数 `BD2SCH` 后接两个零字节 | +| 8 | 2 | uint16 格式版本,当前为 1 | +| 10 | 4 | uint32 payload 字节数 | +| 14 | 32 | payload 的 SHA-256 原始摘要 | +| 46 | payload 长度 | 按固定顺序编码的记录 | + +payload 首先编码 revision、game version、GameData version 三个字符串,再依次编码事件列表、抽卡列表、StepUp 列表、可选常规内容日历、可选魔物追踪日历、商品列表、活动 Hub 列表、小游戏 Hub 列表。 + +字符串编码为 uint32 字节长度及 UTF-8 字节;列表编码为 uint32 记录数量及逐条记录;可选记录先写 0/1 存在标记;布尔值只允许单字节 0 或 1;身份、数量等整数字段编码为 uint64。每类记录采用显式固定字段顺序,权威定义为 `go/internal/server/calendar/records.go`,不使用反射或嵌入 JSON。单文件最多 8 MiB,单字符串最多 64 KiB,记录总量最多 100000;错误魔数、版本、摘要、长度、布尔值及尾随内容都拒绝加载。 + +记录内时间采用 RFC3339 UTC 字符串并保留毫秒,以保留跨年份和永久窗口语义。永久事件起点 `1969-12-31T15:00:00.000Z` 对应协议有符号 int64 的 `-32400000` 毫秒。它不是无符号巨大日期。二进制校验用于发现文件损坏,不代表密码签名。 + +项目维护者在代码中构建具名 `calendar.Manifest` 并调用 `calendar.MarshalBinary` 生成发行文件;读取使用 `calendar.UnmarshalBinary`。项目不提供从公开 JSON 输入生成排期的服主管理命令。发布时保留所有要下发的排期文件;运行时不从版本化 seed 或抓包补齐缺失数据。 + +## 初始数据来源 + +当前 `local-20261005-1` 保留已有时间、编号和协议标记,没有推算后续官方日程: + +| 文件 | 初始记录 | 原始来源 | +| --- | --- | --- | +| `gacha.bd2schedule` | 普通抽卡 11,StepUp 2 | 已迁移删除的 `go/seed/v2_35_10/gacha_schedule.json` | +| `regular.bd2schedule` | 常规内容 9,regular 标记 5 | 已迁移删除的 `go/seed/v2_35_10/schedule.json` | +| `monster_hunt.bd2schedule` | 魔物追踪窗口 1,历史 78 | 原 `go/seed/v2_35_10/readonly.json` 的 `/MonsterHuntScheduleInfo` | +| `cash_shop.bd2schedule` | 商品窗口 37 | 原 readonly seed 的 `/CashShopInfo` 商品行 | +| `event_hubs.bd2schedule` | 活动 Hub 2,小游戏 slot 6 | 原 readonly seed 的 `/EventHubInfo`、`/MiniGameHubInfo` | +| `events.bd2schedule` | 事件排期 54 | `data/capture/2.35.10/20261001-004259/bodies/000004_response_EventScheduleInfoResponse.pb` | + +事件文件将以上同版本响应的既有身份和起止时间维护为项目日历;捕获的 `is_active` 不复制,由服务端根据当前时间重新计算。来源仅用于审计,运行时不读取抓包。UID 为 0 的多条任务以类型和设计组 ID 区分,不制造公开 UID。任务进度和领奖也使用对应组的独立本地身份。 + +Hub 的 UID 是 Hub 身份;slot 内的 UID 按内容类型引用事件、魔物追踪赛季等不同领域。例如 MonsterHunt slot 引用赛季 77/78。旧 Hub 74 的原始时间和子身份作为归档保留,当前捕获事件列表主要对应 Hub 75;不据此虚构已结束 Hub 的子事件窗口。Hub 的 play_end 与 end 独立保留,setting 可以引用多个 UID。 + +事件类型 8 的 ID 引用 `PackTable.Id`,依据当前客户端 `PackInfo.IsProgressUseScheduleEventPack` 和 `IsFinishEventPackSchedule`;它与 Hub 的 `PackEventHubTable.id`、`packId` 分属不同身份。小游戏 Hub 的 progress_type 表示普通、首次开放或复刻状态,不等于静态小游戏的 eventClearType。 + +商品每日、每周、每月重置时间由运行时根据当前时间生成。`SkyWayScheduleInfo` 的 weekday/bonus 规则仍属于静态 seed,`PassInfo` 和 `SeasonRewardInfo` 属于玩家或奖励域。 + +## 更新和验证 + +排期更新由项目维护,相关文件的 revision 随数据修订更新。仅调整时间无需修改游戏版本;引用不同静态设计时必须更新相应版本标签并校验实际 GameData。加载同时校验跨文件重复身份、时间窗口和可玩设计引用,全部通过才下发。事件类型 0、1、4、5、7、8、9、10、11、12、13、17、19 和 20 至 25 校验对应 GameData;类型 3、6、14、15、16、18 当前仅校验协议范围和格式。魔物追踪可玩窗口校验实际 hunt 设计,归档历史仅校验显示记录格式;小游戏 Hub 的 progress_type 校验状态格式。公共日历保留未来、当前和归档记录,玩法入口及进行中标记根据时间和玩家状态限制。 diff --git a/game.json b/game.json index 4a69b02..ebee8b9 100644 --- a/game.json +++ b/game.json @@ -5,5 +5,12 @@ }, "gacha": { "include_collaboration_ur_weapons": false + }, + "purchases": { + "diamond_recharge": { + "currency": "free", + "gold_per_paid_diamond": 1000, + "diamonds_per_paid_diamond": 1 + } } } diff --git a/go/cmd/bd2client/dev_command.go b/go/cmd/bd2client/dev_command.go index 32f275d..40f91e3 100644 --- a/go/cmd/bd2client/dev_command.go +++ b/go/cmd/bd2client/dev_command.go @@ -88,6 +88,7 @@ func developmentRunOptions(args []string) ([]string, clientRunOptions, error) { logExecutablePath: filepath.Join(root, "data", "bd2client-dev"), localIdentityPlugin: filepath.Join(root, "plugins", "LocalIdentity", "bin", "Release", "netstandard2.1", "BD2LocalIdentity.dll"), loginUIPlugin: filepath.Join(root, "plugins", "LoginUI", "bin", "Release", "netstandard2.1", "BD2LoginUI.dll"), + cashShopPlugin: filepath.Join(root, "plugins", "CashShop", "bin", "Release", "netstandard2.1", "BD2CashShop.dll"), }, nil } @@ -176,6 +177,7 @@ func buildDevelopmentPlugins(root, gameDir string) error { projects := []string{ filepath.Join(root, "plugins", "LocalIdentity", "LocalIdentity.csproj"), filepath.Join(root, "plugins", "LoginUI", "LoginUI.csproj"), + filepath.Join(root, "plugins", "CashShop", "CashShop.csproj"), } for _, project := range projects { fmt.Fprintf(os.Stderr, "bd2client: preparing development plugin %s; first-time game source generation may take several minutes\n", filepath.Base(project)) diff --git a/go/cmd/bd2client/dev_command_test.go b/go/cmd/bd2client/dev_command_test.go index 1f3fdce..c12dfb5 100644 --- a/go/cmd/bd2client/dev_command_test.go +++ b/go/cmd/bd2client/dev_command_test.go @@ -94,6 +94,7 @@ func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) { "log executable": options.logExecutablePath, "local identity": options.localIdentityPlugin, "login UI": options.loginUIPlugin, + "cash shop": options.cashShopPlugin, } { if !filepath.IsAbs(path) { t.Errorf("%s path is not absolute: %q", name, path) diff --git a/go/cmd/bd2client/dev_sdk_integration_test.go b/go/cmd/bd2client/dev_sdk_integration_test.go index f98f9a3..916d0e2 100644 --- a/go/cmd/bd2client/dev_sdk_integration_test.go +++ b/go/cmd/bd2client/dev_sdk_integration_test.go @@ -20,7 +20,7 @@ func TestDevelopmentRunBuildsPluginsWithEmbeddedNames(t *testing.T) { if len(args) != 0 { t.Fatalf("unexpected runtime arguments: %v", args) } - for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin} { + for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin, options.cashShopPlugin} { if !clientDevelopmentFile(path) { t.Fatalf("development plugin output missing: %s", path) } diff --git a/go/cmd/bd2client/main.go b/go/cmd/bd2client/main.go index 478107c..b9fdecb 100644 --- a/go/cmd/bd2client/main.go +++ b/go/cmd/bd2client/main.go @@ -14,6 +14,7 @@ type clientRunOptions struct { logExecutablePath string localIdentityPlugin string loginUIPlugin string + cashShopPlugin string } func main() { @@ -80,6 +81,7 @@ func runClient(args []string, options clientRunOptions) error { Versions: versions, LocalIdentityPlugin: options.localIdentityPlugin, LoginUIPlugin: options.loginUIPlugin, + CashShopPlugin: options.cashShopPlugin, }); err != nil { logger.Error("bd2client stopped with an error", "error", err) return err diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 7292171..31cf0e9 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -21,7 +21,15 @@ import ( "bd2server/internal/server/authconfig" "bd2server/internal/server/battle" "bd2server/internal/server/bootstrap" + "bd2server/internal/server/calendar" + "bd2server/internal/server/commerce" "bd2server/internal/server/deck" + "bd2server/internal/server/eventactions" + "bd2server/internal/server/eventexchange" + "bd2server/internal/server/eventgames" + "bd2server/internal/server/eventplay" + "bd2server/internal/server/events" + "bd2server/internal/server/eventtasks" "bd2server/internal/server/feature" "bd2server/internal/server/gacha" "bd2server/internal/server/gameconfig" @@ -37,7 +45,6 @@ import ( "bd2server/internal/server/readonly" "bd2server/internal/server/resourcefetch" "bd2server/internal/server/resourcepolicy" - "bd2server/internal/server/schedule" "bd2server/internal/server/session" "bd2server/internal/server/stateio" "bd2server/internal/server/transport" @@ -98,14 +105,12 @@ func serve(args []string) (serveErr error) { gameDataOrigin := fs.String("game-data-origin", resourcepolicy.OfficialGameDataURL, "official GameData repair source override for development") accountSeed := fs.String("account-seed", "", "versioned local account seed") playerSeed := fs.String("player-seed", "", "versioned starter inventory and characters") - readonlySeed := fs.String("readonly-seed", "", "versioned server schedules and optional feature defaults") + readonlySeed := fs.String("readonly-seed", "", "versioned static protocol defaults") mailSeed := fs.String("mail-seed", "", "versioned starter mailbox") mailGrantSpool := fs.String("mail-grant-spool", "", "optional local JSON spool for idempotent dynamic system mail") stateFile := fs.String("state", "", "account SQLite database override") deckSeed := fs.String("deck-seed", "", "versioned starter deck") worldSeed := fs.String("world-seed", "", "versioned starter world") - gachaScheduleSeed := fs.String("gacha-schedule-seed", "", "versioned dynamic gacha schedule") - seasonScheduleSeed := fs.String("schedule-seed", "", "versioned server content calendar") devToolsConfig := fs.String("dev-tools-config", "", "development-tool settings JSON (defaults to DATA_DIR/dev-tools.json)") if err := fs.Parse(args); err != nil { return err @@ -113,6 +118,11 @@ func serve(args []string) (serveErr error) { if err := configureLogging(os.Stderr, *logLevel, *logColor); err != nil { return err } + defer func() { + if err := gamedata.CloseDatabaseCache(); err != nil { + serveErr = errors.Join(serveErr, fmt.Errorf("close GameData query cache: %w", err)) + } + }() var versions versionconfig.Config var err error if *versionConfigPath == "" { @@ -134,6 +144,15 @@ func serve(args []string) (serveErr error) { } } versionconfig.Use(versions) + calendarDirectory := versions.Resolve("schedules") + calendars, err := calendar.LoadDirectory(calendarDirectory, versions.GameVersion, versions.GameDataVersion) + if err != nil { + return fmt.Errorf("load project calendars: %w", err) + } + if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 { + return errors.New("project calendars require regular content and monster hunt schedules") + } + slog.Info("project calendars loaded", "directory", calendarDirectory, "revisions", calendars.Revisions, "events", len(calendars.Events)) if *gameConfigPath == "" { *gameConfigPath, err = gameconfig.BesideExecutable() if err != nil { @@ -191,8 +210,7 @@ func serve(args []string) (serveErr error) { seedRoot := versions.Resolve(versions.SeedDirectory) for target, name := range map[*string]string{ accountSeed: "login_user.json", playerSeed: "starter_player.json", readonlySeed: "readonly.json", - mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json", gachaScheduleSeed: "gacha_schedule.json", - seasonScheduleSeed: "schedule.json", + mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json", } { if *target == "" { *target = filepath.Join(seedRoot, name) @@ -222,6 +240,9 @@ func serve(args []string) (serveErr error) { if downloaded { slog.Info("repaired GameData from official CDN", "archive", verifiedGameData.ArchivePath, "entries", verifiedGameData.EntryCount) } + if err := calendars.ValidateDesign(gameData, *gameDataVersion); err != nil { + return fmt.Errorf("validate project calendar GameData references: %w", err) + } login, err := account.Load(filepath.Clean(*accountSeed)) if err != nil { return fmt.Errorf("load local account seed: %w", err) @@ -233,10 +254,7 @@ func serve(args []string) (serveErr error) { if login.Version != versions.GameVersion || starter.Version != versions.GameVersion { return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", versions.GameVersion, login.Version, starter.Version) } - gachaSchedule, err := gacha.LoadScheduleSeed(filepath.Clean(*gachaScheduleSeed), versions.GameVersion) - if err != nil { - return fmt.Errorf("load gacha schedule: %w", err) - } + gachaSchedule := calendars.GachaSeed var scheduleGroupIDs, stepUpGroupIDs []uint64 for _, window := range gachaSchedule.Schedules { scheduleGroupIDs = append(scheduleGroupIDs, window.GroupID) @@ -311,10 +329,11 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("load readonly server configuration: %w", err) } - seasonSchedule, err := schedule.Load(filepath.Clean(*seasonScheduleSeed)) + serverConfig, err = calendars.ApplyReadonly(serverConfig) if err != nil { - return fmt.Errorf("load server content calendar: %w", err) + return fmt.Errorf("encode project calendars: %w", err) } + seasonSchedule := calendars.RegularService mailbox, err := mail.Load(filepath.Clean(*mailSeed)) if err != nil { return fmt.Errorf("load starter mailbox: %w", err) @@ -659,7 +678,266 @@ func serve(args []string) (serveErr error) { if err := login.AttachHuntingAP(huntingService); err != nil { return fmt.Errorf("attach persisted hunting AP: %w", err) } + huntingAPDesign, err := gamedata.LoadHuntingAPDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load hunting AP reset: %w", err) + } + if err = huntingService.AttachAPRefresh(huntingAPDesign); err != nil { + return err + } battleService.AttachHunting(huntingService) + huntingService.AttachEligibility(worldService.HuntingEligibility) + if err := worldService.AttachHuntingGround(huntingService); err != nil { + return err + } + eventRegistry := events.NewRegistry() + if err := eventRegistry.Replace(calendars.Events); err != nil { + return err + } + rewardGraph, err := gamedata.LoadRewardGraph(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load event reward graph: %w", err) + } + rewardEquipment, err := gamedata.LoadRewardEquipmentCatalog(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load reward equipment: %w", err) + } + rewardCostumes, err := gamedata.LoadRewardCostumeCatalog(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load reward costumes: %w", err) + } + initialEventCurrency := map[uint64]uint64{} + for itemType, field := range events.AdditionalCurrencyFields { + value, _, readErr := wire.Varint(login.UserInfo, field) + if readErr != nil { + return readErr + } + initialEventCurrency[itemType] = value + } + eventEconomy, err := events.NewEconomy(gameplayStore, ownedItems, wallet, collection, ownedEquipment, rewardCostumes, rewardEquipment, rewardGraph, initialEventCurrency) + if err != nil { + return fmt.Errorf("load event economy: %w", err) + } + eventEconomy.AttachHuntingAP(huntingService) + if err = worldService.AttachResearchRuntime(gameData, *gameDataVersion, eventEconomy); err != nil { + return fmt.Errorf("attach field research: %w", err) + } + prestigeSkins, err := gamedata.LoadPrestigeSkins(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load reward prestige skins: %w", err) + } + eventEconomy.AttachPrestigeSkins(prestigeSkins) + ownedEventItems, err := gamedata.LoadOwnedEventItemDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load event inventory design: %w", err) + } + eventEconomy.AttachOwnedItemDesign(ownedEventItems) + eventAPCaps, eventAPReset, err := gamedata.LoadEventAPDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load event AP reset: %w", err) + } + if err = eventEconomy.AttachAPRefresh(eventAPCaps, eventAPReset); err != nil { + return err + } + if err = login.AttachAdditionalCurrencies(eventEconomy); err != nil { + return err + } + huntingService.AttachRewards(func(identity string, rewards []gamedata.Reward) ([]byte, error) { + return eventEconomy.Apply(identity, nil, rewards) + }) + cashDesign, err := gamedata.LoadCashCatalog(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load cash products: %w", err) + } + cashCatalog, err := commerce.NewCatalog(versions.GameVersion, cashDesign, gameRules.Purchases) + if err != nil { + return fmt.Errorf("configure cash products: %w", err) + } + cashEntitlementDesign, err := gamedata.LoadCashEntitlementDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load cash entitlement design: %w", err) + } + cashRewards, err := gamedata.LoadCashRewardResolver(gameData, *gameDataVersion, rewardGraph) + if err != nil { + return fmt.Errorf("load cash product rewards: %w", err) + } + cashEconomy, err := commerce.NewEntitlementEconomy(gameplayStore, eventEconomy, cashRewards, ownedItems, cashEntitlementDesign) + if err != nil { + return fmt.Errorf("load cash entitlements: %w", err) + } + cashEconomy.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600) + cashService, err := commerce.NewService(cashCatalog, gameplayStore, cashEconomy) + if err != nil { + return fmt.Errorf("load cash purchase state: %w", err) + } + clearPackageDesign, err := gamedata.LoadClearPackageCatalog(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load clear-package rewards: %w", err) + } + clearPackages, err := commerce.NewClearPackages(gameplayStore, clearPackageDesign, cashEconomy, ownedItems) + if err != nil { + return fmt.Errorf("load clear-package claims: %w", err) + } + clearPackages.AttachProgress(worldService.CashPackagePackCleared, nil) + cashService.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600) + if err := cashService.AttachPackageRules(cashDesign.Packages); err != nil { + return fmt.Errorf("attach cash package progression: %w", err) + } + if err := cashService.AttachShopSeed(serverConfig); err != nil { + return fmt.Errorf("attach cash product availability: %w", err) + } + cashService.AttachLegacyCounts(gachaService) + cashService.AttachDelegate(func(key gamedata.CashProductKey, request []byte) ([]byte, bool, error) { + known := key.GroupID == infiniteGacha.ProductGroupID && key.ProductID == infiniteGacha.ProductID && key.SaleGroup == infiniteGacha.SaleGroup + for _, group := range regularGacha.Groups() { + if key.GroupID == group.CashProductGroupID && key.ProductID == group.CashProductID && key.SaleGroup == group.CashSalesGroup { + known = true + break + } + } + if !known { + return nil, false, nil + } + _, response, handled, err := gachaService.Handle("/CashShopBuy", request) + if err != nil || !handled { + return nil, handled, err + } + bundle, _, err := wire.Bytes(response, 1) + return bundle, true, err + }) + if err := login.AttachPurchaseCounts(cashService); err != nil { + return fmt.Errorf("attach cash purchase counts: %w", err) + } + eventTasksDesign, err := gamedata.LoadEventTasksDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load event tasks design: %w", err) + } + eventTasksService, err := eventtasks.Open(gameplayStore, eventTasksDesign, eventRegistry, eventEconomy) + if err != nil { + return fmt.Errorf("load event tasks state: %w", err) + } + newbieStep, _, err := wire.Varint(login.UserInfo, 39) + if err != nil { + return err + } + if err = eventTasksService.SetNewbieStep(newbieStep); err != nil { + return err + } + if err = login.AttachNewbieStep(eventTasksService); err != nil { + return err + } + eventTasksService.AttachCashAuthorization(func(passID, buyType uint64) bool { + for _, buy := range eventTasksDesign.PassBuys[passID] { + if buy.Type == buyType && buy.CashID != 0 { + return cashService.ConsumeEntitlement(gamedata.CashProductKey{GroupID: buy.CashGroup, ProductID: buy.CashID, SaleGroup: buy.CashSales}) + } + } + return false + }) + eventTasksService.AttachAttendancePremium(func(ticket uint64) bool { + for _, item := range ownedItems.All() { + if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(time.Now().UnixMilli())) { + return true + } + } + return false + }) + loginPassDesign, err := gamedata.LoadLoginPassCatalog(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load login-pass rewards: %w", err) + } + loginPasses, err := commerce.NewLoginPasses(gameplayStore, loginPassDesign, cashEconomy, ownedItems, func(group uint64) bool { + for _, pack := range cashDesign.Packages { + if pack.PackageType == 7 && pack.ID == group && cashService.IsAvailable(gamedata.CashProductKey{GroupID: pack.GroupID, ProductID: pack.ID, SaleGroup: pack.SaleGroup}) { + return true + } + } + return false + }) + if err != nil { + return fmt.Errorf("load login-pass progress: %w", err) + } + loginPasses.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600) + eventTasksService.AttachUnlockResolver(missionUnlocked) + missionService.AttachEventHandler(eventTasksService) + if err = missionService.RecordLogin(missionUnlocked); err != nil { + return err + } + eventGamesService, err := eventgames.Open(gameplayStore, gameData, *gameDataVersion, eventRegistry, eventEconomy) + if err != nil { + return fmt.Errorf("load event games state: %w", err) + } + eventExchangeDesign, err := gamedata.LoadEventExchangeCatalog(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load event exchange design: %w", err) + } + eventExchangeService, err := eventexchange.Open(gameplayStore, eventExchangeDesign, eventRegistry, eventEconomy) + if err != nil { + return fmt.Errorf("load event exchange state: %w", err) + } + boxService, err := events.OpenBoxes(gameplayStore, ownedItems, eventEconomy) + if err != nil { + return fmt.Errorf("load random box state: %w", err) + } + eventPlayService, err := eventplay.Open(gameplayStore, gameData, *gameDataVersion, eventRegistry, eventEconomy) + if err != nil { + return fmt.Errorf("load event play state: %w", err) + } + eventBattleChallenges, err := gamedata.LoadEventBattleChallenges(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load event battle challenges: %w", err) + } + eventPlayService.AttachBattleChallenges(eventBattleChallenges) + eventPlayService.AttachHubCalendars(serverConfig) + battleService.AttachEventBattle(eventPlayService) + eventActionsDesign, err := gamedata.LoadEventActionsDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load event action design: %w", err) + } + eventActionsService, err := eventactions.Open(gameplayStore, eventActionsDesign, eventRegistry, eventEconomy) + if err != nil { + return fmt.Errorf("load event action state: %w", err) + } + eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 { + for _, entry := range collection.FriendshipEntries() { + if entry.State != nil && entry.State.CostumeID == id { + return entry.State.Level + } + } + return 0 + }) + eventActionsService.AttachOwnedCharacter(func(index, id uint64) bool { + c, ok := worldService.CharacterService().Find(index) + return ok && c.ID == id + }) + eventActionsService.AttachChargeInfo(func() ([]byte, error) { + rows, err := eventEconomy.ChargeInfo() + if err != nil { + return nil, err + } + huntingRows, err := huntingService.APChargeInfo() + if err != nil { + return nil, err + } + return append(rows, huntingRows...), nil + }) + eventActionsService.AttachProgress(func(condition, sub, count uint64) error { + return missionService.RecordEvent(condition, sub, count, missionUnlocked) + }) + eventPlayService.AttachProgress(func(condition, sub, count uint64) error { + return missionService.RecordEvent(condition, sub, count, missionUnlocked) + }) + eventTasksService.AttachAssociatedMissionGroup(func(schedule events.Schedule) uint64 { + if group := eventActionsService.AssociatedMissionGroup(schedule); group != 0 { + return group + } + group, err := eventPlayService.AssociatedMissionGroup(schedule) + if err != nil { + slog.Error("event mission design unavailable", "event_uid", schedule.UID, "event_id", schedule.ID, "error", err) + } + return group + }) + battleService.AttachEventBattle(eventActionsService) battleService.AttachCurrentDifficulty(worldService.CurrentQuestDifficulty) if err := worldService.AttachBattleActive(battleService.Active); err != nil { return fmt.Errorf("attach world battle guard: %w", err) @@ -676,6 +954,9 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("attach monster hunt preset slots: %w", err) } battleService.AttachMonsterHunt(monsterHuntService) + monsterHuntService.AttachRewards(func(identity string, rewards []gamedata.Reward) ([]byte, error) { + return eventEconomy.Apply(identity, nil, rewards) + }) recruitDesign, err := gamedata.LoadRecruitDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load recruitment GameData: %w", err) @@ -750,7 +1031,19 @@ func serve(args []string) (serveErr error) { if err := achievementObserver.SyncRecordedHistory(); err != nil { return fmt.Errorf("restore recorded achievement history: %w", err) } + eventTasksService.AttachGameplayProvider(worldService.GameplayAchievementProvider(achievementCounterDesign, achievementGrades)) game, err := session.NewServerWithProgress(login, progressState, + cashService, + clearPackages, + commerce.PackInfoHandler{World: worldService, Claims: clearPackages}, + commerce.AttendanceHandler{Events: eventTasksService, Economy: cashEconomy, LoginPasses: loginPasses, Store: gameplayStore}, + eventRegistry, + eventGamesService, + eventExchangeService, + boxService, + eventPlayService, + eventActionsService, + events.SkinHandler{Economy: eventEconomy}, battleService, huntingService, monsterHuntService, @@ -774,6 +1067,7 @@ func serve(args []string) (serveErr error) { gachaService, achievementCounters, missionService, + eventTasksService, pictorialService, seasonSchedule, readonly.Service{Seed: serverConfig}, @@ -785,6 +1079,9 @@ func serve(args []string) (serveErr error) { if err := game.AttachResponseObserver(achievementObserver); err != nil { return fmt.Errorf("attach achievement progress notifications: %w", err) } + if err := game.AttachResponseObserver(eventTasksService); err != nil { + return err + } if authService != nil { if err := game.AttachLoginAuthenticator(authService); err != nil { return err @@ -844,7 +1141,8 @@ func serve(args []string) (serveErr error) { instanceID := hex.EncodeToString(instanceBytes) handler := transport.HTTP{ Dispatcher: dispatcher, Raw: game, Authentication: authentication, - AuthenticationHandler: authHandler, ResourcePolicy: publicResources, Availability: availability, InstanceID: instanceID, + AuthenticationHandler: authHandler, ResourcePolicy: publicResources, + CommerceManifest: func() any { return cashCatalog.Manifest() }, Availability: availability, InstanceID: instanceID, }.Handler() server := &http.Server{ Addr: *listen, diff --git a/go/cmd/bd2server/preflight.go b/go/cmd/bd2server/preflight.go index e2ff0ab..e52fa59 100644 --- a/go/cmd/bd2server/preflight.go +++ b/go/cmd/bd2server/preflight.go @@ -9,15 +9,14 @@ import ( "bd2server/internal/server/account" "bd2server/internal/server/authconfig" + "bd2server/internal/server/calendar" "bd2server/internal/server/deck" - "bd2server/internal/server/gacha" "bd2server/internal/server/gameconfig" "bd2server/internal/server/gamedata" "bd2server/internal/server/mail" "bd2server/internal/server/player" "bd2server/internal/server/readonly" "bd2server/internal/server/resourcepolicy" - calendarschedule "bd2server/internal/server/schedule" "bd2server/internal/server/versionconfig" ) @@ -105,16 +104,24 @@ func preflight(args []string) error { if _, err := deck.LoadSeed(filepath.Join(seedRoot, "decks.json")); err != nil { return err } - if _, err := readonly.Load(filepath.Join(seedRoot, "readonly.json")); err != nil { - return err - } - if _, err := calendarschedule.Load(filepath.Join(seedRoot, "schedule.json")); err != nil { - return fmt.Errorf("load server content calendar: %w", err) - } - schedule, err := gacha.LoadScheduleSeed(filepath.Join(seedRoot, "gacha_schedule.json"), versions.GameVersion) + defaults, err := readonly.Load(filepath.Join(seedRoot, "readonly.json")) if err != nil { return err } + calendars, err := calendar.LoadDirectory(versions.Resolve("schedules"), versions.GameVersion, versions.GameDataVersion) + if err != nil { + return err + } + if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 { + return fmt.Errorf("project calendars require regular content and monster hunt schedules") + } + if _, err := calendars.ApplyReadonly(defaults); err != nil { + return err + } + if err := calendars.ValidateDesign(gameData, versions.GameDataVersion); err != nil { + return fmt.Errorf("preflight project calendar GameData references: %w", err) + } + schedule := calendars.GachaSeed var groups, steps []uint64 for _, window := range schedule.Schedules { groups = append(groups, window.GroupID) diff --git a/go/internal/client/app/app.go b/go/internal/client/app/app.go index 1cfbbd1..4d08a75 100644 --- a/go/internal/client/app/app.go +++ b/go/internal/client/app/app.go @@ -30,6 +30,7 @@ type Options struct { Versions clientconfig.ReleaseVersions LocalIdentityPlugin string LoginUIPlugin string + CashShopPlugin string } type Request struct { @@ -266,7 +267,7 @@ func (s *Studio) Patch(input Request) (Response, error) { func (s *Studio) Install(input Request) (Response, error) { return s.perform("install plugins", func() (Response, error) { - result, err := clientsetup.InstallPlugins(input.GameDirectory, input.settings(), s.options.Versions, s.options.LocalIdentityPlugin, s.options.LoginUIPlugin) + result, err := clientsetup.InstallPlugins(input.GameDirectory, input.settings(), s.options.Versions, s.options.LocalIdentityPlugin, s.options.LoginUIPlugin, s.options.CashShopPlugin) if err != nil { return Response{}, err } @@ -274,7 +275,7 @@ func (s *Studio) Install(input Request) (Response, error) { return Response{}, err } message := "BD2 client plugins installed or updated" - if !result.LocalIdentity.Changed && !result.LoginUI.Changed { + if !result.LocalIdentity.Changed && !result.LoginUI.Changed && !result.CashShop.Changed { message = "BD2 client plugins are already up to date; no DLL was rewritten" } return success(message, result), nil @@ -312,7 +313,7 @@ func (s *Studio) Launch(input Request) (Response, error) { if !installation.SupportedOnHost() { return Response{}, fmt.Errorf("cannot launch a %s game client from this operating system", installation.Kind) } - for _, name := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll"} { + for _, name := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll", "BD2CashShop.dll"} { info, err := os.Stat(filepath.Join(installation.Plugins, name)) if err != nil || !info.Mode().IsRegular() { return Response{}, fmt.Errorf("install or update the client plugins before launching; %s is missing", name) diff --git a/go/internal/client/config/release.go b/go/internal/client/config/release.go index 2d54a47..ad92720 100644 --- a/go/internal/client/config/release.go +++ b/go/internal/client/config/release.go @@ -33,6 +33,7 @@ type ReleaseVersions struct { LocalIdentity string `json:"local_identity"` CaptureEnvironment string `json:"capture_environment"` LoginUI string `json:"login_ui"` + CashShop string `json:"cash_shop"` } `json:"plugins"` } diff --git a/go/internal/client/plugin/install.go b/go/internal/client/plugin/install.go index 6644205..71923f9 100644 --- a/go/internal/client/plugin/install.go +++ b/go/internal/client/plugin/install.go @@ -23,6 +23,7 @@ type Spec struct { var ( LocalIdentity = Spec{fileName: "BD2LocalIdentity.dll"} LoginUI = Spec{fileName: "BD2LoginUI.dll"} + CashShop = Spec{fileName: "BD2CashShop.dll"} GameNames = Spec{fileName: "BD2.GameNames.dll"} ) @@ -84,7 +85,7 @@ func Install(spec Spec, gameDir, source string) (Result, error) { // Read both artifacts before touching the installation. The runtime table // library is shipped beside every plugin, including explicit development paths. var runtimeData []byte - if spec == LocalIdentity || spec == LoginUI { + if spec == LocalIdentity || spec == LoginUI || spec == CashShop { runtimeSource := filepath.Join(filepath.Dir(source), GameNames.fileName) runtimeData, err = os.ReadFile(runtimeSource) if err != nil { diff --git a/go/internal/client/plugin/install_test.go b/go/internal/client/plugin/install_test.go index 4c4e695..d942363 100644 --- a/go/internal/client/plugin/install_test.go +++ b/go/internal/client/plugin/install_test.go @@ -8,7 +8,7 @@ import ( ) func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) { - for _, spec := range []Spec{LocalIdentity, LoginUI} { + for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} { t.Run(spec.FileName(), func(t *testing.T) { gameDir := t.TempDir() for path, data := range map[string][]byte{ @@ -41,7 +41,7 @@ func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) { } func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) { - for _, spec := range []Spec{LocalIdentity, LoginUI} { + for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} { t.Run(spec.FileName(), func(t *testing.T) { gameDir := t.TempDir() for path, data := range map[string][]byte{ @@ -140,7 +140,7 @@ func TestInstallKeepsPluginsSeparate(t *testing.T) { t.Fatal(err) } } - for _, spec := range []Spec{LocalIdentity, LoginUI} { + for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} { source := filepath.Join(t.TempDir(), spec.FileName()) if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil { t.Fatal(err) @@ -152,7 +152,7 @@ func TestInstallKeepsPluginsSeparate(t *testing.T) { t.Fatal(err) } } - for _, spec := range []Spec{LocalIdentity, LoginUI} { + for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} { path := filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName()) data, err := os.ReadFile(path) if err != nil || string(data) != spec.FileName() { diff --git a/go/internal/client/setup/setup.go b/go/internal/client/setup/setup.go index eee3174..09f6b0a 100644 --- a/go/internal/client/setup/setup.go +++ b/go/internal/client/setup/setup.go @@ -50,6 +50,7 @@ type ResourcePolicy struct { type InstallResult struct { LocalIdentity plugin.Result `json:"local_identity"` LoginUI plugin.Result `json:"login_ui"` + CashShop plugin.Result `json:"cash_shop"` } func Inspect(gameDir string, versions clientconfig.ReleaseVersions) (GameStatus, error) { @@ -117,6 +118,7 @@ func InstallPlugins( versions clientconfig.ReleaseVersions, localIdentitySource string, loginUISource string, + cashShopSource string, ) (InstallResult, error) { status, err := Inspect(gameDir, versions) if err != nil { @@ -136,6 +138,10 @@ func InstallPlugins( if err != nil { return InstallResult{}, err } + cashSource, err := plugin.ResolvePackaged(plugin.CashShop, cashShopSource) + if err != nil { + return InstallResult{}, err + } local, err := plugin.Install(plugin.LocalIdentity, gameDir, localSource) if err != nil { return InstallResult{}, err @@ -144,7 +150,11 @@ func InstallPlugins( if err != nil { return InstallResult{}, err } - return InstallResult{LocalIdentity: local, LoginUI: login}, nil + cash, err := plugin.Install(plugin.CashShop, gameDir, cashSource) + if err != nil { + return InstallResult{}, err + } + return InstallResult{LocalIdentity: local, LoginUI: login, CashShop: cash}, nil } func FetchResourcePolicy(ctx context.Context, client *http.Client, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (ResourcePolicy, error) { diff --git a/go/internal/server/account/login.go b/go/internal/server/account/login.go index d15d004..bd65f4a 100644 --- a/go/internal/server/account/login.go +++ b/go/internal/server/account/login.go @@ -29,21 +29,45 @@ var ( // deliberately absent. ResponseFields contains the remaining top-level // protobuf fields (such as client-notification state), never an HTTP envelope. type LoginSeed struct { - Version string - PacketCode int - UserInfo []byte - ResponseFields []byte - currencies CurrencyProvider - purchaseCounts PurchaseCountProvider - presetSlots PresetSlotProvider - inventorySlots InventorySlotProvider - firstGacha FirstGachaProvider - friendshipAP FriendshipAPProvider - lastPlayedPack LastPlayedPackProvider - achievementExp AchievementExperienceProvider - levelReward LevelRewardProvider - huntingAP HuntingAPProvider - monsterHuntSlots PresetSlotProvider + Version string + PacketCode int + UserInfo []byte + ResponseFields []byte + currencies CurrencyProvider + purchaseCounts PurchaseCountProvider + presetSlots PresetSlotProvider + inventorySlots InventorySlotProvider + firstGacha FirstGachaProvider + friendshipAP FriendshipAPProvider + lastPlayedPack LastPlayedPackProvider + achievementExp AchievementExperienceProvider + levelReward LevelRewardProvider + huntingAP HuntingAPProvider + monsterHuntSlots PresetSlotProvider + additionalCurrencies interface { + AdditionalCurrencies() (map[int]uint64, error) + } + newbieStep interface{ NewbieStep() uint64 } +} + +func (s *LoginSeed) AttachNewbieStep(provider interface{ NewbieStep() uint64 }) error { + if provider == nil { + return errors.New("account: missing newbie step provider") + } + s.newbieStep = provider + return nil +} + +// AttachAdditionalCurrencies supplies balances owned by optional gameplay +// domains without altering the stable wallet representation. +func (s *LoginSeed) AttachAdditionalCurrencies(provider interface { + AdditionalCurrencies() (map[int]uint64, error) +}) error { + if provider == nil { + return errors.New("account: missing additional currency provider") + } + s.additionalCurrencies = provider + return nil } func (s *LoginSeed) AttachMonsterHuntSlots(provider PresetSlotProvider) error { @@ -414,6 +438,32 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { return nil, fmt.Errorf("account: session key: %w", err) } user := append([]byte(nil), s.UserInfo...) + if s.newbieStep != nil { + step := s.newbieStep.NewbieStep() + if step > math.MaxInt32 { + return nil, errors.New("account: newbie step exceeds protocol range") + } + var err error + user, _, err = wire.ReplaceVarint(user, 39, step) + if err != nil { + return nil, err + } + } + if s.additionalCurrencies != nil { + values, err := s.additionalCurrencies.AdditionalCurrencies() + if err != nil { + return nil, err + } + for field, value := range values { + if value > math.MaxInt32 { + return nil, errors.New("account: additional currency exceeds protocol range") + } + user, _, err = wire.ReplaceVarint(user, field, value) + if err != nil { + return nil, err + } + } + } if s.monsterHuntSlots != nil { count := s.monsterHuntSlots.PresetSlotCount() if count > math.MaxInt32 { diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go index 95a55f4..b7a8831 100644 --- a/go/internal/server/battle/battle.go +++ b/go/internal/server/battle/battle.go @@ -4,6 +4,7 @@ package battle import ( + "bytes" "errors" "fmt" "log/slog" @@ -33,6 +34,30 @@ type Service struct { commitHealth func(map[uint64]uint64) error hunting HuntingRuntime monsterHunt MonsterHuntRuntime + eventBattles []EventBattleRuntime +} + +// EventBattleRuntime owns event stage eligibility, costs and settlement while +// the normal battle service transports the client's turn simulation. +type EventBattleRuntime interface { + HandlesBattle(mode uint64) bool + EnterBattle(request []byte, receipt string) ([]byte, error) + CompleteBattle(request []byte, receipt string) ([]byte, error) +} + +func (s *Service) AttachEventBattle(runtime EventBattleRuntime) { + if runtime != nil { + s.eventBattles = append(s.eventBattles, runtime) + } +} + +func (s *Service) eventBattle(mode uint64) EventBattleRuntime { + for _, runtime := range s.eventBattles { + if runtime.HandlesBattle(mode) { + return runtime + } + } + return nil } type MonsterHuntRuntime interface { @@ -74,6 +99,9 @@ type battleState struct { phaseStarted bool phaseSeq uint64 phaseReply []byte + endSeq uint64 + endRequest []byte + endReply []byte } // BeginSession discards an unfinished battle when LoginUser creates a new @@ -216,6 +244,14 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } monster, _, _ := wire.Varint(request, 3) var huntResponse []byte + eventRuntime := s.eventBattle(mode) + if eventRuntime != nil { + seq, _, _ := wire.Varint(request, 1) + huntResponse, err = eventRuntime.EnterBattle(request, fmt.Sprintf("%s:%d", s.activeSession, seq)) + if err != nil { + return 0, nil, true, fmt.Errorf("battle: enter event: %w", err) + } + } if isMonsterHunt(mode) { if s.monsterHunt == nil { return 0, nil, true, errors.New("battle: monster hunt runtime unavailable") @@ -256,7 +292,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } } var phases []gamedata.BattlePhase - if !isMonsterHunt(mode) && (s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0)) { + if !isMonsterHunt(mode) && eventRuntime == nil && (s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0)) { loader := s.loadPhases if loader == nil { loader = gamedata.BattleDeckPhases @@ -368,6 +404,13 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) response = wire.AppendVarint(response, 3, seed) return 14, response, true, nil case "/BattleEnd": + endSeq, _, _ := wire.Varint(request, 1) + if state.endSeq == endSeq && state.endRequest != nil { + if !bytes.Equal(state.endRequest, request) { + return 0, nil, true, errors.New("battle: changed settlement retry") + } + return 15, append([]byte(nil), state.endReply...), true, nil + } if !state.entered { return 0, nil, true, errors.New("battle: end before enter") } @@ -379,6 +422,16 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, errors.New("battle: victory before final phase start") } response := wire.AppendVarint(nil, 1, result) + if eventRuntime := s.eventBattle(state.mode); eventRuntime != nil { + extra, err := eventRuntime.CompleteBattle(request, state.enterReceipt) + if err != nil { + return 0, nil, true, fmt.Errorf("battle: settle event: %w", err) + } + response = append(response, extra...) + state.rememberEnd(request, response) + state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil + return 15, response, true, nil + } if isMonsterHunt(state.mode) { // Monster Hunt owns its remaining HP, progression and daily/season // rewards. Field HP and ordinary pack rewards must not settle here. @@ -387,6 +440,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, fmt.Errorf("battle: settle monster hunt: %w", err) } response = append(response, extra...) + state.rememberEnd(request, response) state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil return 15, response, true, nil } @@ -497,6 +551,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } response = wire.AppendBytes(response, field, nil) } + state.rememberEnd(request, response) state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil return 15, response, true, nil case "/BattleExit": @@ -506,6 +561,12 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) panic("unreachable") } +func (state *battleState) rememberEnd(request, response []byte) { + state.endSeq, _, _ = wire.Varint(request, 1) + state.endRequest = append([]byte(nil), request...) + state.endReply = append([]byte(nil), response...) +} + func (s *Service) stateLocked() *battleState { if s.states == nil { s.states = make(map[string]*battleState) diff --git a/go/internal/server/battle/event_test.go b/go/internal/server/battle/event_test.go new file mode 100644 index 0000000..c3f4c63 --- /dev/null +++ b/go/internal/server/battle/event_test.go @@ -0,0 +1,72 @@ +package battle + +import ( + "bytes" + "fmt" + "testing" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" +) + +type fakeEventBattle struct { + enter, end string + rejected bool +} + +func (*fakeEventBattle) HandlesBattle(mode uint64) bool { return mode == 17 } +func (e *fakeEventBattle) EnterBattle(_ []byte, receipt string) ([]byte, error) { + e.enter = receipt + if e.rejected { + return nil, fmt.Errorf("stage locked") + } + return wire.AppendVarint(nil, 1, 7), nil +} +func (e *fakeEventBattle) CompleteBattle(_ []byte, receipt string) ([]byte, error) { + e.end = receipt + return wire.AppendBytes(nil, 16, wire.AppendVarint(nil, 3, 77)), nil +} + +func TestEventBattleOwnsSettlementAndAvoidsWorldDeck(t *testing.T) { + s := NewService("unused", "unused", nil, func() (int, error) { return 21, nil }) + s.BeginSession("event-session") + e := &fakeEventBattle{} + s.AttachEventBattle(e) + s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) { + t.Fatal("event read ordinary world deck") + return nil, nil + } + s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("event altered world health"); return nil }) + enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 73), 5, 17) + if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(2), 2, 73)); err != nil { + t.Fatal(err) + } + code, out, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1)) + if err != nil || code != 15 { + t.Fatalf("settlement: %d %v", code, err) + } + if e.enter != "event-session:1" || e.end != e.enter { + t.Fatalf("receipt %q/%q", e.enter, e.end) + } + row, _, _ := wire.Bytes(out, 16) + if !bytes.Equal(row, wire.AppendVarint(nil, 3, 77)) { + t.Fatal("event progress lost") + } + if s.Active() { + t.Fatal("event battle remained active") + } + _, retry, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1)) + if err != nil || !bytes.Equal(retry, out) { + t.Fatal("settlement retry lost reply") + } + if _, _, _, err = s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 2)); err == nil { + t.Fatal("changed settlement retry accepted") + } + e.rejected = true + if _, _, _, err = s.Handle("/BattleEnter", enter); err == nil { + t.Fatal("locked stage entered") + } +} diff --git a/go/internal/server/battle/hunting_test.go b/go/internal/server/battle/hunting_test.go index fdaf817..453c2ef 100644 --- a/go/internal/server/battle/hunting_test.go +++ b/go/internal/server/battle/hunting_test.go @@ -1,6 +1,7 @@ package battle import ( + "bytes" "errors" "testing" @@ -61,7 +62,8 @@ func TestHuntingBattleUsesLockedEncounterAndSessionReceipt(t *testing.T) { if bundle, found, _ := wire.Bytes(response, 5); !found || len(bundle) == 0 { t.Fatal("missing hunting reward") } - if _, _, _, err := s.Handle("/BattleEnd", end); err == nil { - t.Fatal("completed battle settled twice") + settlements := h.settlements + if _, retry, _, err := s.Handle("/BattleEnd", end); err != nil || !bytes.Equal(retry, response) || h.settlements != settlements { + t.Fatal("completed battle retry settled twice or lost response") } } diff --git a/go/internal/server/calendar/binary.go b/go/internal/server/calendar/binary.go new file mode 100644 index 0000000..dd0e2a5 --- /dev/null +++ b/go/internal/server/calendar/binary.go @@ -0,0 +1,194 @@ +package calendar + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "fmt" + "unicode/utf8" +) + +const MaxFileSize = 8 * 1024 * 1024 +const maxRows = 100000 +const maxString = 64 * 1024 +const headerSize = 46 + +var magic = []byte{'B', 'D', '2', 'S', 'C', 'H', 0, 0} + +type encoder struct { + data []byte + err error + rows uint64 +} + +func (e *encoder) u64(v uint64) { + if e.room(8) { + e.data = binary.LittleEndian.AppendUint64(e.data, v) + } +} +func (e *encoder) u32(v uint32) { + if e.room(4) { + e.data = binary.LittleEndian.AppendUint32(e.data, v) + } +} +func (e *encoder) b(v bool) { + if !e.room(1) { + return + } + if v { + e.data = append(e.data, 1) + } else { + e.data = append(e.data, 0) + } +} +func (e *encoder) str(v string) { + if e.err != nil { + return + } + if len(v) > maxString || !utf8.ValidString(v) { + e.err = fmt.Errorf("calendar: invalid/oversized UTF8 string") + return + } + if !e.room(4 + len(v)) { + return + } + e.u32(uint32(len(v))) + e.data = append(e.data, v...) +} +func (e *encoder) count(n int) { + if e.err != nil { + return + } + e.rows += uint64(n) + if n > maxRows || e.rows > maxRows { + e.err = fmt.Errorf("calendar: row limit exceeded") + return + } + e.u32(uint32(n)) +} +func (e *encoder) room(n int) bool { + if e.err != nil { + return false + } + if n > MaxFileSize-headerSize-len(e.data) { + e.err = fmt.Errorf("calendar: file size limit exceeded") + return false + } + return true +} + +type decoder struct { + data []byte + pos int + err error + rows uint64 +} + +func (d *decoder) take(n int) []byte { + if d.err != nil { + return nil + } + if n < 0 || n > len(d.data)-d.pos { + d.err = fmt.Errorf("calendar: truncated payload") + return nil + } + v := d.data[d.pos : d.pos+n] + d.pos += n + return v +} +func (d *decoder) u64() uint64 { + v := d.take(8) + if len(v) != 8 { + return 0 + } + return binary.LittleEndian.Uint64(v) +} +func (d *decoder) u32() uint32 { + v := d.take(4) + if len(v) != 4 { + return 0 + } + return binary.LittleEndian.Uint32(v) +} +func (d *decoder) b() bool { + v := d.take(1) + if len(v) != 1 { + return false + } + if v[0] > 1 { + d.err = fmt.Errorf("calendar: invalid boolean/presence") + } + return v[0] == 1 +} +func (d *decoder) str() string { + n := d.u32() + if n > maxString { + d.err = fmt.Errorf("calendar: string limit exceeded") + return "" + } + v := d.take(int(n)) + if !utf8.Valid(v) { + d.err = fmt.Errorf("calendar: invalid UTF8") + } + return string(v) +} +func (d *decoder) count() int { + n := d.u32() + d.rows += uint64(n) + if n > maxRows || d.rows > maxRows || uint64(n) > uint64(len(d.data)-d.pos) { + d.err = fmt.Errorf("calendar: row count limit/truncation") + return 0 + } + return int(n) +} + +// MarshalBinary emits format 1's fixed-order record payload and checksum. +func MarshalBinary(m Manifest) ([]byte, error) { + if m.SchemaVersion != 1 { + return nil, fmt.Errorf("calendar: unsupported schema") + } + e := &encoder{} + e.manifest(m) + if e.err != nil { + return nil, e.err + } + if len(e.data) > MaxFileSize-headerSize { + return nil, fmt.Errorf("calendar: file size limit exceeded") + } + out := append([]byte(nil), magic...) + out = binary.LittleEndian.AppendUint16(out, 1) + out = binary.LittleEndian.AppendUint32(out, uint32(len(e.data))) + sum := sha256.Sum256(e.data) + out = append(out, sum[:]...) + return append(out, e.data...), nil +} + +// UnmarshalBinary rejects damaged, unsupported, oversized and trailing data. +func UnmarshalBinary(raw []byte) (Manifest, error) { + var m Manifest + if len(raw) < headerSize || len(raw) > MaxFileSize { + return m, fmt.Errorf("calendar: invalid file size") + } + if !bytes.Equal(raw[:8], magic) || binary.LittleEndian.Uint16(raw[8:10]) != 1 { + return m, fmt.Errorf("calendar: unsupported magic/format") + } + n := binary.LittleEndian.Uint32(raw[10:14]) + if uint64(n) != uint64(len(raw)-headerSize) { + return m, fmt.Errorf("calendar: payload length mismatch") + } + payload := raw[headerSize:] + sum := sha256.Sum256(payload) + if !bytes.Equal(raw[14:46], sum[:]) { + return m, fmt.Errorf("calendar: checksum mismatch") + } + d := &decoder{data: payload} + m = d.manifest() + if d.err != nil { + return Manifest{}, d.err + } + if d.pos != len(payload) { + return Manifest{}, fmt.Errorf("calendar: trailing payload") + } + m.SchemaVersion = 1 + return m, nil +} diff --git a/go/internal/server/calendar/binary_test.go b/go/internal/server/calendar/binary_test.go new file mode 100644 index 0000000..e2dd61b --- /dev/null +++ b/go/internal/server/calendar/binary_test.go @@ -0,0 +1,115 @@ +package calendar + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestInstalledBinaryFilesReencodeExactly(t *testing.T) { + paths, e := filepath.Glob("../../../../schedules/*.bd2schedule") + if e != nil || len(paths) != 6 { + t.Fatalf("installed files=%d err=%v", len(paths), e) + } + for _, path := range paths { + raw, e := os.ReadFile(path) + if e != nil { + t.Fatal(e) + } + m, e := UnmarshalBinary(raw) + if e != nil { + t.Fatal(e) + } + actual, e := MarshalBinary(m) + if e != nil { + t.Fatal(e) + } + if !bytes.Equal(raw, actual) { + t.Fatalf("canonical bytes changed %s", path) + } + } +} +func TestBinaryRejectsInvalidPresence(t *testing.T) { + raw, e := MarshalBinary(baseManifest()) + if e != nil { + t.Fatal(e) + } + d := &decoder{data: raw[headerSize:]} + d.str() + d.str() + d.str() + d.count() + d.count() + d.count() + raw[headerSize+d.pos] = 2 + sum := sha256.Sum256(raw[headerSize:]) + copy(raw[14:46], sum[:]) + if _, e = UnmarshalBinary(raw); e == nil { + t.Fatal("invalid optional presence accepted despite valid checksum") + } +} + +func TestBinaryRoundTripAllRecords(t *testing.T) { + m := baseManifest() + m.Events = []Event{{UID: 5, Type: 11, ID: 7, Start: "2026-01-01T00:00:00Z", End: "2026-02-01T00:00:00Z"}} + m.Gacha = []Gacha{{GroupID: 2, FreeCountBonus: true}} + m.StepUps = []Gacha{{GroupID: 3}} + m.Regular = &Regular{Contents: []Content{{ID: 4, Current: Season{ID: 8, Error: true}, Next: Season{ID: 9, Return: true}}}} + m.MonsterHunt = &MonsterHunt{Seasons: []Hunt{{HuntID: 6, CostumeBanIDs: []uint64{5}, BurstBanIDs: []uint64{7}, IndependentFlag: true}}, History: []HuntHistory{{Season: 5, HuntID: 6, Hidden: true}}} + m.CashProducts = []CashProduct{{GroupID: 1, ProductID: 2, EventIndex: 3}} + m.EventHubs = []EventHub{{HubID: 4, Settings: []HubSetting{{Slot: 5, EventUIDs: []uint64{6, 7}}}}} + m.MiniGameHubs = []MiniGameHub{{Slot: 8, EventUID: 5}} + raw, e := MarshalBinary(m) + if e != nil { + t.Fatal(e) + } + actual, e := UnmarshalBinary(raw) + if e != nil { + t.Fatal(e) + } + if !reflect.DeepEqual(actual, m) { + t.Fatalf("roundtrip changed records: %+v", actual) + } +} +func TestBinaryRejectsCorruptionLimitsAndTrailing(t *testing.T) { + raw, e := MarshalBinary(baseManifest()) + if e != nil { + t.Fatal(e) + } + for n := 0; n < len(raw); n++ { + if _, e = UnmarshalBinary(raw[:n]); e == nil { + t.Fatalf("accepted truncation %d", n) + } + } + for _, offset := range []int{0, 8, 10, 14, 46} { + bad := append([]byte(nil), raw...) + bad[offset] ^= 0xff + if _, e = UnmarshalBinary(bad); e == nil { + t.Fatalf("accepted corruption %d", offset) + } + } + if _, e = UnmarshalBinary(append(raw, 0)); e == nil { + t.Fatal("accepted trailing data") + } + m := baseManifest() + m.Revision = string(make([]byte, maxString+1)) + if _, e = MarshalBinary(m); e == nil { + t.Fatal("oversized string accepted") + } + m = baseManifest() + m.Events = make([]Event, maxRows+1) + if _, e = MarshalBinary(m); e == nil { + t.Fatal("oversized count accepted") + } + bad := append([]byte(nil), raw...) + binary.LittleEndian.PutUint32(bad[46:50], ^uint32(0)) + sum := sha256.Sum256(bad[46:]) + copy(bad[14:46], sum[:]) + if _, e = UnmarshalBinary(bad); e == nil { + t.Fatal("overflow string length accepted") + } +} diff --git a/go/internal/server/calendar/design.go b/go/internal/server/calendar/design.go new file mode 100644 index 0000000..f549593 --- /dev/null +++ b/go/internal/server/calendar/design.go @@ -0,0 +1,130 @@ +package calendar + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "fmt" +) + +// ValidateDesign checks playable identities against their domain's installed +// GameData. Announcement-only types 3/6/14/15/16/18 have no local gameplay +// design resolver and receive only the manifest's protocol/format validation. +func (s *Set) ValidateDesign(root, version string) error { + cash, e := gamedata.LoadCashCatalog(root, version) + if e != nil { + return e + } + keys := map[gamedata.CashProductKey]bool{} + for _, v := range cash.Products { + keys[v.Key] = true + } + for _, v := range s.CashProducts { + if !keys[gamedata.CashProductKey{GroupID: v.GroupID, ProductID: v.ProductID, SaleGroup: v.SaleGroup}] { + return fmt.Errorf("calendar: cash product %d/%d/%d missing GameData", v.GroupID, v.ProductID, v.SaleGroup) + } + } + if s.MonsterHunt != nil { + ids, e := gamedata.LoadMonsterHuntIDs(root, version) + if e != nil { + return e + } + for _, v := range s.MonsterHunt.Seasons { + if !ids[v.HuntID] { + return fmt.Errorf("calendar: active/playable hunt %d missing GameData", v.HuntID) + } + } + } + play, e := gamedata.LoadEventPlayCatalog(root, version) + if e != nil { + return e + } + tasks, e := gamedata.LoadEventTasksDesign(root, version) + if e != nil { + return e + } + exchange, e := gamedata.LoadEventExchangeCatalog(root, version) + if e != nil { + return e + } + actions, e := gamedata.LoadEventActionsDesign(root, version) + if e != nil { + return e + } + packs, e := gamedata.LoadCalendarPackIDs(root, version) + if e != nil { + return e + } + for _, h := range s.EventHubs { + if _, e = play.Row("PackEventHubTable", 14, h.HubID); e != nil { + return fmt.Errorf("calendar: hub %d: %w", h.HubID, e) + } + } + uid := map[uint64]events.Schedule{} + for _, v := range s.Events { + if v.UID != 0 { + uid[v.UID] = v + } + } + for _, h := range s.MiniGameHubs { + v, ok := uid[h.EventUID] + if !ok || v.Type != 11 { + return fmt.Errorf("calendar: mini game slot %d references non-minigame event %d", h.Slot, h.EventUID) + } + if _, e := play.Row("PackEventMiniGameTable", 8, v.ID); e != nil { + return e + } + + } + for _, v := range s.Events { + valid := true + var err error + switch v.Type { + case 0: + _, valid = tasks.Attendance[v.ID] + case 1: + valid = false + for k := range tasks.LimitRewards { + if k[0] == v.ID { + valid = true + break + } + } + case 4: + _, valid = tasks.MissionGroups[v.ID] + case 5: + _, valid = tasks.Passes[v.ID] + case 7: + _, valid = exchange.Groups[v.ID] + case 8: + valid = packs[v.ID] + case 9: + _, err = play.Row("PackEventBattleGroupTable", 3, v.ID) + case 10: + _, err = play.Row("PackEventStoryGroupTable", 1, v.ID) + case 11: + _, err = play.Row("PackEventMiniGameTable", 8, v.ID) + case 12, 13, 17, 19: + _, err = gamedata.LoadEventGame(root, version, v.Type, v.ID) + case 20: + _, valid = actions.Row("TacticsBingoGroupTable", 3, v.ID) + case 21: + _, valid = actions.Row("FieldSpawnEventTable", 5, v.ID) + case 22: + _, valid = actions.Row("FireworksTable", 4, v.ID) + case 23: + _, valid = actions.Row("VotingEventTable", 6, v.ID) + case 24: + _, valid = actions.Row("FriendshipSpecialEpisodeTable", 5, v.ID) + case 25: + id := v.SubID + if id == 0 { + id = v.ID + } + _, valid = actions.Row("CafeteriaEventTable", 5, id) + } + if !valid || err != nil { + return fmt.Errorf("calendar: event uid %d type %d id %d missing GameData: %v", v.UID, v.Type, v.ID, err) + } + } + return nil +} diff --git a/go/internal/server/calendar/load.go b/go/internal/server/calendar/load.go new file mode 100644 index 0000000..310537f --- /dev/null +++ b/go/internal/server/calendar/load.go @@ -0,0 +1,379 @@ +package calendar + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gacha" + "bd2server/internal/server/schedule" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func timestamp(raw string) (uint64, error) { + t, e := time.Parse(time.RFC3339Nano, raw) + if e != nil { + return 0, fmt.Errorf("invalid RFC3339 timestamp %q", raw) + } + ms := t.UnixMilli() + if ms <= 0 || t.Nanosecond()%1000000 != 0 { + return 0, fmt.Errorf("timestamp must be positive with millisecond precision: %q", raw) + } + return uint64(ms), nil +} +func window(start, end string, equal bool) (uint64, uint64, error) { + a, e := timestamp(start) + if e != nil { + return 0, 0, e + } + b, e := timestamp(end) + if e != nil { + return 0, 0, e + } + if b < a || (!equal && b == a) { + return 0, 0, fmt.Errorf("reversed/empty calendar window") + } + return a, b, nil +} +func int32s(values ...uint64) error { + for _, v := range values { + if v > math.MaxInt32 { + return fmt.Errorf("identity/count %d exceeds protocol int32", v) + } + } + return nil +} +func convertSeason(s Season) (schedule.Season, error) { + a, b, e := window(s.Start, s.End, true) + if e != nil { + return schedule.Season{}, e + } + if s.ID == 0 { + return schedule.Season{}, fmt.Errorf("missing season identity") + } + if e = int32s(s.ID, s.RankRewardGroupID); e != nil { + return schedule.Season{}, e + } + return schedule.Season{ID: s.ID, StartMilliseconds: a, EndMilliseconds: b, RankRewardGroupID: s.RankRewardGroupID, Error: s.Error, Return: s.Return}, nil +} + +// LoadDirectory validates the whole directory before returning any calendar. +// Every calendar file must use the .bd2schedule suffix. +// Directories and symlinks are +// rejected so an accidentally omitted calendar never produces a partial set. +func LoadDirectory(dir, gameVersion, gameDataVersion string) (*Set, error) { + entries, e := os.ReadDir(dir) + if e != nil { + return nil, fmt.Errorf("calendar: read directory: %w", e) + } + set := &Set{GachaSeed: &gacha.ScheduleSeed{ClientVersion: gameVersion}} + seen := map[string]bool{} + claim := func(key string) error { + if seen[key] { + return fmt.Errorf("duplicate calendar identity %s", key) + } + seen[key] = true + return nil + } + files := 0 + for _, entry := range entries { + if !entry.Type().IsRegular() || !strings.HasSuffix(entry.Name(), ".bd2schedule") { + return nil, fmt.Errorf("calendar: unsupported entry %s", entry.Name()) + } + info, e := entry.Info() + if e != nil { + return nil, e + } + if info.Size() > MaxFileSize { + return nil, fmt.Errorf("calendar: %s: file size limit exceeded", entry.Name()) + } + raw, e := os.ReadFile(filepath.Join(dir, entry.Name())) + if e != nil { + return nil, e + } + m, e := UnmarshalBinary(raw) + if e != nil { + return nil, fmt.Errorf("calendar: %s: %w", entry.Name(), e) + } + if m.SchemaVersion != 1 || strings.TrimSpace(m.Revision) == "" || m.GameVersion != gameVersion || m.GameDataVersion != gameDataVersion || gameVersion == "" || gameDataVersion == "" { + return nil, fmt.Errorf("calendar: %s: incompatible schema/version or missing revision", entry.Name()) + } + if e = merge(set, m, claim); e != nil { + return nil, fmt.Errorf("calendar: %s: %w", entry.Name(), e) + } + set.Revisions = append(set.Revisions, m.Revision) + files++ + } + if files == 0 { + return nil, fmt.Errorf("calendar: empty directory") + } + sort.Slice(set.EventHubs, func(i, j int) bool { return set.EventHubs[i].UID < set.EventHubs[j].UID }) + sort.Slice(set.MiniGameHubs, func(i, j int) bool { return set.MiniGameHubs[i].Slot < set.MiniGameHubs[j].Slot }) + sort.Slice(set.Events, func(i, j int) bool { + a, b := set.Events[i], set.Events[j] + if a.UID != b.UID { + return a.UID < b.UID + } + if a.Type != b.Type { + return a.Type < b.Type + } + if a.ID != b.ID { + return a.ID < b.ID + } + return a.SubID < b.SubID + }) + sortWindows := func(w []gacha.ScheduleWindow) { + sort.Slice(w, func(i, j int) bool { + if w[i].GroupID != w[j].GroupID { + return w[i].GroupID < w[j].GroupID + } + return w[i].StartTime < w[j].StartTime + }) + } + sortWindows(set.GachaSeed.Schedules) + sortWindows(set.GachaSeed.StepUps) + if e = set.GachaSeed.Validate(gameVersion); e != nil { + return nil, e + } + if set.RegularService != nil { + sort.Slice(set.RegularService.Contents, func(i, j int) bool { return set.RegularService.Contents[i].ID < set.RegularService.Contents[j].ID }) + sort.Slice(set.RegularService.Regular, func(i, j int) bool { + return set.RegularService.Regular[i].ContentID < set.RegularService.Regular[j].ContentID + }) + if e = set.RegularService.Validate(); e != nil { + return nil, e + } + } + sort.Slice(set.CashProducts, func(i, j int) bool { + a, b := set.CashProducts[i], set.CashProducts[j] + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + if a.ProductID != b.ProductID { + return a.ProductID < b.ProductID + } + return a.SaleGroup < b.SaleGroup + }) + if set.MonsterHunt != nil { + sort.Slice(set.MonsterHunt.Seasons, func(i, j int) bool { + return set.MonsterHunt.Seasons[i].Season.ID < set.MonsterHunt.Seasons[j].Season.ID + }) + sort.Slice(set.MonsterHunt.History, func(i, j int) bool { return set.MonsterHunt.History[i].Season < set.MonsterHunt.History[j].Season }) + } + return set, nil +} +func merge(s *Set, m Manifest, claim func(string) error) error { + for _, v := range m.EventHubs { + if e := claim(fmt.Sprintf("hub:%d", v.UID)); e != nil { + return e + } + if v.HubID == 0 { + return fmt.Errorf("missing hub identity") + } + if e := int32s(v.UID, v.HubID); e != nil { + return e + } + a, b, e := window(v.Start, v.End, false) + if e != nil { + return e + } + play, e := timestamp(v.PlayEnd) + if e != nil { + return e + } + if play < a || play > b { + return fmt.Errorf("invalid hub play end") + } + slots := map[uint64]bool{} + for _, setting := range v.Settings { + if slots[setting.Slot] { + return fmt.Errorf("duplicate hub slot") + } + slots[setting.Slot] = true + if e := int32s(setting.Slot, setting.ProgressType); e != nil { + return e + } + if e := int32s(setting.EventUIDs...); e != nil { + return e + } + ids := map[uint64]bool{} + for _, uid := range setting.EventUIDs { + if ids[uid] { + return fmt.Errorf("duplicate hub event uid") + } + ids[uid] = true + } + } + s.EventHubs = append(s.EventHubs, v) + } + for _, v := range m.MiniGameHubs { + if e := claim(fmt.Sprintf("mini_game:%d", v.Slot)); e != nil { + return e + } + if e := int32s(v.Slot, v.EventUID, v.ProgressType); e != nil { + return e + } + s.MiniGameHubs = append(s.MiniGameHubs, v) + } + for _, v := range m.Events { + if e := claim(eventIdentity(v)); e != nil { + return e + } + if e := int32s(v.Type, v.ID, v.SubID); e != nil { + return e + } + a, b, e := eventWindow(v.Start, v.End) + if e != nil { + return e + } + s.Events = append(s.Events, events.Schedule{UID: v.UID, Type: v.Type, ID: v.ID, SubID: v.SubID, Start: a, End: b}) + } + r := events.NewRegistry() + if e := r.Replace(s.Events); e != nil { + return e + } + for n, rows := range [][]Gacha{m.Gacha, m.StepUps} { + for _, v := range rows { + if e := int32s(v.GroupID); e != nil { + return e + } + a, b, e := window(v.Start, v.End, false) + if e != nil { + return e + } + w := gacha.ScheduleWindow{GroupID: v.GroupID, StartTime: a, EndTime: b, FreeCountBonus: v.FreeCountBonus, CashCountBonus: v.CashCountBonus} + if n == 0 { + s.GachaSeed.Schedules = append(s.GachaSeed.Schedules, w) + } else { + s.GachaSeed.StepUps = append(s.GachaSeed.StepUps, w) + } + } + } + if m.Regular != nil { + if e := claim("regular"); e != nil { + return e + } + r := m.Regular + s.RegularService = &schedule.Service{Version: m.GameVersion, CalculateMilliseconds: r.CalculateMilliseconds, Regular: r.Regular} + for _, v := range r.Contents { + a, e := convertSeason(v.Current) + if e != nil { + return e + } + b, e := convertSeason(v.Next) + if e != nil { + return e + } + s.RegularService.Contents = append(s.RegularService.Contents, schedule.Content{ID: v.ID, Current: a, Next: b}) + } + } + if m.MonsterHunt != nil { + if e := claim("monster_hunt"); e != nil { + return e + } + h := m.MonsterHunt + if e := int32s(h.StartRegularSeason); e != nil { + return e + } + for _, v := range h.Seasons { + if e := claim(fmt.Sprintf("hunt:%d", v.Season.ID)); e != nil { + return e + } + if v.HuntID == 0 { + return fmt.Errorf("missing hunt identity") + } + if e := int32s(v.HuntID, v.InfoOpenDay, v.RankRewardGroupID); e != nil { + return e + } + if _, e := convertSeason(v.Season); e != nil { + return e + } + end, e := timestamp(v.CalculateEndAt) + if e != nil { + return e + } + seasonEnd, _ := timestamp(v.Season.End) + if end < seasonEnd { + return fmt.Errorf("hunt calculation ends before season") + } + for _, ids := range [][]uint64{v.CostumeBanIDs, v.BurstBanIDs} { + if e := int32s(ids...); e != nil { + return e + } + seen := map[uint64]bool{} + for _, id := range ids { + if id == 0 || seen[id] { + return fmt.Errorf("invalid hunt ban identity") + } + seen[id] = true + } + } + } + for _, v := range h.History { + if e := claim(fmt.Sprintf("hunt_history:%d", v.Season)); e != nil { + return e + } + if v.Season == 0 || v.HuntID == 0 { + return fmt.Errorf("missing history identity") + } + if e := int32s(v.Season, v.HuntID); e != nil { + return e + } + } + s.MonsterHunt = h + } + for _, v := range m.CashProducts { + if e := claim(fmt.Sprintf("cash:%d:%d:%d", v.GroupID, v.ProductID, v.SaleGroup)); e != nil { + return e + } + if v.GroupID == 0 || v.ProductID == 0 || v.EventIndex > math.MaxInt64 { + return fmt.Errorf("invalid cash identity") + } + if e := int32s(v.GroupID, v.ProductID, v.SaleGroup, v.EndDelayMinutes); e != nil { + return e + } + var a, b uint64 + var e error + if v.Start != "" { + a, e = timestamp(v.Start) + if e != nil { + return e + } + } + if v.End != "" { + b, e = timestamp(v.End) + if e != nil { + return e + } + if b <= a { + return fmt.Errorf("invalid cash window") + } + } + s.CashProducts = append(s.CashProducts, v) + } + return nil +} + +func eventIdentity(v Event) string { + if v.UID != 0 { + return fmt.Sprintf("event:%d", v.UID) + } + return fmt.Sprintf("event:0:%d:%d:%d", v.Type, v.ID, v.SubID) +} +func eventWindow(start, end string) (int64, int64, error) { + a, e := time.Parse(time.RFC3339Nano, start) + if e != nil { + return 0, 0, e + } + b, e := time.Parse(time.RFC3339Nano, end) + if e != nil { + return 0, 0, e + } + if a.Nanosecond()%1000000 != 0 || b.Nanosecond()%1000000 != 0 || b.UnixMilli() <= a.UnixMilli() { + return 0, 0, fmt.Errorf("invalid event window") + } + return a.UnixMilli(), b.UnixMilli(), nil +} diff --git a/go/internal/server/calendar/load_test.go b/go/internal/server/calendar/load_test.go new file mode 100644 index 0000000..fd53b55 --- /dev/null +++ b/go/internal/server/calendar/load_test.go @@ -0,0 +1,122 @@ +package calendar + +import ( + "bd2server/internal/server/readonly" + "bd2server/internal/server/wire" + "os" + "path/filepath" + "testing" +) + +func writeManifest(t *testing.T, dir, name string, m Manifest) { + t.Helper() + raw, e := MarshalBinary(m) + if e != nil { + t.Fatal(e) + } + if e = os.WriteFile(filepath.Join(dir, name), raw, 0600); e != nil { + t.Fatal(e) + } +} +func baseManifest() Manifest { + return Manifest{SchemaVersion: 1, Revision: "2026-10-05", GameVersion: "2.35.10", GameDataVersion: "2.35.10"} +} +func TestInstalledCalendarsContainAllRuntimeDomains(t *testing.T) { + set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640") + if e != nil { + t.Fatal(e) + } + if len(set.Events) != 54 || len(set.GachaSeed.Schedules) != 11 || len(set.GachaSeed.StepUps) != 2 || set.RegularService == nil || len(set.RegularService.Contents) != 9 || set.MonsterHunt == nil || len(set.MonsterHunt.History) != 78 || len(set.CashProducts) != 37 || len(set.EventHubs) != 2 || len(set.MiniGameHubs) != 6 { + t.Fatalf("installed calendar omitted a domain: events=%d gacha=%d steps=%d cash=%d hubs=%d mini=%d", len(set.Events), len(set.GachaSeed.Schedules), len(set.GachaSeed.StepUps), len(set.CashProducts), len(set.EventHubs), len(set.MiniGameHubs)) + } +} +func TestMultiFileCalendarAtomicDeterministicAndIndependentRevision(t *testing.T) { + dir := t.TempDir() + a := baseManifest() + a.Revision = "a" + a.Gacha = []Gacha{{GroupID: 9, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}} + writeManifest(t, dir, "z.bd2schedule", a) + b := baseManifest() + b.Revision = "b" + b.Gacha = []Gacha{{GroupID: 1, Start: "2027-01-01T09:00:00+09:00", End: "2027-01-02T09:00:00+09:00"}, {GroupID: 9, Start: "2026-10-02T00:00:00Z", End: "2026-10-03T00:00:00Z"}} + writeManifest(t, dir, "a.bd2schedule", b) + set, e := LoadDirectory(dir, "2.35.10", "2.35.10") + if e != nil { + t.Fatal(e) + } + if len(set.GachaSeed.Schedules) != 3 || set.GachaSeed.Schedules[0].GroupID != 1 || set.Revisions[0] != "b" { + t.Fatalf("unexpected merged set %+v", set) + } + b.Revision = "changed without game update" + b.Gacha[0].End = "2027-01-03T09:00:00+09:00" + writeManifest(t, dir, "a.bd2schedule", b) + changed, e := LoadDirectory(dir, "2.35.10", "2.35.10") + if e != nil || changed.GachaSeed.Schedules[0].EndTime == set.GachaSeed.Schedules[0].EndTime { + t.Fatalf("calendar update ignored: %v", e) + } + if e = os.WriteFile(filepath.Join(dir, "broken.bd2schedule"), []byte("broken binary"), 0600); e != nil { + t.Fatal(e) + } + if partial, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil || partial != nil { + t.Fatal("bad file returned partial calendar") + } +} +func TestStrictCalendarRejectsConflictsAndInvalidData(t *testing.T) { + for _, mutate := range []func(*Manifest){func(m *Manifest) { m.GameDataVersion = "wrong" }, func(m *Manifest) { m.Gacha[0].Start = "2026-10-01" }, func(m *Manifest) { m.Gacha[0].End = m.Gacha[0].Start }, func(m *Manifest) { m.Gacha = append(m.Gacha, m.Gacha[0]) }, func(m *Manifest) { m.Gacha[0].GroupID = 1 << 32 }} { + dir := t.TempDir() + m := baseManifest() + m.Gacha = []Gacha{{GroupID: 1, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}} + mutate(&m) + writeManifest(t, dir, "one.bd2schedule", m) + if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil { + t.Fatal("invalid calendar accepted") + } + } + dir := t.TempDir() + m := baseManifest() + m.Events = []Event{{UID: 77, Type: 12, ID: 3, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}} + writeManifest(t, dir, "a.bd2schedule", m) + writeManifest(t, dir, "b.bd2schedule", m) + if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil { + t.Fatal("duplicate UID across files accepted") + } +} +func TestReadonlyCalendarReplacementPreservesStaticResponses(t *testing.T) { + set := &Set{CashProducts: []CashProduct{{GroupID: 11, ProductID: 22, EventIndex: 99}}, MonsterHunt: &MonsterHunt{StartRegularSeason: 5, History: []HuntHistory{{Season: 78, HuntID: 73, Hidden: true}}}} + old := &readonly.Seed{Version: "2.35.10", Responses: map[string]readonly.Response{"/Static": {PacketCode: 17, Fields: []readonly.Field{scalar(1, 10)}}, "/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{scalar(2, 1)}}}} + result, e := set.ApplyReadonly(old) + if e != nil { + t.Fatal(e) + } + if len(old.Responses["/CashShopInfo"].Fields) != 1 || result.Responses["/Static"].PacketCode != 17 { + t.Fatal("source seed changed/static endpoint lost") + } + _, raw, handled, e := result.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1)) + if e != nil || !handled { + t.Fatal(e) + } + p, found, e := wire.Bytes(raw, 1) + if e != nil || !found { + t.Fatal("missing product") + } + id, _, _ := wire.Varint(p, 8) + if id != 99 { + t.Fatalf("event index=%d", id) + } + if _, found, _ = wire.Varint(raw, 2); found { + t.Fatal("stale reset timestamp retained") + } +} +func TestInstalledCalendarRealGameData(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640") + if e != nil { + t.Fatal(e) + } + if e = set.ValidateDesign(root, "20260923193640"); e != nil { + t.Fatal(e) + } +} diff --git a/go/internal/server/calendar/readonly.go b/go/internal/server/calendar/readonly.go new file mode 100644 index 0000000..bd664c6 --- /dev/null +++ b/go/internal/server/calendar/readonly.go @@ -0,0 +1,91 @@ +package calendar + +import ( + "bd2server/internal/server/readonly" + "fmt" +) + +func scalar(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} } +func message(n int, f []readonly.Field) readonly.Field { + return readonly.Field{Number: n, Type: 2, Fields: f} +} +func flag(n int, b bool) readonly.Field { + if b { + return scalar(n, 1) + } + return scalar(n, 0) +} +func seasonFields(s Season) []readonly.Field { + a, _ := timestamp(s.Start) + b, _ := timestamp(s.End) + return []readonly.Field{scalar(1, s.ID), scalar(2, a), scalar(3, b), flag(4, s.Error), flag(5, s.Return), scalar(6, s.RankRewardGroupID)} +} + +// ApplyReadonly returns a separate seed with every calendar endpoint encoded +// entirely from the loaded files. Commerce supplies reset times from its +// current reset rules and clock when serving CashShopInfo. +func (s *Set) ApplyReadonly(seed *readonly.Seed) (*readonly.Seed, error) { + if s == nil || seed == nil { + return nil, fmt.Errorf("calendar: missing calendar/static seed") + } + result := &readonly.Seed{Version: seed.Version, Responses: make(map[string]readonly.Response, len(seed.Responses))} + for path, response := range seed.Responses { + result.Responses[path] = response + } + var hunt []readonly.Field + if h := s.MonsterHunt; h != nil { + for _, v := range h.Seasons { + end, _ := timestamp(v.CalculateEndAt) + f := []readonly.Field{message(1, seasonFields(v.Season)), scalar(2, v.HuntID), scalar(3, v.InfoOpenDay), scalar(4, end), flag(5, v.ErrorFlag), flag(6, v.IndependentFlag), scalar(7, v.RankRewardGroupID)} + for _, id := range v.CostumeBanIDs { + f = append(f, scalar(8, id)) + } + for _, id := range v.BurstBanIDs { + f = append(f, scalar(9, id)) + } + hunt = append(hunt, message(1, f)) + } + hunt = append(hunt, scalar(2, h.StartRegularSeason)) + for _, v := range h.History { + hunt = append(hunt, message(3, []readonly.Field{scalar(1, v.Season), scalar(2, v.HuntID), flag(3, v.ErrorFlag), flag(4, v.Hidden)})) + } + } + result.Responses["/MonsterHuntScheduleInfo"] = readonly.Response{PacketCode: 0, Fields: hunt} + var cash []readonly.Field + for _, v := range s.CashProducts { + var a, b uint64 + if v.Start != "" { + a, _ = timestamp(v.Start) + } + if v.End != "" { + b, _ = timestamp(v.End) + } + cash = append(cash, message(1, []readonly.Field{scalar(1, v.GroupID), scalar(2, v.ProductID), scalar(3, v.SaleGroup), scalar(4, a), scalar(5, b), scalar(6, v.EndDelayMinutes), scalar(8, v.EventIndex)})) + } + result.Responses["/CashShopInfo"] = readonly.Response{PacketCode: 60, Fields: cash} + var hubs []readonly.Field + for _, v := range s.EventHubs { + a, _ := timestamp(v.Start) + b, _ := timestamp(v.PlayEnd) + c, _ := timestamp(v.End) + f := []readonly.Field{scalar(1, v.UID), scalar(2, v.HubID), scalar(3, a), scalar(4, b), scalar(5, c)} + for _, setting := range v.Settings { + sf := []readonly.Field{scalar(1, setting.Slot), scalar(2, setting.ProgressType)} + for _, uid := range setting.EventUIDs { + sf = append(sf, scalar(3, uid)) + } + f = append(f, message(6, sf)) + } + hubs = append(hubs, message(1, f)) + } + result.Responses["/EventHubInfo"] = readonly.Response{PacketCode: 0, Fields: hubs} + var mini []readonly.Field + for _, v := range s.MiniGameHubs { + mini = append(mini, message(1, []readonly.Field{scalar(1, v.Slot), scalar(2, v.EventUID), scalar(3, v.ProgressType)})) + } + result.Responses["/MiniGameHubInfo"] = readonly.Response{PacketCode: 390, Fields: mini} + if err := result.Validate(); err != nil { + return nil, err + } + return result, nil +} diff --git a/go/internal/server/calendar/records.go b/go/internal/server/calendar/records.go new file mode 100644 index 0000000..02f1c03 --- /dev/null +++ b/go/internal/server/calendar/records.go @@ -0,0 +1,312 @@ +package calendar + +import "bd2server/internal/server/schedule" + +func (e *encoder) manifest(v Manifest) { + e.str(v.Revision) + e.str(v.GameVersion) + e.str(v.GameDataVersion) + e.count(len(v.Events)) + for _, item := range v.Events { + e.event(item) + } + e.count(len(v.Gacha)) + for _, item := range v.Gacha { + e.gacha(item) + } + e.count(len(v.StepUps)) + for _, item := range v.StepUps { + e.gacha(item) + } + e.b(v.Regular != nil) + if v.Regular != nil { + e.regular(*v.Regular) + } + e.b(v.MonsterHunt != nil) + if v.MonsterHunt != nil { + e.monsterhunt(*v.MonsterHunt) + } + e.count(len(v.CashProducts)) + for _, item := range v.CashProducts { + e.cashproduct(item) + } + e.count(len(v.EventHubs)) + for _, item := range v.EventHubs { + e.eventhub(item) + } + e.count(len(v.MiniGameHubs)) + for _, item := range v.MiniGameHubs { + e.minigamehub(item) + } +} +func (d *decoder) manifest() Manifest { + var v Manifest + v.Revision = d.str() + v.GameVersion = d.str() + v.GameDataVersion = d.str() + for n := d.count(); n > 0 && d.err == nil; n-- { + v.Events = append(v.Events, d.event()) + } + for n := d.count(); n > 0 && d.err == nil; n-- { + v.Gacha = append(v.Gacha, d.gacha()) + } + for n := d.count(); n > 0 && d.err == nil; n-- { + v.StepUps = append(v.StepUps, d.gacha()) + } + if d.b() { + item := d.regular() + v.Regular = &item + } + if d.b() { + item := d.monsterhunt() + v.MonsterHunt = &item + } + for n := d.count(); n > 0 && d.err == nil; n-- { + v.CashProducts = append(v.CashProducts, d.cashproduct()) + } + for n := d.count(); n > 0 && d.err == nil; n-- { + v.EventHubs = append(v.EventHubs, d.eventhub()) + } + for n := d.count(); n > 0 && d.err == nil; n-- { + v.MiniGameHubs = append(v.MiniGameHubs, d.minigamehub()) + } + return v +} +func (e *encoder) event(v Event) { + e.u64(v.UID) + e.u64(v.Type) + e.u64(v.ID) + e.u64(v.SubID) + e.str(v.Start) + e.str(v.End) +} +func (d *decoder) event() Event { + var v Event + v.UID = d.u64() + v.Type = d.u64() + v.ID = d.u64() + v.SubID = d.u64() + v.Start = d.str() + v.End = d.str() + return v +} +func (e *encoder) gacha(v Gacha) { + e.u64(v.GroupID) + e.str(v.Start) + e.str(v.End) + e.b(v.FreeCountBonus) + e.b(v.CashCountBonus) +} +func (d *decoder) gacha() Gacha { + var v Gacha + v.GroupID = d.u64() + v.Start = d.str() + v.End = d.str() + v.FreeCountBonus = d.b() + v.CashCountBonus = d.b() + return v +} +func (e *encoder) season(v Season) { + e.u64(v.ID) + e.str(v.Start) + e.str(v.End) + e.u64(v.RankRewardGroupID) + e.b(v.Error) + e.b(v.Return) +} +func (d *decoder) season() Season { + var v Season + v.ID = d.u64() + v.Start = d.str() + v.End = d.str() + v.RankRewardGroupID = d.u64() + v.Error = d.b() + v.Return = d.b() + return v +} +func (e *encoder) content(v Content) { + e.u64(v.ID) + e.season(v.Current) + e.season(v.Next) +} +func (d *decoder) content() Content { + var v Content + v.ID = d.u64() + v.Current = d.season() + v.Next = d.season() + return v +} +func (e *encoder) regular(v Regular) { + e.u64(v.CalculateMilliseconds) + e.count(len(v.Contents)) + for _, item := range v.Contents { + e.content(item) + } + e.count(len(v.Regular)) + for _, item := range v.Regular { + e.regularseason(item) + } +} +func (d *decoder) regular() Regular { + var v Regular + v.CalculateMilliseconds = d.u64() + for n := d.count(); n > 0 && d.err == nil; n-- { + v.Contents = append(v.Contents, d.content()) + } + for n := d.count(); n > 0 && d.err == nil; n-- { + v.Regular = append(v.Regular, d.regularseason()) + } + return v +} +func (e *encoder) hunt(v Hunt) { + e.season(v.Season) + e.u64(v.HuntID) + e.u64(v.InfoOpenDay) + e.str(v.CalculateEndAt) + e.b(v.ErrorFlag) + e.b(v.IndependentFlag) + e.u64(v.RankRewardGroupID) + e.count(len(v.CostumeBanIDs)) + for _, item := range v.CostumeBanIDs { + e.u64(item) + } + e.count(len(v.BurstBanIDs)) + for _, item := range v.BurstBanIDs { + e.u64(item) + } +} +func (d *decoder) hunt() Hunt { + var v Hunt + v.Season = d.season() + v.HuntID = d.u64() + v.InfoOpenDay = d.u64() + v.CalculateEndAt = d.str() + v.ErrorFlag = d.b() + v.IndependentFlag = d.b() + v.RankRewardGroupID = d.u64() + for n := d.count(); n > 0 && d.err == nil; n-- { + v.CostumeBanIDs = append(v.CostumeBanIDs, d.u64()) + } + for n := d.count(); n > 0 && d.err == nil; n-- { + v.BurstBanIDs = append(v.BurstBanIDs, d.u64()) + } + return v +} +func (e *encoder) hunthistory(v HuntHistory) { + e.u64(v.Season) + e.u64(v.HuntID) + e.b(v.ErrorFlag) + e.b(v.Hidden) +} +func (d *decoder) hunthistory() HuntHistory { + var v HuntHistory + v.Season = d.u64() + v.HuntID = d.u64() + v.ErrorFlag = d.b() + v.Hidden = d.b() + return v +} +func (e *encoder) monsterhunt(v MonsterHunt) { + e.count(len(v.Seasons)) + for _, item := range v.Seasons { + e.hunt(item) + } + e.u64(v.StartRegularSeason) + e.count(len(v.History)) + for _, item := range v.History { + e.hunthistory(item) + } +} +func (d *decoder) monsterhunt() MonsterHunt { + var v MonsterHunt + for n := d.count(); n > 0 && d.err == nil; n-- { + v.Seasons = append(v.Seasons, d.hunt()) + } + v.StartRegularSeason = d.u64() + for n := d.count(); n > 0 && d.err == nil; n-- { + v.History = append(v.History, d.hunthistory()) + } + return v +} +func (e *encoder) cashproduct(v CashProduct) { + e.u64(v.GroupID) + e.u64(v.ProductID) + e.u64(v.SaleGroup) + e.str(v.Start) + e.str(v.End) + e.u64(v.EndDelayMinutes) + e.u64(v.EventIndex) +} +func (d *decoder) cashproduct() CashProduct { + var v CashProduct + v.GroupID = d.u64() + v.ProductID = d.u64() + v.SaleGroup = d.u64() + v.Start = d.str() + v.End = d.str() + v.EndDelayMinutes = d.u64() + v.EventIndex = d.u64() + return v +} +func (e *encoder) hubsetting(v HubSetting) { + e.u64(v.Slot) + e.u64(v.ProgressType) + e.count(len(v.EventUIDs)) + for _, item := range v.EventUIDs { + e.u64(item) + } +} +func (d *decoder) hubsetting() HubSetting { + var v HubSetting + v.Slot = d.u64() + v.ProgressType = d.u64() + for n := d.count(); n > 0 && d.err == nil; n-- { + v.EventUIDs = append(v.EventUIDs, d.u64()) + } + return v +} +func (e *encoder) eventhub(v EventHub) { + e.u64(v.UID) + e.u64(v.HubID) + e.str(v.Start) + e.str(v.PlayEnd) + e.str(v.End) + e.count(len(v.Settings)) + for _, item := range v.Settings { + e.hubsetting(item) + } +} +func (d *decoder) eventhub() EventHub { + var v EventHub + v.UID = d.u64() + v.HubID = d.u64() + v.Start = d.str() + v.PlayEnd = d.str() + v.End = d.str() + for n := d.count(); n > 0 && d.err == nil; n-- { + v.Settings = append(v.Settings, d.hubsetting()) + } + return v +} +func (e *encoder) minigamehub(v MiniGameHub) { + e.u64(v.Slot) + e.u64(v.EventUID) + e.u64(v.ProgressType) +} +func (d *decoder) minigamehub() MiniGameHub { + var v MiniGameHub + v.Slot = d.u64() + v.EventUID = d.u64() + v.ProgressType = d.u64() + return v +} +func (e *encoder) regularseason(v schedule.RegularSeason) { + e.u64(v.ContentID) + e.u64(v.Season) +} +func (d *decoder) regularseason() schedule.RegularSeason { + var v schedule.RegularSeason + v.ContentID = d.u64() + v.Season = d.u64() + return v +} diff --git a/go/internal/server/calendar/schema.go b/go/internal/server/calendar/schema.go new file mode 100644 index 0000000..491c275 --- /dev/null +++ b/go/internal/server/calendar/schema.go @@ -0,0 +1,115 @@ +// Package calendar reads the project's independently distributed calendars. +package calendar + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gacha" + "bd2server/internal/server/schedule" +) + +type Manifest struct { + SchemaVersion int + Revision string + GameVersion string + GameDataVersion string + Events []Event + Gacha []Gacha + StepUps []Gacha + Regular *Regular + MonsterHunt *MonsterHunt + CashProducts []CashProduct + EventHubs []EventHub + MiniGameHubs []MiniGameHub +} +type Event struct { + UID uint64 + Type uint64 + ID uint64 + SubID uint64 + Start string + End string +} +type Gacha struct { + GroupID uint64 + Start string + End string + FreeCountBonus bool + CashCountBonus bool +} +type Season struct { + ID uint64 + Start string + End string + RankRewardGroupID uint64 + Error bool + Return bool +} +type Content struct { + ID uint64 + Current Season + Next Season +} +type Regular struct { + CalculateMilliseconds uint64 + Contents []Content + Regular []schedule.RegularSeason +} +type Hunt struct { + Season Season + HuntID uint64 + InfoOpenDay uint64 + CalculateEndAt string + ErrorFlag bool + IndependentFlag bool + RankRewardGroupID uint64 + CostumeBanIDs []uint64 + BurstBanIDs []uint64 +} +type HuntHistory struct { + Season uint64 + HuntID uint64 + ErrorFlag bool + Hidden bool +} +type MonsterHunt struct { + Seasons []Hunt + StartRegularSeason uint64 + History []HuntHistory +} +type CashProduct struct { + GroupID uint64 + ProductID uint64 + SaleGroup uint64 + Start string + End string + EndDelayMinutes uint64 + EventIndex uint64 +} +type HubSetting struct { + Slot uint64 + ProgressType uint64 + EventUIDs []uint64 +} +type EventHub struct { + UID uint64 + HubID uint64 + Start string + PlayEnd string + End string + Settings []HubSetting +} +type MiniGameHub struct { + Slot uint64 + EventUID uint64 + ProgressType uint64 +} +type Set struct { + Events []events.Schedule + GachaSeed *gacha.ScheduleSeed + RegularService *schedule.Service + MonsterHunt *MonsterHunt + CashProducts []CashProduct + Revisions []string + EventHubs []EventHub + MiniGameHubs []MiniGameHub +} diff --git a/go/internal/server/commerce/attendance.go b/go/internal/server/commerce/attendance.go new file mode 100644 index 0000000..c6da918 --- /dev/null +++ b/go/internal/server/commerce/attendance.go @@ -0,0 +1,96 @@ +package commerce + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type attendanceHandler interface { + Handle(string, []byte) (int, []byte, bool, error) +} + +// AttendanceHandler preserves the original event progress response and adds +// subscription grants in extension fields understood by the commerce plugin. +// All operations execute inside the transport's account transaction. +type AttendanceHandler struct { + Events attendanceHandler + Economy *EntitlementEconomy + LoginPasses *LoginPasses + Store stateio.Store +} + +type attendanceReceipt struct { + Digest string `json:"digest"` + Bundle []byte `json:"bundle"` +} + +func (h AttendanceHandler) Handle(path string, request []byte) (int, []byte, bool, error) { + return h.HandleSession(path, request, "") +} + +func (h AttendanceHandler) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { + if path != "/Attendance" { + return 0, nil, false, nil + } + if session == "" || h.Events == nil || h.Economy == nil || h.Store == nil { + return 0, nil, true, fmt.Errorf("commerce: attendance dependencies/session unavailable") + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, fmt.Errorf("commerce: missing attendance sequence") + } + code, response, handled, err := h.Events.Handle(path, request) + if err != nil || !handled { + return code, response, handled, err + } + key := fmt.Sprintf("commerce_attendance:%x:%d", sha256.Sum256([]byte(session)), seq) + digest := fmt.Sprintf("%x", sha256.Sum256(request)) + var receipt attendanceReceipt + previous, err := h.Store.Load(key) + if err != nil { + return code, nil, true, err + } + var loginBundle []byte + if h.LoginPasses != nil { + var infos [][]byte + loginBundle, infos, err = h.LoginPasses.ClaimAndInfo(key) + if err != nil { + return code, nil, true, err + } + for _, info := range infos { + response = wire.AppendBytes(response, 6, info) + } + } + if previous != nil { + if err = json.Unmarshal(previous, &receipt); err != nil || receipt.Digest != digest { + return code, nil, true, fmt.Errorf("commerce: conflicting attendance replay") + } + } else { + receipt.Digest = digest + receipt.Bundle, err = h.Economy.ClaimSubscriptions(key) + if err != nil { + return code, nil, true, err + } + receipt.Bundle = append(receipt.Bundle, loginBundle...) + raw, err := json.Marshal(receipt) + if err != nil { + return code, nil, true, err + } + if err = h.Store.Save(key, raw); err != nil { + return code, nil, true, err + } + } + response, err = h.Economy.MergeAttendance(response) + if err != nil { + return code, nil, true, err + } + if len(receipt.Bundle) != 0 { + response = wire.AppendBytes(response, 1001, receipt.Bundle) + response = wire.AppendString(response, 1002, key) + } + return code, response, true, nil +} diff --git a/go/internal/server/commerce/attendance_test.go b/go/internal/server/commerce/attendance_test.go new file mode 100644 index 0000000..ce6fc43 --- /dev/null +++ b/go/internal/server/commerce/attendance_test.go @@ -0,0 +1,101 @@ +package commerce + +import ( + "bytes" + "testing" + "time" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" +) + +type originalAttendance struct{} + +func (originalAttendance) Handle(path string, _ []byte) (int, []byte, bool, error) { + return 0, wire.AppendVarint(nil, 55, 123), path == "/Attendance", nil +} + +func TestAttendanceExtensionReplaysRewardAndPreservesNativeResponse(t *testing.T) { + e, _, base, _, now := entitlementFixture(t) + if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil { + t.Fatal(err) + } + *now = now.Add(24 * time.Hour) + h := AttendanceHandler{Events: originalAttendance{}, Economy: e, Store: e.store} + request := wire.AppendVarint(nil, 1, 1) + _, response, handled, err := h.HandleSession("/Attendance", request, "session") + if err != nil || !handled { + t.Fatal(err) + } + if v, ok, _ := wire.Varint(response, 55); !ok || v != 123 { + t.Fatal("native response lost") + } + bundle, ok, err := wire.Bytes(response, 1001) + if err != nil || !ok || len(bundle) == 0 { + t.Fatal("reward extension missing") + } + if receipt, ok, _ := wire.Bytes(response, 1002); !ok || len(receipt) == 0 { + t.Fatal("receipt extension missing") + } + calls := base.Calls + _, replay, _, err := h.HandleSession("/Attendance", request, "session") + if err != nil || !bytes.Equal(response, replay) || base.Calls != calls { + t.Fatal("retry lost reward or granted twice", err) + } + _, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "session") + if err != nil { + t.Fatal(err) + } + if _, ok, _ := wire.Bytes(next, 1001); ok || base.Calls != calls { + t.Fatal("same day duplicate grant") + } + _, _, _, err = h.HandleSession("/Attendance", append(request, wire.AppendVarint(nil, 2, 1)...), "session") + if err == nil { + t.Fatal("conflicting sequence accepted") + } +} + +func TestAttendanceCombinesLoginPassAndSubscriptionWithoutDuplicateClaims(t *testing.T) { + e, items, base, _, now := entitlementFixture(t) + if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil { + t.Fatal(err) + } + *now = now.Add(24 * time.Hour) + design := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{ + 10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}, Premium: gamedata.Reward{Type: 3, Count: 22}}}, + }} + passes, err := NewLoginPasses(e.store, design, e, items, func(group uint64) bool { return group == 10 }) + if err != nil { + t.Fatal(err) + } + passes.SetClock(func() time.Time { return *now }, 0) + h := AttendanceHandler{Events: originalAttendance{}, Economy: e, LoginPasses: passes, Store: e.store} + calls := base.Calls + request := wire.AppendVarint(nil, 1, 1) + _, response, _, err := h.HandleSession("/Attendance", request, "combined-session") + if err != nil || base.Calls != calls+2 { + t.Fatalf("daily claims not combined: calls=%d err=%v", base.Calls-calls, err) + } + info, ok, err := wire.Bytes(response, 6) + if err != nil || !ok { + t.Fatal("login-pass metadata lost", err) + } + if group, _, _ := wire.Varint(info, 1); group != 10 { + t.Fatal("wrong login-pass group") + } + bundle, ok, err := wire.Bytes(response, 1001) + if err != nil || !ok || len(bundle) == 0 { + t.Fatal("combined reward envelope missing", err) + } + _, replay, _, err := h.HandleSession("/Attendance", request, "combined-session") + if err != nil || !bytes.Equal(replay, response) || base.Calls != calls+2 { + t.Fatal("retry changed the envelope or repeated claims", err) + } + _, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session") + if err != nil || base.Calls != calls+2 { + t.Fatal("fresh request claimed the same day again", err) + } + if _, ok, _ := wire.Bytes(next, 1001); ok { + t.Fatal("fresh request replayed old rewards") + } +} diff --git a/go/internal/server/commerce/catalog.go b/go/internal/server/commerce/catalog.go new file mode 100644 index 0000000..6835c73 --- /dev/null +++ b/go/internal/server/commerce/catalog.go @@ -0,0 +1,202 @@ +// Package commerce exposes server-authoritative prices for SDK cash goods. +package commerce + +import ( + "fmt" + "math" + "sort" + + "bd2server/internal/server/gameconfig" + "bd2server/internal/server/gamedata" +) + +type Product struct { + SKU string `json:"sku"` + GroupID uint64 `json:"group_id"` + ProductID uint64 `json:"product_id"` + SaleGroup uint64 `json:"sale_group"` + ItemType uint64 `json:"item_type"` + Amount uint64 `json:"amount"` + Recharge bool `json:"recharge"` + Enabled bool `json:"enabled"` + Currency string `json:"currency"` + Cost uint64 `json:"cost"` + PaidDiamondPrice uint64 `json:"paid_diamond_price"` +} +type Manifest struct { + SchemaVersion int `json:"schema_version"` + GameVersion string `json:"game_version"` + Products []Product `json:"products"` +} +type Catalog struct { + version string + designs map[gamedata.CashProductKey]gamedata.CashProductDesign + quotes map[gamedata.CashProductKey]Product + manifest []Product +} + +func NewCatalog(version string, design *gamedata.CashCatalog, cfg gameconfig.PurchasesConfig) (*Catalog, error) { + validation := gameconfig.Default() + validation.Purchases = cfg + if err := validation.Validate(); err != nil { + return nil, err + } + if version == "" || design == nil { + return nil, fmt.Errorf("commerce: missing cash catalog/version") + } + c := &Catalog{version: version, designs: map[gamedata.CashProductKey]gamedata.CashProductDesign{}, quotes: map[gamedata.CashProductKey]Product{}} + // Select the smallest regular recharge amount; its cash/diamond ratio is + // the conservative conversion baseline. GameData provides both quantities. + var baseAmount, basePrice uint64 + for _, p := range design.Products { + if !p.Recharge || p.PriceType != 1 { + continue + } + if p.NominalPaidDiamonds == 0 || p.PriceCount == 0 { + return nil, fmt.Errorf("commerce: invalid recharge baseline") + } + if baseAmount == 0 || p.NominalPaidDiamonds < baseAmount || p.NominalPaidDiamonds == baseAmount && p.PriceCount > basePrice { + baseAmount = p.NominalPaidDiamonds + basePrice = p.PriceCount + } + } + if baseAmount == 0 { + return nil, fmt.Errorf("commerce: no regular diamond recharge conversion baseline") + } + for _, p := range design.Products { + if _, exists := c.designs[p.Key]; exists { + return nil, fmt.Errorf("commerce: duplicate product %+v", p.Key) + } + c.designs[p.Key] = p + if p.PriceType != 1 { + continue + } // native wallet goods already use their own path + price, err := ceilRatio(p.PriceCount, baseAmount, basePrice) + if err != nil { + return nil, err + } + q := Product{GroupID: p.Key.GroupID, ProductID: p.Key.ProductID, SaleGroup: p.Key.SaleGroup, Recharge: p.Recharge, Enabled: true, Currency: "paid_diamonds", ItemType: 2, Amount: price, Cost: price, PaidDiamondPrice: price} + if p.Recharge { + switch cfg.DiamondRecharge.Currency { + case "free": + q.Currency = "free" + q.ItemType = 0 + q.Amount = 0 + case "gold": + q.Currency = "gold" + q.ItemType = 4 + q.Amount, err = boundedProduct(p.NominalPaidDiamonds, uint64(cfg.DiamondRecharge.GoldPerPaidDiamond)) + case "diamonds": + q.Currency = "diamonds" + q.ItemType = 3 + q.Amount, err = boundedProduct(p.NominalPaidDiamonds, uint64(cfg.DiamondRecharge.DiamondsPerPaidDiamond)) + case "ban", "": + q.Currency = "ban" + q.Enabled = false + q.ItemType = 0 + q.Amount = 0 + } + if err != nil { + return nil, fmt.Errorf("commerce: recharge %+v: %w", p.Key, err) + } + q.Cost = q.Amount + } + aliases := []string{p.GoogleSKU} + if p.AppleSKU != p.GoogleSKU { + aliases = append(aliases, p.AppleSKU) + } + for _, sku := range aliases { + if sku == "" { + continue + } + q.SKU = sku + c.manifest = append(c.manifest, q) + } + q.SKU = p.GoogleSKU + if q.SKU == "" { + q.SKU = p.AppleSKU + } + if q.SKU == "" { + return nil, fmt.Errorf("commerce: cash product has no SKU") + } + c.quotes[p.Key] = q + } + sort.Slice(c.manifest, func(i, j int) bool { + a, b := c.manifest[i], c.manifest[j] + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + if a.ProductID != b.ProductID { + return a.ProductID < b.ProductID + } + if a.SaleGroup != b.SaleGroup { + return a.SaleGroup < b.SaleGroup + } + return a.SKU < b.SKU + }) + return c, nil +} +func ceilRatio(value, numerator, denominator uint64) (uint64, error) { + if value == 0 || numerator == 0 || denominator == 0 || value > math.MaxUint64/numerator { + return 0, fmt.Errorf("commerce: invalid or overflowing monetary conversion") + } + n := value * numerator + result := n / denominator + if n%denominator != 0 { + result++ + } + if result == 0 || result > math.MaxInt32 { + return 0, fmt.Errorf("commerce: price outside supported wallet range") + } + return result, nil +} +func boundedProduct(a, b uint64) (uint64, error) { + if a == 0 || b == 0 || a > math.MaxInt32/b { + return 0, fmt.Errorf("commerce: purchase cost exceeds supported wallet range") + } + return a * b, nil +} +func (c *Catalog) Manifest() Manifest { + return Manifest{SchemaVersion: 1, GameVersion: c.version, Products: append([]Product{}, c.manifest...)} +} +func (c *Catalog) Design(key gamedata.CashProductKey) (gamedata.CashProductDesign, bool) { + p, ok := c.designs[key] + return p, ok +} +func (c *Catalog) Designs() []gamedata.CashProductDesign { + out := make([]gamedata.CashProductDesign, 0, len(c.designs)) + for _, p := range c.designs { + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i].Key, out[j].Key + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + if a.ProductID != b.ProductID { + return a.ProductID < b.ProductID + } + return a.SaleGroup < b.SaleGroup + }) + return out +} +func (c *Catalog) Quote(key gamedata.CashProductKey, count uint64) (Product, error) { + p, ok := c.quotes[key] + if !ok { + return Product{}, fmt.Errorf("commerce: unknown SDK cash product %+v", key) + } + if !p.Enabled { + return Product{}, fmt.Errorf("commerce: paid-diamond recharge is disabled") + } + if count == 0 || count > math.MaxInt32 { + return Product{}, fmt.Errorf("commerce: invalid purchase count") + } + if p.Amount != 0 { + cost, err := boundedProduct(p.Amount, count) + if err != nil { + return Product{}, err + } + p.Cost = cost + } + return p, nil +} diff --git a/go/internal/server/commerce/catalog_test.go b/go/internal/server/commerce/catalog_test.go new file mode 100644 index 0000000..e47d5e8 --- /dev/null +++ b/go/internal/server/commerce/catalog_test.go @@ -0,0 +1,95 @@ +package commerce + +import ( + "bd2server/internal/server/gameconfig" + "bd2server/internal/server/gamedata" + "math" + "testing" +) + +func testDesign() *gamedata.CashCatalog { + return &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{ + {Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100}, + {Key: gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100}, + {Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "skin", AppleSKU: "skin-ios", PriceType: 1, PriceCount: 5501}, + {Key: gamedata.CashProductKey{GroupID: 3, ProductID: 1}, GoogleSKU: "native", PriceType: 2, PriceCount: 100}, + }} +} +func TestCashPricesAliasesAndIsolation(t *testing.T) { + for _, currency := range []string{"free", "gold", "diamonds", "ban", ""} { + cfg := gameconfig.Default().Purchases + cfg.DiamondRecharge.Currency = currency + c, err := NewCatalog("test", testDesign(), cfg) + if err != nil { + t.Fatal(err) + } + m := c.Manifest() + if m.SchemaVersion != 1 || len(m.Products) != 6 { + t.Fatalf("manifest %+v", m) + } + skin, err := c.Quote(gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, 2) + if err != nil || skin.Cost != 502 || skin.ItemType != 2 || skin.Currency != "paid_diamonds" { + t.Fatalf("ordinary goods affected by %q: %+v %v", currency, skin, err) + } + recharge, err := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, 1) + if currency == "ban" || currency == "" { + if err == nil { + t.Fatal("disabled recharge accepted") + } + continue + } + if err != nil { + t.Fatal(err) + } + want := uint64(0) + if currency == "gold" { + want = 100000 + } + if currency == "diamonds" { + want = 100 + } + if recharge.Cost != want { + t.Fatalf("currency %s cost=%d", currency, recharge.Cost) + } + second, _ := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, 1) + if second.Cost != recharge.Cost { + t.Fatal("first purchase incorrectly doubled cost") + } + m.Products[0].SKU = "changed" + if c.Manifest().Products[0].SKU == "changed" { + t.Fatal("manifest mutable through caller") + } + if _, err = c.Quote(gamedata.CashProductKey{GroupID: 3, ProductID: 1}, 1); err == nil { + t.Fatal("native wallet item intercepted") + } + } +} +func TestMonetaryBoundsAndBaseline(t *testing.T) { + for _, v := range [][3]uint64{{math.MaxUint64, 2, 1}, {1, 0, 1}, {1, 1, 0}, {math.MaxInt32 + 1, 1, 1}} { + if _, err := ceilRatio(v[0], v[1], v[2]); err == nil { + t.Fatalf("accepted %v", v) + } + } + d := testDesign() + d.Products[0].PriceCount = 2000 + d.Products[1].PriceCount = 2000 + c, err := NewCatalog("test", d, gameconfig.Default().Purchases) + if err != nil { + t.Fatal(err) + } + p, _ := c.Quote(d.Products[2].Key, 1) + if p.Cost != 276 { + t.Fatalf("baseline not derived dynamically: %+v", p) + } + if _, err = c.Quote(d.Products[2].Key, math.MaxInt32); err == nil { + t.Fatal("overflow accepted") + } + if _, err = c.Quote(d.Products[2].Key, 0); err == nil { + t.Fatal("zero purchase accepted") + } + d.Products[0].Recharge = false + d.Products[1].Recharge = false + if _, err = NewCatalog("test", d, gameconfig.Default().Purchases); err == nil { + t.Fatal("missing baseline accepted") + } +} diff --git a/go/internal/server/commerce/clear_packages.go b/go/internal/server/commerce/clear_packages.go new file mode 100644 index 0000000..cba6cbd --- /dev/null +++ b/go/internal/server/commerce/clear_packages.go @@ -0,0 +1,186 @@ +package commerce + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "encoding/json" + "fmt" + "sort" + "sync" + "time" +) + +type ClearPackageInventory interface{ All() []player.Item } +type clearClaim struct{ Kind, GroupID, TicketID, TargetID, Level uint64 } +type clearClaimReceipt struct { + Claim clearClaim `json:"claim"` + Response []byte `json:"response"` +} +type ClearPackages struct { + mu sync.Mutex + store stateio.Store + economy Economy + items ClearPackageInventory + design map[clearClaim]gamedata.ClearPackageRewardDesign + packCleared func(uint64, uint64) bool + towerCleared func(uint64, uint64) bool + now func() time.Time +} + +func NewClearPackages(store stateio.Store, design *gamedata.ClearPackageCatalog, economy Economy, items ClearPackageInventory) (*ClearPackages, error) { + if store == nil || design == nil || economy == nil || items == nil { + return nil, fmt.Errorf("commerce: invalid clear package dependencies") + } + s := &ClearPackages{store: store, economy: economy, items: items, design: map[clearClaim]gamedata.ClearPackageRewardDesign{}, now: time.Now} + for _, r := range design.Rewards { + k := clearClaim{r.Kind, r.GroupID, r.TicketID, r.TargetID, r.Level} + if _, ok := s.design[k]; ok { + return nil, fmt.Errorf("commerce: duplicate clear reward") + } + s.design[k] = r + } + _, err := s.load() + return s, err +} +func (s *ClearPackages) AttachProgress(pack, tower func(uint64, uint64) bool) { + s.packCleared = pack + s.towerCleared = tower +} +func (s *ClearPackages) load() (map[string]clearClaimReceipt, error) { + v := map[string]clearClaimReceipt{} + raw, err := s.store.Load("commerce_clear_claims") + if err != nil || raw == nil { + return v, err + } + err = json.Unmarshal(raw, &v) + if err == nil && v == nil { + err = fmt.Errorf("commerce: invalid clear claim state") + } + if err == nil { + for identity, receipt := range v { + if identity != clearClaimID(receipt.Claim) || len(receipt.Response) == 0 { + err = fmt.Errorf("commerce: invalid saved clear claim") + break + } + if _, ok := s.design[receipt.Claim]; !ok { + err = fmt.Errorf("commerce: unknown saved clear claim") + break + } + } + } + return v, err +} +func clearClaimID(c clearClaim) string { + return fmt.Sprintf("clear-package:%d:%d:%d:%d:%d", c.Kind, c.GroupID, c.TicketID, c.TargetID, c.Level) +} +func (s *ClearPackages) entitled(ticket uint64) bool { + for _, item := range s.items.All() { + if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(s.now().UnixMilli())) { + return true + } + } + return false +} +func (s *ClearPackages) Handle(path string, request []byte) (int, []byte, bool, error) { + return s.HandleSession(path, request, "") +} +func (s *ClearPackages) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { + if path != "/ClearPackageReward" { + return 0, nil, false, nil + } + seq, ok, err := wire.Varint(request, 1) + if err != nil || !ok || seq == 0 { + return 286, nil, true, fmt.Errorf("commerce: invalid clear claim sequence") + } + kind, _, err := wire.Varint(request, 2) + if err != nil || kind > 1 { + return 286, nil, true, fmt.Errorf("commerce: invalid clear claim type") + } + var nested []byte + err = wire.Walk(request, func(f wire.Field) error { + if f.Number == 3 || f.Number == 4 { + if int(f.Number) != int(kind)+3 || f.Type != 2 || nested != nil { + return fmt.Errorf("commerce: invalid clear claim row") + } + nested = append([]byte(nil), f.Value...) + } + return nil + }) + if err != nil || nested == nil { + return 286, nil, true, fmt.Errorf("commerce: missing clear claim row") + } + var values [4]uint64 + for i := range values { + values[i], _, err = wire.Varint(nested, i+1) + if err != nil { + return 286, nil, true, err + } + } + claim := clearClaim{kind, values[0], values[1], values[2], values[3]} + d, ok := s.design[claim] + if !ok { + return 286, nil, true, fmt.Errorf("commerce: unknown clear reward") + } + s.mu.Lock() + defer s.mu.Unlock() + v, err := s.load() + if err != nil { + return 286, nil, true, err + } + identity := clearClaimID(claim) + if receipt, ok := v[identity]; ok { + return 286, append([]byte(nil), receipt.Response...), true, nil + } + if d.Type == 1 && !s.entitled(d.TicketID) { + return 286, nil, true, fmt.Errorf("commerce: clear reward premium ticket required") + } + proof := s.packCleared + if kind == 1 { + proof = s.towerCleared + } + if proof == nil || !proof(d.TargetID, d.Level) { + return 286, nil, true, fmt.Errorf("commerce: clear reward progression incomplete") + } + bundle, err := s.economy.Apply(identity, nil, []gamedata.Reward{{Type: 9, ID: d.RandomBoxID, Count: 1}}) + if err != nil { + return 286, nil, true, err + } + response := wire.AppendBytes(nil, 1, bundle) + v[identity] = clearClaimReceipt{Claim: claim, Response: response} + raw, err := json.Marshal(v) + if err == nil { + err = s.store.Save("commerce_clear_claims", raw) + } + return 286, response, true, err +} + +// RewardDBInfos is attached to PackInfoResponse fields 3 and 4 so reconnects +// restore claimed reward buttons from server state. +func (s *ClearPackages) RewardDBInfos() (pack, evil [][]byte, err error) { + s.mu.Lock() + defer s.mu.Unlock() + v, err := s.load() + if err != nil { + return nil, nil, err + } + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + c := v[k].Claim + b := wire.AppendVarint(nil, 1, c.GroupID) + b = wire.AppendVarint(b, 2, c.TicketID) + b = wire.AppendVarint(b, 3, c.TargetID) + b = wire.AppendVarint(b, 4, c.Level) + if c.Kind == 0 { + pack = append(pack, b) + } else { + evil = append(evil, b) + } + } + return pack, evil, nil +} diff --git a/go/internal/server/commerce/clear_packages_test.go b/go/internal/server/commerce/clear_packages_test.go new file mode 100644 index 0000000..e26d61c --- /dev/null +++ b/go/internal/server/commerce/clear_packages_test.go @@ -0,0 +1,61 @@ +package commerce + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "testing" +) + +type clearInventory struct{ items []player.Item } + +func (i *clearInventory) All() []player.Item { return i.items } +func clearRequest(kind, ticket uint64) []byte { + b := wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 2, kind) + row := wire.AppendVarint(nil, 1, 10) + row = wire.AppendVarint(row, 2, ticket) + row = wire.AppendVarint(row, 3, 2) + row = wire.AppendVarint(row, 4, 0) + return wire.AppendBytes(b, int(kind)+3, row) +} +func TestClearPackageRequiresServerProgressAndPremiumEntitlement(t *testing.T) { + store := stateio.NewMemory() + eco := &purchaseEconomy{} + items := &clearInventory{} + design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{Kind: 0, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100, Type: 0}, {Kind: 0, GroupID: 10, TicketID: 77, TargetID: 2, RandomBoxID: 101, Type: 1}, {Kind: 1, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 102, Type: 0}}} + s, err := NewClearPackages(store, design, eco, items) + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 12)); err == nil || eco.calls != 0 { + t.Fatal("client spoofed clear progress") + } + s.AttachProgress(func(pack, level uint64) bool { return pack == 2 && level == 0 }, nil) + if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err == nil { + t.Fatal("unpaid premium claim accepted") + } + code, _, ok, err := s.Handle("/ClearPackageReward", clearRequest(0, 12)) + if err != nil || !ok || code != 286 || eco.rewards[0].ID != 100 { + t.Fatal(code, err) + } + items.items = []player.Item{{Type: 19, ID: 77, Count: 1}} + if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil { + t.Fatal(err) + } + reloaded, err := NewClearPackages(store, design, eco, items) + if err != nil { + t.Fatal(err) + } + if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil || eco.calls != 2 { + t.Fatal("restart duplicated grant", err, eco.calls) + } + p, e, err := reloaded.RewardDBInfos() + if err != nil || len(p) != 2 || len(e) != 0 { + t.Fatal(p, e, err) + } + if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(1, 12)); err == nil { + t.Fatal("unimplemented evil progress granted reward") + } +} diff --git a/go/internal/server/commerce/entitlements.go b/go/internal/server/commerce/entitlements.go new file mode 100644 index 0000000..42a2183 --- /dev/null +++ b/go/internal/server/commerce/entitlements.go @@ -0,0 +1,323 @@ +package commerce + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "sync" + "time" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type grantedResolver interface { + ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error) +} +type entitlementReceipt struct { + Definition []byte `json:"definition"` + Bundle []byte `json:"bundle"` +} +type cashSubscription struct { + Start, Expiry int64 + Claimed uint64 + LastDay string +} +type entitlementState struct { + Receipts map[string]entitlementReceipt `json:"receipts"` + Subscriptions map[string]cashSubscription `json:"subscriptions"` +} +type EntitlementEconomy struct { + mu sync.Mutex + store stateio.Store + base Economy + graph grantedResolver + items *player.Inventory + design *gamedata.CashEntitlementDesign + now func() time.Time + resetSeconds int64 +} + +func NewEntitlementEconomy(store stateio.Store, base Economy, graph grantedResolver, items *player.Inventory, design *gamedata.CashEntitlementDesign) (*EntitlementEconomy, error) { + if store == nil || base == nil || graph == nil || items == nil || design == nil { + return nil, fmt.Errorf("commerce: missing entitlement dependency") + } + e := &EntitlementEconomy{store: store, base: base, graph: graph, items: items, design: design, now: time.Now} + _, err := e.load() + return e, err +} +func (e *EntitlementEconomy) SetClock(now func() time.Time, resetSeconds int64) { + e.now = now + e.resetSeconds = resetSeconds +} +func (e *EntitlementEconomy) day() string { + return e.now().UTC().Add(-time.Duration(e.resetSeconds) * time.Second).Format("2006-01-02") +} +func (e *EntitlementEconomy) load() (entitlementState, error) { + s := entitlementState{Receipts: map[string]entitlementReceipt{}, Subscriptions: map[string]cashSubscription{}} + raw, err := e.store.Load("commerce_entitlements") + if err != nil || raw == nil { + return s, err + } + if err = stateio.RequireExactJSONObject(raw, "receipts", "subscriptions"); err != nil { + return s, err + } + if err = json.Unmarshal(raw, &s); err != nil { + return s, err + } + if s.Receipts == nil || s.Subscriptions == nil { + return s, fmt.Errorf("commerce: malformed entitlement state") + } + for id, r := range s.Receipts { + if id == "" || len(r.Definition) != sha256.Size { + return s, fmt.Errorf("commerce: malformed entitlement receipt") + } + } + for id, sub := range s.Subscriptions { + ticket, err := strconv.ParseUint(id, 10, 64) + if err != nil || len(e.design.Attendance[ticket]) == 0 || sub.Start <= 0 || sub.Expiry != 0 && sub.Expiry < sub.Start || sub.Claimed == 0 || sub.LastDay == "" { + return s, fmt.Errorf("commerce: malformed subscription") + } + } + return s, nil +} +func (e *EntitlementEconomy) save(s entitlementState) error { + b, err := json.Marshal(s) + if err != nil { + return err + } + return e.store.Save("commerce_entitlements", b) +} +func (e *EntitlementEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + e.mu.Lock() + defer e.mu.Unlock() + return e.apply(identity, costs, rewards) +} +func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + if identity == "" { + return nil, fmt.Errorf("commerce: missing entitlement identity") + } + definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) + digest := sha256.Sum256(definition) + s, err := e.load() + if err != nil { + return nil, err + } + if r, ok := s.Receipts[identity]; ok { + if !bytes.Equal(r.Definition, digest[:]) { + return nil, fmt.Errorf("commerce: entitlement identity reused") + } + return append([]byte(nil), r.Bundle...), nil + } + input := make([]gamedata.BattleReward, len(rewards)) + for i, r := range rewards { + input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} + } + leaves, err := e.graph.ResolveGranted(input) + if err != nil { + return nil, err + } + // Some product boxes include the first attendance reward (draw-ticket + // subscriptions); others include only the upfront paid currency. Add only + // the missing first-day components on activation, then mark row one claimed. + initial := append([]gamedata.BattleReward(nil), leaves...) + for _, r := range initial { + rows := e.design.Attendance[r.ID] + if r.Type != 19 || len(rows) == 0 { + continue + } + sub := s.Subscriptions[strconv.FormatUint(r.ID, 10)] + if sub.Start != 0 && (sub.Expiry == 0 || sub.Expiry > e.now().UnixMilli()) { + continue + } + first, resolveErr := e.graph.ResolveGranted([]gamedata.BattleReward{rows[0].Reward}) + if resolveErr != nil { + return nil, resolveErr + } + available := map[[2]uint64]uint64{} + for _, leaf := range leaves { + k := [2]uint64{leaf.Type, leaf.ID} + available[k] += leaf.Count + } + for _, leaf := range first { + k := [2]uint64{leaf.Type, leaf.ID} + if available[k] < leaf.Count { + leaf.Count -= available[k] + leaves = append(leaves, leaf) + } + } + } + var regular []gamedata.Reward + var special []player.Item + for _, r := range leaves { + if r.Count == 0 || r.Count > math.MaxInt32 { + return nil, fmt.Errorf("commerce: invalid entitlement quantity") + } + switch { + case r.Type == 62: + if !e.design.AvatarSets[r.ID] { + return nil, fmt.Errorf("commerce: unknown avatar set %d", r.ID) + } + special = append(special, player.Item{Type: r.Type, ID: r.ID, Count: r.Count, TimeValue: uint64(e.now().UnixMilli())}) + case r.Type == 19 && e.design.TicketTypes[r.ID] == 2: + if len(e.design.Attendance[r.ID]) == 0 { + return nil, fmt.Errorf("commerce: subscription reward schedule missing") + } + now := e.now().UnixMilli() + expiry := int64(e.items.ContentTicketExpiry(r.ID)) + if expiry < now { + expiry = now + } + if r.Count > uint64((math.MaxInt64-expiry)/(30*86400000)) { + return nil, fmt.Errorf("commerce: subscription expiry overflow") + } + expiry += int64(r.Count) * 30 * 86400000 + special = append(special, player.Item{Type: 19, ID: r.ID, Count: r.Count, ExpiryTime: uint64(expiry), TimeValue: uint64(e.now().UnixMilli())}) + key := strconv.FormatUint(r.ID, 10) + sub := s.Subscriptions[key] + if sub.Start == 0 || sub.Expiry <= now { + sub = cashSubscription{Start: now, Claimed: 1, LastDay: e.day()} + } + sub.Expiry = expiry + s.Subscriptions[key] = sub + default: + regular = append(regular, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + if r.Type == 19 && e.design.TicketTypes[r.ID] == 3 && len(e.design.Attendance[r.ID]) > 0 { + key := strconv.FormatUint(r.ID, 10) + if _, exists := s.Subscriptions[key]; !exists { + s.Subscriptions[key] = cashSubscription{Start: e.now().UnixMilli(), Claimed: 1, LastDay: e.day()} + } + } + } + } + bundle, err := e.base.Apply(identity+":base", costs, regular) + if err != nil { + return nil, err + } + items, err := e.items.GrantCommerceOnce(identity+":special", special) + if err != nil { + return nil, err + } + for _, i := range items { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(i)) + bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{ID: i.ID, Type: i.Type, Count: i.Count})) + } + s.Receipts[identity] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)} + if err = e.save(s); err != nil { + return nil, err + } + return bundle, nil +} + +// ClaimSubscriptions grants the next row once per server reset day. Missing +// login days are not retroactively claimed. First-row purchase rewards are +// already present in the product box, matching the client's first-row marker. +func (e *EntitlementEconomy) ClaimSubscriptions(identity string) ([]byte, error) { + e.mu.Lock() + defer e.mu.Unlock() + if identity == "" { + return nil, fmt.Errorf("commerce: missing attendance receipt identity") + } + s, err := e.load() + if err != nil { + return nil, err + } + receiptKey := "attendance-reply:" + identity + if receipt, ok := s.Receipts[receiptKey]; ok { + return append([]byte(nil), receipt.Bundle...), nil + } + keys := make([]string, 0, len(s.Subscriptions)) + for k := range s.Subscriptions { + keys = append(keys, k) + } + sort.Strings(keys) + var bundle []byte + for _, key := range keys { + sub := s.Subscriptions[key] + ticket, _ := strconv.ParseUint(key, 10, 64) + rows := e.design.Attendance[ticket] + if sub.LastDay == e.day() || sub.Expiry != 0 && sub.Expiry <= e.now().UnixMilli() || sub.Expiry == 0 && sub.Claimed >= uint64(len(rows)) { + continue + } + index := sub.Claimed % uint64(len(rows)) + r := rows[index] + grant, err := e.apply(fmt.Sprintf("commerce:attendance:%s:%s", key, e.day()), nil, []gamedata.Reward{{Type: r.Reward.Type, ID: r.Reward.ID, Count: r.Reward.Count}}) + if err != nil { + return nil, err + } + bundle = append(bundle, grant...) + // apply persists its receipt; reload before saving progress so it survives. + latest, err := e.load() + if err != nil { + return nil, err + } + sub.Claimed++ + sub.LastDay = e.day() + latest.Subscriptions[key] = sub + if err = e.save(latest); err != nil { + return nil, err + } + s = latest + } + latest, err := e.load() + if err != nil { + return nil, err + } + digest := sha256.Sum256([]byte(receiptKey)) + latest.Receipts[receiptKey] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)} + if err = e.save(latest); err != nil { + return nil, err + } + return bundle, nil +} +func (e *EntitlementEconomy) MergeAttendance(response []byte) ([]byte, error) { + e.mu.Lock() + defer e.mu.Unlock() + s, err := e.load() + if err != nil { + return nil, err + } + out := append([]byte(nil), response...) + keys := make([]string, 0, len(s.Subscriptions)) + for k := range s.Subscriptions { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + sub := s.Subscriptions[key] + ticket, _ := strconv.ParseUint(key, 10, 64) + if sub.Expiry != 0 { + b := wire.AppendVarint(nil, 1, ticket) + b = wire.AppendVarint(b, 2, uint64(sub.Start)) + b = wire.AppendVarint(b, 3, uint64(sub.Expiry)) + out = wire.AppendBytes(out, 3, b) + } + if typ := e.design.AttendanceTypes[ticket]; typ != 0 { + rewards := []byte{} + n := sub.Claimed + if n > uint64(len(e.design.Attendance[ticket])) { + n = uint64(len(e.design.Attendance[ticket])) + } + for i := uint64(1); i <= n; i++ { + rewards = wire.AppendVarint(rewards, 1, i) + } + entry := wire.AppendVarint(nil, 1, typ) + entry = wire.AppendBytes(entry, 2, rewards) + out = wire.AppendBytes(out, 7, entry) + } else { + for i := uint64(1); i <= sub.Claimed; i++ { + b := wire.AppendVarint(nil, 1, ticket) + b = wire.AppendVarint(b, 2, i) + b = wire.AppendVarint(b, 3, 1) + out = wire.AppendBytes(out, 4, b) + } + } + } + return out, nil +} diff --git a/go/internal/server/commerce/entitlements_test.go b/go/internal/server/commerce/entitlements_test.go new file mode 100644 index 0000000..d7deb77 --- /dev/null +++ b/go/internal/server/commerce/entitlements_test.go @@ -0,0 +1,138 @@ +package commerce + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "testing" + "time" +) + +type entitlementGraph struct{ Calls int } + +func (g *entitlementGraph) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) { + g.Calls++ + return r, nil +} + +type entitlementBase struct { + Calls int + Rewards []gamedata.Reward +} + +func (b *entitlementBase) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { + b.Calls++ + b.Rewards = append(b.Rewards, r...) + return wire.AppendVarint(nil, 7, 1), nil +} +func entitlementFixture(t *testing.T) (*EntitlementEconomy, *player.Inventory, *entitlementBase, *entitlementGraph, *time.Time) { + t.Helper() + store := stateio.NewMemory() + items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + d := &gamedata.CashEntitlementDesign{AvatarSets: map[uint64]bool{10: true}, TicketTypes: map[uint64]uint64{38: 2, 99: 3}, Attendance: map[uint64][]gamedata.CashAttendanceReward{38: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 5}}}, 99: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 7}}}}, AttendanceTypes: map[uint64]uint64{38: 1}} + base := &entitlementBase{} + graph := &entitlementGraph{} + e, err := NewEntitlementEconomy(store, base, graph, items, d) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + e.SetClock(func() time.Time { return now }, 0) + return e, items, base, graph, &now +} +func TestEntitlementTypedSkinExpiryRetryAndRenewal(t *testing.T) { + e, items, base, graph, now := entitlementFixture(t) + rewards := []gamedata.Reward{{Type: 62, ID: 10, Count: 1}, {Type: 19, ID: 38, Count: 1}, {Type: 45, ID: 999, Count: 1}} + first, err := e.Apply("buy1", nil, rewards) + if err != nil { + t.Fatal(err) + } + if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+30*86400000) || len(base.Rewards) != 2 || base.Rewards[0].Type != 45 { + t.Fatal("expiry or prestige dispatch invalid") + } + again, err := e.Apply("buy1", nil, rewards) + if err != nil || !bytes.Equal(first, again) || graph.Calls != 2 || base.Calls != 1 { + t.Fatal("retry rerolled or double granted") + } + if _, err = e.Apply("buy1", nil, nil); err == nil { + t.Fatal("identity reuse accepted") + } + if _, err = e.Apply("invalid", nil, []gamedata.Reward{{Type: 62, ID: 888, Count: 1}}); err == nil || base.Calls != 1 { + t.Fatal("unknown skin partially applied") + } + if _, err = e.Apply("buy2", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil { + t.Fatal(err) + } + if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+60*86400000) { + t.Fatal("renewal lost prior period") + } + merged, err := e.MergeAttendance(nil) + if err != nil { + t.Fatal(err) + } + if _, ok, _ := wire.Bytes(merged, 3); !ok { + t.Fatal("subscription info missing") + } + if _, ok, _ := wire.Bytes(merged, 7); !ok { + t.Fatal("monthly reward map missing") + } +} +func TestSubscriptionDailyClaimResetExpiryAndPermanentCompletion(t *testing.T) { + e, _, base, _, now := entitlementFixture(t) + _, err := e.Apply("buy", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 19, ID: 99, Count: 1}}) + if err != nil { + t.Fatal(err) + } + calls := base.Calls + if _, err = e.ClaimSubscriptions("login"); err != nil || base.Calls != calls { + t.Fatal("purchase day double claim") + } + *now = now.Add(24 * time.Hour) + if _, err = e.ClaimSubscriptions("login2"); err != nil || base.Calls != calls+2 { + t.Fatalf("daily claims %v calls%d", err, base.Calls) + } + calls = base.Calls + if _, err = e.ClaimSubscriptions("retry"); err != nil || base.Calls != calls { + t.Fatal("daily retry grants again") + } + *now = now.Add(31 * 24 * time.Hour) + if _, err = e.ClaimSubscriptions("expired"); err != nil || base.Calls != calls { + t.Fatal("expired or completed claim") + } + merged, err := e.MergeAttendance(nil) + if err != nil { + t.Fatal(err) + } + if _, ok, _ := wire.Bytes(merged, 4); !ok { + t.Fatal("permanent attendance markers missing") + } +} +func TestSubscriptionFirstRewardAndLostReply(t *testing.T) { + e, _, base, _, now := entitlementFixture(t) + _, err := e.Apply("covered", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 3, Count: 1}}) + if err != nil { + t.Fatal(err) + } + if len(base.Rewards) != 1 || base.Rewards[0].Count != 1 { + t.Fatalf("first-day reward duplicated: %+v", base.Rewards) + } + *now = now.Add(24 * time.Hour) + first, err := e.ClaimSubscriptions("same-session-request") + if err != nil || len(first) == 0 { + t.Fatalf("claim %x %v", first, err) + } + calls := base.Calls + replay, err := e.ClaimSubscriptions("same-session-request") + if err != nil || !bytes.Equal(first, replay) || base.Calls != calls { + t.Fatal("lost reply not replayed exactly") + } + fresh, err := e.ClaimSubscriptions("new-request-same-day") + if err != nil || len(fresh) != 0 || base.Calls != calls { + t.Fatal("new same-day request repeated award") + } +} diff --git a/go/internal/server/commerce/installed_test.go b/go/internal/server/commerce/installed_test.go new file mode 100644 index 0000000..de2a6ac --- /dev/null +++ b/go/internal/server/commerce/installed_test.go @@ -0,0 +1,80 @@ +package commerce + +import ( + "os" + "testing" + + "bd2server/internal/server/gameconfig" + "bd2server/internal/server/gamedata" +) + +// Opt in against the installed release; synthetic tests also run without it. +func TestInstalledCashCatalogAndRewardPrograms(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not set") + } + design, err := gamedata.LoadCashCatalog(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + catalog, err := NewCatalog("2.35.10", design, gameconfig.Default().Purchases) + if err != nil { + t.Fatal(err) + } + rewards, err := gamedata.LoadCashRewardResolver(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + counts, recharge := 0, 0 + for _, product := range design.Products { + if product.PriceType != 1 { + continue + } + counts++ + if product.Recharge { + recharge++ + } + quote, err := catalog.Quote(product.Key, 1) + if err != nil { + t.Fatalf("quote %+v: %v", product.Key, err) + } + if product.Recharge && (quote.ItemType != 0 || quote.Amount != 0) { + t.Fatalf("default recharge isn't free: %+v", quote) + } + if !product.Recharge && (quote.ItemType != 2 || quote.Amount == 0) { + t.Fatalf("cash goods aren't paid diamond: %+v", quote) + } + // Infinite preview products use the gacha delegate, whose selected + // costumes replace weighted/empty placeholder reward programs. + if product.Key.GroupID == 1100001 { + continue + } + leaves, err := rewards.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: product.RandomBoxID, Count: 1}}) + if err != nil || len(leaves) == 0 { + t.Fatalf("product %+v: no reward program: %v", product.Key, err) + } + for _, leaf := range leaves { + if leaf.Type == 19 && entitlements.TicketTypes[leaf.ID] == 2 && len(entitlements.Attendance[leaf.ID]) == 0 { + t.Fatalf("monthly reward schedule missing: %d", leaf.ID) + } + if leaf.Type == 62 && !entitlements.AvatarSets[leaf.ID] { + t.Fatalf("avatar set missing: %d", leaf.ID) + } + } + } + if counts != 614 || recharge != 14 { + t.Fatalf("unexpected cash coverage: goods=%d recharge=%d", counts, recharge) + } + for _, key := range []gamedata.CashProductKey{{GroupID: 1100001, ProductID: 9100033}, {GroupID: 310001, ProductID: 507}, {GroupID: 310001, ProductID: 518}} { + quote, err := catalog.Quote(key, 1) + if err != nil || quote.Amount != 1000 || quote.ItemType != 2 { + t.Fatalf("sample %+v: %+v %v", key, quote, err) + } + } + t.Logf("Verified all %d cash products, %d recharge variants and %d platform aliases", counts, recharge, len(catalog.Manifest().Products)) +} diff --git a/go/internal/server/commerce/login_pass.go b/go/internal/server/commerce/login_pass.go new file mode 100644 index 0000000..1eda7cb --- /dev/null +++ b/go/internal/server/commerce/login_pass.go @@ -0,0 +1,157 @@ +package commerce + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "encoding/json" + "fmt" + "sort" + "sync" + "time" +) + +type loginPassProgress struct { + Free, Premium uint64 + LastDay string +} +type loginPassReceipt struct { + Bundle []byte + Rewarded map[uint64]bool +} +type loginPassState struct { + Progress map[uint64]loginPassProgress + Receipts map[string]loginPassReceipt +} +type LoginPasses struct { + mu sync.Mutex + store stateio.Store + design *gamedata.LoginPassCatalog + economy Economy + items ClearPackageInventory + available func(uint64) bool + now func() time.Time + resetSeconds int64 +} + +func NewLoginPasses(store stateio.Store, design *gamedata.LoginPassCatalog, economy Economy, items ClearPackageInventory, available func(uint64) bool) (*LoginPasses, error) { + if store == nil || design == nil || economy == nil || items == nil || available == nil { + return nil, fmt.Errorf("commerce: invalid login-pass dependencies") + } + s := &LoginPasses{store: store, design: design, economy: economy, items: items, available: available, now: time.Now} + _, err := s.load() + return s, err +} +func (s *LoginPasses) SetClock(now func() time.Time, resetSeconds int64) { + s.now = now + s.resetSeconds = resetSeconds +} +func (s *LoginPasses) load() (loginPassState, error) { + v := loginPassState{Progress: map[uint64]loginPassProgress{}, Receipts: map[string]loginPassReceipt{}} + raw, err := s.store.Load("commerce_login_passes") + if err != nil || raw == nil { + return v, err + } + if err = json.Unmarshal(raw, &v); err != nil { + return v, err + } + if v.Progress == nil || v.Receipts == nil { + return v, fmt.Errorf("commerce: invalid login-pass state") + } + for group, p := range v.Progress { + rows, ok := s.design.Groups[group] + if !ok || p.Free > uint64(len(rows)) || p.Premium > p.Free || p.Free > 0 && p.LastDay == "" { + return v, fmt.Errorf("commerce: invalid login-pass progress") + } + } + return v, nil +} +func (s *LoginPasses) paid(ticket uint64) bool { + for _, i := range s.items.All() { + if i.Type == 19 && i.ID == ticket && i.Count > 0 && (i.ExpiryTime == 0 || i.ExpiryTime > uint64(s.now().UnixMilli())) { + return true + } + } + return false +} + +// ClaimAndInfo is called by AttendanceHandler inside the account transaction. +// Free progression advances once per observed reset day, and buying premium +// catches up the already earned rows without advancing the login day count. +func (s *LoginPasses) ClaimAndInfo(identity string) ([]byte, [][]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if identity == "" { + return nil, nil, fmt.Errorf("commerce: missing login-pass identity") + } + v, err := s.load() + if err != nil { + return nil, nil, err + } + keys := make([]uint64, 0, len(s.design.Groups)) + for group := range s.design.Groups { + keys = append(keys, group) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + r, seen := v.Receipts[identity] + if !seen { + r = loginPassReceipt{Rewarded: map[uint64]bool{}} + var rewards []gamedata.Reward + day := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second).Format("2006-01-02") + for _, group := range keys { + if !s.available(group) { + continue + } + rows := s.design.Groups[group] + if len(rows) == 0 { + continue + } + p := v.Progress[group] + if p.LastDay != day && p.Free < uint64(len(rows)) { + rewards = append(rewards, rows[p.Free].Free) + p.Free++ + p.LastDay = day + r.Rewarded[group] = true + } + if s.paid(rows[0].TicketID) { + for p.Premium < p.Free { + rewards = append(rewards, rows[p.Premium].Premium) + p.Premium++ + r.Rewarded[group] = true + } + } + v.Progress[group] = p + } + if len(rewards) > 0 { + r.Bundle, err = s.economy.Apply("login-pass:"+identity, nil, rewards) + if err != nil { + return nil, nil, err + } + } + v.Receipts[identity] = r + raw, err := json.Marshal(v) + if err != nil { + return nil, nil, err + } + if err = s.store.Save("commerce_login_passes", raw); err != nil { + return nil, nil, err + } + } + var infos [][]byte + for _, group := range keys { + if !s.available(group) { + continue + } + p := v.Progress[group] + if p.Free == 0 { + continue + } + b := wire.AppendVarint(nil, 1, group) + b = wire.AppendVarint(b, 2, p.Free) + if r.Rewarded[group] { + b = wire.AppendVarint(b, 3, 1) + } + infos = append(infos, b) + } + return append([]byte(nil), r.Bundle...), infos, nil +} diff --git a/go/internal/server/commerce/login_pass_test.go b/go/internal/server/commerce/login_pass_test.go new file mode 100644 index 0000000..1785c5f --- /dev/null +++ b/go/internal/server/commerce/login_pass_test.go @@ -0,0 +1,68 @@ +package commerce + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "testing" + "time" +) + +type loginPassEconomy struct { + calls int + rewards [][]gamedata.Reward +} + +func (e *loginPassEconomy) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { + e.calls++ + e.rewards = append(e.rewards, append([]gamedata.Reward(nil), r...)) + return []byte{8, 1}, nil +} +func TestLoginPassFreeDaysPaidCatchupAndRestart(t *testing.T) { + d := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 100, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 200, Count: 1}}, {ID: 2, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 101, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 201, Count: 1}}}, 20: {{ID: 1, TicketID: 88, Free: gamedata.Reward{Type: 9, ID: 300, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 400, Count: 1}}}}} + store := stateio.NewMemory() + eco := &loginPassEconomy{} + items := &clearInventory{} + available := func(group uint64) bool { return group == 10 } + s, err := NewLoginPasses(store, d, eco, items, available) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) + s.SetClock(func() time.Time { return now }, 0) + bundle, infos, err := s.ClaimAndInfo("attendance1") + if err != nil || eco.calls != 1 || len(eco.rewards[0]) != 1 || eco.rewards[0][0].ID != 100 || len(infos) != 1 { + t.Fatal(err, eco.rewards, infos) + } + replay, _, err := s.ClaimAndInfo("attendance1") + if err != nil || !bytes.Equal(bundle, replay) || eco.calls != 1 { + t.Fatal("duplicate login grant", err) + } + _, infos, err = s.ClaimAndInfo("attendance2") + rewarded, _, _ := wire.Varint(infos[0], 3) + if err != nil || eco.calls != 1 || rewarded != 0 { + t.Fatal("same-day advanced") + } + items.items = []player.Item{{Type: 19, ID: 77, Count: 1}} + _, _, err = s.ClaimAndInfo("after-buy") + if err != nil || eco.calls != 2 || len(eco.rewards[1]) != 1 || eco.rewards[1][0].ID != 200 { + t.Fatal("premium catchup missing", err, eco.rewards) + } + now = now.Add(48 * time.Hour) + _, infos, err = s.ClaimAndInfo("next-login") + day, _, _ := wire.Varint(infos[0], 2) + if err != nil || day != 2 || eco.calls != 3 || len(eco.rewards[2]) != 2 { + t.Fatal("missed day incorrectly advanced", err, day) + } + s, err = NewLoginPasses(store, d, eco, items, available) + if err != nil { + t.Fatal(err) + } + s.SetClock(func() time.Time { return now }, 0) + _, _, err = s.ClaimAndInfo("restart") + if err != nil || eco.calls != 3 { + t.Fatal("restart advanced complete pass", err) + } +} diff --git a/go/internal/server/commerce/pack_info.go b/go/internal/server/commerce/pack_info.go new file mode 100644 index 0000000..c4130ba --- /dev/null +++ b/go/internal/server/commerce/pack_info.go @@ -0,0 +1,39 @@ +package commerce + +import "bd2server/internal/server/wire" + +type PackInfoHandler struct { + World attendanceHandler + Claims *ClearPackages +} + +func (h PackInfoHandler) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/PackInfo" { + return 0, nil, false, nil + } + code, response, handled, err := h.World.Handle(path, request) + if err != nil || !handled { + return code, response, handled, err + } + pack, evil, err := h.Claims.RewardDBInfos() + if err != nil { + return code, nil, true, err + } + var result []byte + err = wire.Walk(response, func(field wire.Field) error { + if field.Number != 3 && field.Number != 4 { + result = append(result, response[field.Start:field.End]...) + } + return nil + }) + if err != nil { + return code, nil, true, err + } + for _, info := range pack { + result = wire.AppendBytes(result, 3, info) + } + for _, info := range evil { + result = wire.AppendBytes(result, 4, info) + } + return code, result, true, nil +} diff --git a/go/internal/server/commerce/service.go b/go/internal/server/commerce/service.go new file mode 100644 index 0000000..cfb5a27 --- /dev/null +++ b/go/internal/server/commerce/service.go @@ -0,0 +1,695 @@ +package commerce + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "sync" + "time" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/readonly" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +// Economy and the receipt store must participate in the caller's account +// transaction. A failed hook, delegate or save then rolls back all purchases. +type Economy interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) +} +type CountProvider interface{ PurchaseCountDBInfos() [][]byte } +type PurchaseDelegate func(gamedata.CashProductKey, []byte) ([]byte, bool, error) +type PurchaseHook func(string, gamedata.CashProductDesign, uint64) error +type purchaseReceipt struct { + Digest string `json:"digest"` + Response []byte `json:"response"` +} +type purchaseCount struct { + Count uint64 `json:"count"` + Period string `json:"period"` + Lifetime uint64 `json:"lifetime"` +} +type purchaseState struct { + Receipts map[string]purchaseReceipt `json:"receipts"` + Billing map[string]string `json:"billing"` + Counts map[string]purchaseCount `json:"counts"` +} +type Service struct { + mu sync.Mutex + catalog *Catalog + store stateio.Store + economy Economy + now func() time.Time + resetSeconds int64 + delegate PurchaseDelegate + hook PurchaseHook + legacy CountProvider + shopProducts [][]byte + shopWindows map[gamedata.CashProductKey][2]uint64 + predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey +} + +func NewService(catalog *Catalog, store stateio.Store, economy Economy) (*Service, error) { + if catalog == nil || store == nil || economy == nil { + return nil, fmt.Errorf("commerce: invalid service dependencies") + } + s := &Service{catalog: catalog, store: store, economy: economy, now: time.Now} + _, err := s.load() + return s, err +} +func (s *Service) AttachDelegate(d PurchaseDelegate) { s.delegate = d } +func (s *Service) AttachPurchaseHook(h PurchaseHook) { s.hook = h } +func (s *Service) AttachLegacyCounts(p CountProvider) { s.legacy = p } + +// AttachPackageRules enforces the client's type-2/type-8 step and relay +// ordering using versioned contentsGroupId/contentsSortId, never SKU numbers. +func (s *Service) AttachPackageRules(packages []gamedata.CashPackageDesign) error { + rules := map[gamedata.CashProductKey][]gamedata.CashProductKey{} + for _, p := range packages { + if (p.PackageType != 2 && p.PackageType != 8) || p.ContentsGroupID == 0 || p.ContentsSortID <= 1 { + continue + } + key := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup} + var prior []gamedata.CashProductKey + for _, q := range packages { + if q.PackageType == p.PackageType && q.ContentsGroupID == p.ContentsGroupID && q.ContentsSortID == p.ContentsSortID-1 { + prior = append(prior, gamedata.CashProductKey{GroupID: q.GroupID, ProductID: q.ID, SaleGroup: q.SaleGroup}) + } + } + // Some current-version packages have a sort label above one but an + // independent contents group. Only a real same-group predecessor is a + // progression rule; do not invent links between adjacent product IDs. + if len(prior) == 0 { + continue + } + rules[key] = prior + } + s.predecessors = rules + return nil +} +func (s *Service) predecessorBought(v purchaseState, key gamedata.CashProductKey) bool { + prior := s.predecessors[key] + if len(prior) == 0 { + return true + } + for _, p := range prior { + if v.Counts[skuKey(p)].Lifetime > 0 { + return true + } + } + if s.legacy != nil { + for _, raw := range s.legacy.PurchaseCountDBInfos() { + g, _, _ := wire.Varint(raw, 1) + id, _, _ := wire.Varint(raw, 2) + sale, _, _ := wire.Varint(raw, 3) + n, _, _ := wire.Varint(raw, 4) + if n > 0 { + for _, p := range prior { + if p.GroupID == g && p.ProductID == id && p.SaleGroup == sale { + return true + } + } + } + } + } + return false +} + +// AttachShopSeed retains versioned dynamic windows and event identities. It +// must run before serving sessions; absent windows never authorize timed goods. +func (s *Service) AttachShopSeed(seed *readonly.Seed) error { + if seed == nil { + return fmt.Errorf("commerce: missing shop seed") + } + _, raw, handled, err := seed.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil || !handled { + return fmt.Errorf("commerce: invalid shop seed: %w", err) + } + windows := map[gamedata.CashProductKey][2]uint64{} + var products [][]byte + err = wire.Walk(raw, func(f wire.Field) error { + if f.Number != 1 { + return nil + } + if f.Type != 2 { + return fmt.Errorf("commerce: invalid shop product") + } + g, _, e := wire.Varint(f.Value, 1) + if e != nil { + return e + } + id, _, e := wire.Varint(f.Value, 2) + if e != nil { + return e + } + sale, _, e := wire.Varint(f.Value, 3) + if e != nil { + return e + } + start, _, e := wire.Varint(f.Value, 4) + if e != nil { + return e + } + end, _, e := wire.Varint(f.Value, 5) + if e != nil { + return e + } + k := gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale} + if _, ok := windows[k]; ok { + return fmt.Errorf("commerce: duplicate shop schedule") + } + if end != 0 && start >= end { + return fmt.Errorf("commerce: invalid shop window") + } + windows[k] = [2]uint64{start, end} + products = append(products, append([]byte(nil), f.Value...)) + return nil + }) + if err != nil { + return err + } + s.shopProducts = products + s.shopWindows = windows + return nil +} +func (s *Service) available(d gamedata.CashProductDesign) bool { + w, ok := s.shopWindows[d.Key] + if !ok { + return d.TimeLimitType == 0 + } + now := uint64(s.now().UnixMilli()) + return w[0] <= now && (w[1] == 0 || now < w[1]) +} +func (s *Service) IsAvailable(key gamedata.CashProductKey) bool { + d, ok := s.catalog.Design(key) + return ok && s.available(d) +} +func (s *Service) shopInfo() []byte { + var response []byte + for _, raw := range s.shopProducts { + g, _, _ := wire.Varint(raw, 1) + id, _, _ := wire.Varint(raw, 2) + sale, _, _ := wire.Varint(raw, 3) + d, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}) + if ok && s.available(d) { + response = wire.AppendBytes(response, 1, raw) + } + } + t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second) + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + daily := day.AddDate(0, 0, 1) + days := (8 - int(day.Weekday())) % 7 + if days == 0 { + days = 7 + } + weekly := day.AddDate(0, 0, days) + monthly := time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC) + for i, reset := range []time.Time{daily, weekly, monthly} { + response = wire.AppendVarint(response, i+2, uint64(reset.Add(time.Duration(s.resetSeconds)*time.Second).UnixMilli())) + } + return response +} +func (s *Service) SetClock(now func() time.Time, resetSeconds int64) { + s.now = now + s.resetSeconds = resetSeconds +} +func skuKey(k gamedata.CashProductKey) string { + return fmt.Sprintf("%d:%d:%d", k.GroupID, k.ProductID, k.SaleGroup) +} +func (s *Service) load() (purchaseState, error) { + v := purchaseState{Receipts: map[string]purchaseReceipt{}, Billing: map[string]string{}, Counts: map[string]purchaseCount{}} + b, e := s.store.Load("commerce") + if e != nil || b == nil { + return v, e + } + if e = stateio.RequireExactJSONObject(b, "receipts", "billing", "counts"); e != nil { + return v, e + } + if e = json.Unmarshal(b, &v); e != nil { + return v, e + } + if v.Receipts == nil || v.Billing == nil || v.Counts == nil { + return v, fmt.Errorf("commerce: invalid state") + } + for identity, receipt := range v.Receipts { + digest, err := hex.DecodeString(receipt.Digest) + if identity == "" || err != nil || len(digest) != sha256.Size || len(receipt.Response) == 0 { + return v, fmt.Errorf("commerce: invalid saved receipt") + } + } + for _, identity := range v.Billing { + if _, ok := v.Receipts[identity]; !ok { + return v, fmt.Errorf("commerce: billing identity has no purchase receipt") + } + } + for key, count := range v.Counts { + if key == "" || count.Period == "" || count.Count == 0 || count.Count > math.MaxInt32 || count.Lifetime < count.Count { + return v, fmt.Errorf("commerce: invalid saved purchase count") + } + } + return v, nil +} +func (s *Service) period(typ uint64) string { + t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second) + switch typ { + case 1: + return t.Format("2006-01-02") + case 2: + y, w := t.ISOWeek() + return fmt.Sprintf("%d-W%02d", y, w) + case 3: + return t.Format("2006-01") + default: + return "account" + } +} +func (s *Service) count(v purchaseState, d gamedata.CashProductDesign) uint64 { + c := v.Counts[skuKey(d.Key)] + if c.Period != s.period(d.PurchaseLimitType) { + return 0 + } + return c.Count +} +func (s *Service) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { + switch path { + case "/CashShopInfo": + if s.shopWindows == nil { + return 0, nil, false, nil + } + if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 { + return 60, nil, true, fmt.Errorf("commerce: invalid shop sequence") + } + return 60, s.shopInfo(), true, nil + case "/CashShopPurchaseCountInfo": + if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 { + return 432, nil, true, fmt.Errorf("commerce: invalid count sequence") + } + infos, err := s.purchaseCountDBInfos() + if err != nil { + return 0, nil, true, err + } + var b []byte + for _, i := range infos { + b = wire.AppendBytes(b, 1, i) + } + return 432, b, true, nil + case "/CashShopBuy": + return s.buy(session, request) + default: + return 0, nil, false, nil + } +} +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + return s.HandleSession(path, request, "") +} + +type buyLine struct { + Key gamedata.CashProductKey + Count uint64 +} + +func parseBuy(request []byte) (uint64, []buyLine, []string, error) { + seq, found, e := wire.Varint(request, 1) + if e != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, nil, fmt.Errorf("commerce: invalid sequence") + } + group, found, e := wire.Varint(request, 3) + if e != nil || !found || group == 0 { + return 0, nil, nil, fmt.Errorf("commerce: invalid product group") + } + var lines []buyLine + var billing []string + seen := map[gamedata.CashProductKey]bool{} + e = wire.Walk(request, func(f wire.Field) error { + switch f.Number { + case 4: + if f.Type != 2 { + return fmt.Errorf("commerce: invalid buy info") + } + id, ok, err := wire.Varint(f.Value, 1) + if err != nil || !ok || id == 0 { + return fmt.Errorf("commerce: invalid product id") + } + sale, _, err := wire.Varint(f.Value, 2) + if err != nil { + return err + } + n, ok, err := wire.Varint(f.Value, 3) + if err != nil || !ok || n == 0 || n > math.MaxInt32 { + return fmt.Errorf("commerce: invalid buy count") + } + k := gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale} + if seen[k] { + return fmt.Errorf("commerce: duplicate buy info") + } + seen[k] = true + lines = append(lines, buyLine{k, n}) + case 7: + if f.Type != 2 { + return fmt.Errorf("commerce: invalid billing info") + } + return wire.Walk(f.Value, func(b wire.Field) error { + if b.Number == 1 || b.Number == 2 { + if b.Type != 2 { + return fmt.Errorf("commerce: invalid billing identity") + } + if len(b.Value) > 16384 { + return fmt.Errorf("commerce: excessive billing identity") + } + if len(b.Value) > 0 { + h := sha256.Sum256(b.Value) + billing = append(billing, fmt.Sprintf("%d:%x", b.Number, h)) + } + } + return nil + }) + } + return nil + }) + if e == nil && len(lines) == 0 { + e = fmt.Errorf("commerce: empty purchase") + } + return seq, lines, billing, e +} +func (s *Service) buy(session string, request []byte) (int, []byte, bool, error) { + if session == "" { + return 61, nil, true, fmt.Errorf("commerce: authenticated session required") + } + seq, lines, billing, e := parseBuy(request) + if e != nil { + return 61, nil, true, e + } + var native, cash bool + for _, line := range lines { + if design, ok := s.catalog.Design(line.Key); ok { + if design.PriceType == 1 { + cash = true + } else { + native = true + } + } + } + if native && !cash { + return 0, nil, false, nil + } + if native { + return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase") + } + h := sha256.Sum256(request) + digest := hex.EncodeToString(h[:]) + sessionHash := sha256.Sum256([]byte(session)) + identity := fmt.Sprintf("commerce:%x:%d", sessionHash, seq) + s.mu.Lock() + defer s.mu.Unlock() + v, e := s.load() + if e != nil { + return 61, nil, true, e + } + if r, ok := v.Receipts[identity]; ok { + if r.Digest != digest { + return 61, nil, true, fmt.Errorf("commerce: sequence reused with different purchase") + } + return 61, append([]byte(nil), r.Response...), true, nil + } + for _, b := range billing { + if _, ok := v.Billing[b]; ok { + return 61, nil, true, fmt.Errorf("commerce: billing receipt already used") + } + } + // Validate every line before charging the first one. + designs := make([]gamedata.CashProductDesign, len(lines)) + quotes := make([]Product, len(lines)) + for i, l := range lines { + if !s.predecessorBought(v, l.Key) { + return 61, nil, true, fmt.Errorf("commerce: preceding package purchase required") + } + d, ok := s.catalog.Design(l.Key) + if !ok { + return 61, nil, true, fmt.Errorf("commerce: unknown product") + } + if !s.available(d) { + return 61, nil, true, fmt.Errorf("commerce: product is not currently available") + } + q, err := s.catalog.Quote(l.Key, l.Count) + if err != nil { + return 61, nil, true, err + } + if !q.Enabled { + return 61, nil, true, fmt.Errorf("commerce: product disabled") + } + if l.Count > 1 && d.BulkOrderAvailability == 0 { + return 61, nil, true, fmt.Errorf("commerce: bulk purchase disabled") + } + n := s.count(v, d) + if s.legacy != nil { + for _, raw := range s.legacy.PurchaseCountDBInfos() { + g, _, _ := wire.Varint(raw, 1) + id, _, _ := wire.Varint(raw, 2) + sale, _, _ := wire.Varint(raw, 3) + legacyCount, _, _ := wire.Varint(raw, 4) + if g == l.Key.GroupID && id == l.Key.ProductID && sale == l.Key.SaleGroup && legacyCount > n { + n = legacyCount + } + } + } + if d.PurchaseLimitType > 4 { + return 61, nil, true, fmt.Errorf("commerce: unknown purchase limit") + } + if n > math.MaxInt32-l.Count { + return 61, nil, true, fmt.Errorf("commerce: purchase count overflow") + } + if d.PurchaseLimitType != 0 && (d.PurchaseLimitCount == 0 || n+l.Count > d.PurchaseLimitCount) { + return 61, nil, true, fmt.Errorf("commerce: purchase limit exceeded") + } + designs[i] = d + quotes[i] = q + } + if e = validateAcceptedQuote(request, quotes); e != nil { + return 61, nil, true, e + } + var bundle []byte + for i, l := range lines { + d, q := designs[i], quotes[i] + operation := fmt.Sprintf("%s:%d", identity, i) + var costs []gamedata.Reward + if q.Cost > 0 { + var typ uint64 + switch q.Currency { + case "paid_diamonds": + typ = 2 + case "diamonds": + typ = 3 + case "gold": + typ = 4 + default: + return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency) + } + costs = []gamedata.Reward{{Type: typ, Count: q.Cost}} + } + var special []byte + handled := false + // Delegate must only select known special products. Parent account transaction + // guarantees its grant and the subsequent debit commit together. + if s.delegate != nil { + special, handled, e = s.delegate(l.Key, request) + if e != nil { + return 61, nil, true, e + } + if handled && (len(lines) != 1 || l.Count != 1) { + return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product") + } + } + var rewards []gamedata.Reward + if !handled { + if d.RandomBoxID == 0 { + return 61, nil, true, fmt.Errorf("commerce: product reward missing") + } + rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.RandomBoxID, Count: l.Count}) + if d.BonusRandomBoxID != 0 { + rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.BonusRandomBoxID, Count: l.Count}) + } + } + b, err := s.economy.Apply(operation, costs, rewards) + if err != nil { + return 61, nil, true, err + } + bundle = append(bundle, b...) + bundle = append(bundle, special...) + if s.hook != nil { + if err = s.hook(operation, d, l.Count); err != nil { + return 61, nil, true, err + } + } + old := v.Counts[skuKey(l.Key)] + if old.Lifetime > math.MaxUint64-l.Count { + return 61, nil, true, fmt.Errorf("commerce: lifetime purchase count overflow") + } + v.Counts[skuKey(l.Key)] = purchaseCount{Count: s.count(v, d) + l.Count, Period: s.period(d.PurchaseLimitType), Lifetime: old.Lifetime + l.Count} + } + response := wire.AppendBytes(nil, 1, bundle) + v.Receipts[identity] = purchaseReceipt{digest, response} + for _, b := range billing { + v.Billing[b] = identity + } + encoded, e := json.Marshal(v) + if e == nil { + e = s.store.Save("commerce", encoded) + } + return 61, response, true, e +} + +// The receipt repeats the client-visible quote for stale-catalog detection. +// Prices always come from Catalog, and a mismatch never changes the debit. +func validateAcceptedQuote(request []byte, quotes []Product) error { + var pay, receipt string + seen := false + err := wire.Walk(request, func(f wire.Field) error { + if f.Number != 7 { + return nil + } + if seen || f.Type != 2 { + return fmt.Errorf("commerce: invalid billing info") + } + seen = true + return wire.Walk(f.Value, func(b wire.Field) error { + if b.Number == 1 { + if b.Type != 2 || pay != "" { + return fmt.Errorf("commerce: invalid pay id") + } + pay = string(b.Value) + } + if b.Number == 2 { + if b.Type != 2 || receipt != "" { + return fmt.Errorf("commerce: invalid receipt") + } + receipt = string(b.Value) + } + return nil + }) + }) + if err != nil { + return err + } + parts := strings.Split(receipt, ":") + if len(parts) != 4 || parts[0] != "bd2-local-commerce-v1" || parts[1] != pay { + return fmt.Errorf("commerce: local billing quote required") + } + payID, err := strconv.ParseUint(pay, 10, 64) + if err != nil || payID == 0 || strconv.FormatUint(payID, 10) != pay { + return fmt.Errorf("commerce: invalid local pay id") + } + typ, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil || strconv.FormatUint(typ, 10) != parts[2] { + return fmt.Errorf("commerce: invalid accepted quote currency") + } + amount, err := strconv.ParseUint(parts[3], 10, 64) + if err != nil || strconv.FormatUint(amount, 10) != parts[3] { + return fmt.Errorf("commerce: invalid accepted quote amount") + } + var expected uint64 + for _, q := range quotes { + if q.ItemType != typ || q.Cost > math.MaxInt32-expected { + return fmt.Errorf("commerce: accepted quote currency mismatch") + } + expected += q.Cost + } + if expected != amount { + return fmt.Errorf("commerce: purchase quote changed; refresh the shop") + } + return nil +} +func (s *Service) purchaseCountDBInfos() ([][]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, e := s.load() + if e != nil { + return nil, e + } + counts := map[gamedata.CashProductKey]uint64{} + if s.legacy != nil { + for _, raw := range s.legacy.PurchaseCountDBInfos() { + g, _, _ := wire.Varint(raw, 1) + id, _, _ := wire.Varint(raw, 2) + sale, _, _ := wire.Varint(raw, 3) + n, _, _ := wire.Varint(raw, 4) + counts[gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}] = n + } + } + for _, d := range s.catalog.Designs() { + if n := s.count(v, d); n > counts[d.Key] { + counts[d.Key] = n + } + } + keys := make([]gamedata.CashProductKey, 0, len(counts)) + for k := range counts { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + a, b := keys[i], keys[j] + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + if a.ProductID != b.ProductID { + return a.ProductID < b.ProductID + } + return a.SaleGroup < b.SaleGroup + }) + var out [][]byte + for _, k := range keys { + b := wire.AppendVarint(nil, 1, k.GroupID) + b = wire.AppendVarint(b, 2, k.ProductID) + b = wire.AppendVarint(b, 3, k.SaleGroup) + b = wire.AppendVarint(b, 4, counts[k]) + out = append(out, b) + } + return out, nil +} +func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v } +func (s *Service) HasPurchased(k gamedata.CashProductKey) bool { + s.mu.Lock() + defer s.mu.Unlock() + v, e := s.load() + if e != nil { + return false + } + return v.Counts[skuKey(k)].Count > 0 +} + +// ConsumeEntitlement is an authorization callback within the caller's account +// transaction. Each successful cash activation consumes one lifetime purchase. +// It must not be called recursively from the purchase hook or delegate. +func (s *Service) ConsumeEntitlement(k gamedata.CashProductKey) bool { + s.mu.Lock() + defer s.mu.Unlock() + v, err := s.load() + if err != nil { + return false + } + n := v.Counts[skuKey(k)].Lifetime + used := map[string]uint64{} + raw, err := s.store.Load("commerce_pass_receipts") + if err != nil { + return false + } + if raw != nil { + if err = json.Unmarshal(raw, &used); err != nil || used == nil { + return false + } + } + key := skuKey(k) + if used[key] >= n { + return false + } + used[key]++ + raw, err = json.Marshal(used) + if err != nil { + return false + } + return s.store.Save("commerce_pass_receipts", raw) == nil +} diff --git a/go/internal/server/commerce/service_test.go b/go/internal/server/commerce/service_test.go new file mode 100644 index 0000000..c04f2c0 --- /dev/null +++ b/go/internal/server/commerce/service_test.go @@ -0,0 +1,316 @@ +package commerce + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gameconfig" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/readonly" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "testing" + "time" +) + +type purchaseEconomy struct { + calls int + costs, rewards []gamedata.Reward + fail bool +} + +func (e *purchaseEconomy) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { + e.calls++ + e.costs = c + e.rewards = r + if e.fail { + return nil, fmt.Errorf("insufficient balance") + } + return []byte{8, 1}, nil +} +func serviceFixture(t *testing.T, limit uint64) (*Service, *purchaseEconomy, *stateio.Memory) { + t.Helper() + design := &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{ + {Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1}, GoogleSKU: "topup", PriceType: 1, PriceCount: 2200, RandomBoxID: 10, Recharge: true, NominalPaidDiamonds: 100}, + {Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1}, GoogleSKU: "goods", PriceType: 1, PriceCount: 22000, RandomBoxID: 20, PurchaseLimitType: limit, PurchaseLimitCount: 1}, + {Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "goods-sale", PriceType: 1, PriceCount: 11000, RandomBoxID: 21, PurchaseLimitType: 4, PurchaseLimitCount: 1}, + }} + c, err := NewCatalog("test", design, gameconfig.Default().Purchases) + if err != nil { + t.Fatal(err) + } + eco := &purchaseEconomy{} + store := stateio.NewMemory() + s, err := NewService(c, store, eco) + if err != nil { + t.Fatal(err) + } + return s, eco, store +} +func buyRequest(seq, group, id, sale uint64, billing string) []byte { + b := wire.AppendVarint(nil, 1, seq) + b = wire.AppendVarint(b, 3, group) + line := wire.AppendVarint(nil, 1, id) + line = wire.AppendVarint(line, 2, sale) + line = wire.AppendVarint(line, 3, 1) + b = wire.AppendBytes(b, 4, line) + if billing == "" { + billing = fmt.Sprint(seq) + } + if billing == "pay-1" { + billing = "1" + } + typ, amount := uint64(2), uint64(1000) + if sale == 1 { + amount = 500 + } + if group == 1 { + typ = 0 + amount = 0 + } + info := wire.AppendBytes(nil, 1, []byte(billing)) + info = wire.AppendBytes(info, 2, []byte(fmt.Sprintf("bd2-local-commerce-v1:%s:%d:%d", billing, typ, amount))) + b = wire.AppendBytes(b, 7, info) + return b +} +func TestPurchaseReplayPersistsAndRejectsCollisions(t *testing.T) { + s, eco, store := serviceFixture(t, 0) + req := buyRequest(1, 2, 1, 0, "pay-1") + code, out, ok, err := s.HandleSession("/CashShopBuy", req, "session") + if err != nil || !ok || code != 61 { + t.Fatal(code, ok, err) + } + if eco.calls != 1 || len(eco.costs) != 1 || eco.costs[0].Type != 2 || eco.costs[0].Count != 1000 || eco.rewards[0].ID != 20 { + t.Fatal(eco) + } + s2, err := NewService(s.catalog, store, eco) + if err != nil { + t.Fatal(err) + } + _, replayed, _, err := s2.HandleSession("/CashShopBuy", req, "session") + if err != nil || !bytes.Equal(out, replayed) || eco.calls != 1 { + t.Fatal(err, eco.calls) + } + changed := buyRequest(1, 2, 1, 1, "") + if _, _, _, err = s2.HandleSession("/CashShopBuy", changed, "session"); err == nil { + t.Fatal("changed sequence accepted") + } + if _, _, _, err = s2.HandleSession("/CashShopBuy", req, "other-session"); err == nil { + t.Fatal("billing identity reused") + } +} +func TestSaleGroupsLimitsAndReset(t *testing.T) { + s, eco, _ := serviceFixture(t, 1) + now := time.Date(2026, 10, 5, 1, 0, 0, 0, time.UTC) + s.SetClock(func() time.Time { return now }, 0) + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil { + t.Fatal("daily limit ignored") + } + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil { + t.Fatal("sale group should be independent", err) + } + now = now.Add(24 * time.Hour) + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(4, 2, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + if eco.calls != 3 { + t.Fatal(eco.calls) + } +} +func TestFailedEconomyDoesNotConsumeLimitAndFreeRecharge(t *testing.T) { + s, eco, _ := serviceFixture(t, 4) + eco.fail = true + req := buyRequest(1, 2, 1, 0, "") + if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil { + t.Fatal("failure accepted") + } + if s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) { + t.Fatal("failed buy persisted") + } + eco.fail = false + if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 1, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + if len(eco.costs) != 0 || eco.rewards[0].ID != 10 { + t.Fatal("free recharge wrong", eco) + } +} +func TestAuthenticatedSessionRequired(t *testing.T) { + s, eco, _ := serviceFixture(t, 0) + if _, _, _, err := s.Handle("/CashShopBuy", buyRequest(1, 2, 1, 0, "")); err == nil || eco.calls != 0 { + t.Fatal("unauthenticated buy accepted") + } +} +func TestPassEntitlementConsumedOncePerPurchase(t *testing.T) { + s, _, _ := serviceFixture(t, 0) + k := gamedata.CashProductKey{GroupID: 2, ProductID: 1} + if s.ConsumeEntitlement(k) { + t.Fatal("unpaid entitlement authorized") + } + for i := uint64(1); i <= 2; i++ { + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(i, 2, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + if !s.ConsumeEntitlement(k) || s.ConsumeEntitlement(k) { + t.Fatal("entitlement count not enforced") + } + } +} +func TestRelayRequiresPreviousProduct(t *testing.T) { + s, eco, _ := serviceFixture(t, 0) + if err := s.AttachPackageRules([]gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 1}, {GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 2}}); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 1, ""), "s"); err == nil || eco.calls != 0 { + t.Fatal("relay step skipped") + } + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil { + t.Fatal(err) + } +} + +func TestStaleClientQuoteRejectedBeforeEconomy(t *testing.T) { + s, eco, _ := serviceFixture(t, 0) + req := buyRequest(1, 2, 1, 0, "") + req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":3:1000")) + if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 { + t.Fatal("stale currency accepted") + } + req = buyRequest(2, 2, 1, 0, "") + req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":2:9999")) + if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 { + t.Fatal("stale amount accepted") + } +} +func TestShopWindowFiltersAndAuthorizesProducts(t *testing.T) { + s, eco, _ := serviceFixture(t, 0) + now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) + s.SetClock(func() time.Time { return now }, 0) + seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{{Number: 1, Type: 0, Varint: 2}, {Number: 2, Type: 0, Varint: 1}, {Number: 4, Type: 0, Varint: uint64(now.Add(-time.Hour).UnixMilli())}, {Number: 5, Type: 0, Varint: uint64(now.Add(time.Hour).UnixMilli())}, {Number: 8, Type: 0, Varint: 123}}}}}}} + if err := s.AttachShopSeed(seed); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + now = now.Add(2 * time.Hour) + if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil { + t.Fatal("expired product accepted") + } + code, out, ok, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s") + if err != nil || !ok || code != 60 { + t.Fatal(err) + } + products := 0 + _ = wire.Walk(out, func(f wire.Field) error { + if f.Number == 1 { + products++ + } + return nil + }) + if products != 0 || eco.calls != 1 { + t.Fatal(products, eco.calls) + } +} + +type walletPurchaseEconomy struct { + wallet *player.Wallet + store stateio.Store +} + +func (e walletPurchaseEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + if err := e.wallet.ExchangeOnce(identity, costs, nil); err != nil { + return nil, err + } + raw, err := json.Marshal(rewards) + if err != nil { + return nil, err + } + if err = e.store.Save("commerce_test_rewards", raw); err != nil { + return nil, err + } + return []byte{8, 1}, nil +} +func TestSQLiteAccountTransactionRollsBackCostRewardAndReceipt(t *testing.T) { + s, _, _ := serviceFixture(t, 4) + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = repo.Close() }) + wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 1000}) + if err != nil { + t.Fatal(err) + } + if err = wallet.EnsurePersisted(); err != nil { + t.Fatal(err) + } + s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo}) + if err != nil { + t.Fatal(err) + } + s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("entitlement failure") }) + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil { + t.Fatal("hook failure lost") + } + if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatal(err) + } + if err = repo.Close(); err != nil { + t.Fatal(err) + } + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + wallet, err = player.OpenWallet(repo, player.Currency{}) + if err != nil { + t.Fatal(err) + } + if wallet.Snapshot().Jewelry != 1000 { + t.Fatal("paid diamonds survived rollback") + } + for _, name := range []string{"commerce", "commerce_test_rewards"} { + raw, err := repo.Load(name) + if err != nil || raw != nil { + t.Fatal("purchase state survived rollback", name, err) + } + } + s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo}) + if err != nil { + t.Fatal(err) + } + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { + _ = op.Rollback() + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + if wallet.Snapshot().Jewelry != 0 || !s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) { + t.Fatal("retry did not commit") + } +} diff --git a/go/internal/server/eventactions/battle.go b/go/internal/server/eventactions/battle.go new file mode 100644 index 0000000..10589b0 --- /dev/null +++ b/go/internal/server/eventactions/battle.go @@ -0,0 +1,164 @@ +package eventactions + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/wire" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" +) + +// Tactics uses the client's custom battle stage fields 8/9 and has fixed +// tutorial decks. Character health and ordinary pack rewards do not apply. +func (s *Service) HandlesBattle(mode uint64) bool { return mode == 29 } +func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + digest := battleDigest(req) + if r, ok := s.state.Receipts[s.session+":enter:"+receipt]; ok { + if r.Digest != digest { + return nil, errors.New("eventactions: changed battle enter replay") + } + return r.Reply, nil + } + before, _ := json.Marshal(s.state) + if val(req, 5) != 29 { + return nil, errors.New("eventactions: wrong tactics battle mode") + } + group, stage, deck := val(req, 8), val(req, 9), val(req, 4) + row, ok := s.row("TacticsBingoTable", 4, 5, group, stage) + if !ok || row.V(2) != deck { + return nil, errors.New("eventactions: invalid tactics stage/deck") + } + var uid uint64 + for _, v := range s.registry.List() { + if v.Type != 20 || !s.active(v) { + continue + } + g, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + if ok && g.V(1) == group { + uid = v.UID + break + } + } + if uid == 0 { + return nil, errors.New("eventactions: tactics event inactive") + } + s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = uid, stage, deck + s.state.Receipts[s.session+":enter:"+receipt] = receiptRecord(nil, digest) + if e := s.save(); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + return nil, nil +} +func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.state.Receipts[s.session+":battle:"+receipt]; ok { + if r.Digest != battleDigest(req) { + return nil, errors.New("eventactions: changed battle completion replay") + } + return r.Reply, nil + } + before, _ := json.Marshal(s.state) + uid, stage := s.state.BattleUID, s.state.BattleStage + if uid == 0 || stage == 0 { + return nil, errors.New("eventactions: tactics battle not entered") + } + if _, e := s.resolve(uid, 20); e != nil { + return nil, e + } + if val(req, 2) == 1 && !contains(s.state.Tactics[uid], stage) { + priorLines := s.tacticsLines(uid) + s.state.Tactics[uid] = append(s.state.Tactics[uid], stage) + if s.progress != nil { + if e := s.progress(346, stage, 1); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + lines := s.tacticsLines(uid) + if lines > priorLines { + if e := s.progress(347, 0, lines-priorLines); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + } + v, _ := s.registry.Resolve(uid) + g, _ := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + n := uint64(5) - g.V(4) + if uint64(len(s.state.Tactics[uid])) == n*n { + if e := s.progress(348, 0, 1); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + } + } + } + var out []byte + for _, id := range s.state.Tactics[uid] { + out = wire.AppendVarint(out, 29, id) + } + s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = 0, 0, 0 + s.state.Receipts[s.session+":battle:"+receipt] = receiptRecord(out, battleDigest(req)) + if e := s.save(); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + return out, nil +} + +func (s *Service) tacticsLines(uid uint64) uint64 { + v, _ := s.registry.Resolve(uid) + g, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + if !ok || g.V(4) > 2 { + return 0 + } + n := uint64(5) - g.V(4) + clear := s.state.Tactics[uid] + var lines uint64 + for i := uint64(0); i < n; i++ { + row, col := true, true + for j := uint64(0); j < n; j++ { + row = row && contains(clear, i*n+j+1) + col = col && contains(clear, j*n+i+1) + } + if row { + lines++ + } + if col { + lines++ + } + } + a, b := true, true + for i := uint64(0); i < n; i++ { + a = a && contains(clear, i*n+i+1) + b = b && contains(clear, i*n+(n-i)) + } + if a { + lines++ + } + if b { + lines++ + } + return lines +} + +func (s *Service) AssociatedMissionGroup(v events.Schedule) uint64 { + if v.Type == 20 { + r, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + if ok { + return r.V(2) + } + } + if v.Type == 23 { + r, ok := s.design.Row("VotingEventTable", 6, v.ID) + if ok { + return r.V(12) + } + } + return 0 +} +func receiptRecord(b []byte, digest string) receipt { return receipt{Digest: digest, Reply: b} } +func battleDigest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) } diff --git a/go/internal/server/eventactions/cafeteria.go b/go/internal/server/eventactions/cafeteria.go new file mode 100644 index 0000000..c4351f8 --- /dev/null +++ b/go/internal/server/eventactions/cafeteria.go @@ -0,0 +1,56 @@ +package eventactions + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "errors" +) + +func (s *Service) cafeteriaReward(req []byte, identity string) ([]byte, error) { + group, id := val(req, 2), val(req, 3) + row, ok := s.row("CafeteriaEventTable", 5, 6, group, id) + if !ok || row.V(13) == 0 || row.V(11) == 0 { + return nil, errors.New("eventactions: cafeteria interaction absent") + } + var uid uint64 + for _, v := range s.registry.List() { + if v.Type == 25 && s.active(v) && (v.SubID == group || v.SubID == 0 && v.ID == group) { + uid = v.UID + break + } + } + if uid == 0 { + return nil, errors.New("eventactions: cafeteria event inactive") + } + if len(s.design.Tables["CafeteriaDefaultTable"]) != 1 { + return nil, errors.New("eventactions: cafeteria default missing") + } + defaults := s.design.Tables["CafeteriaDefaultTable"][0] + cap := defaults.V(8) + if cap == 0 || s.state.CafeteriaCurrency >= cap { + return nil, errors.New("eventactions: cafeteria daily currency limit") + } + term := defaults.V(3) + if row.V(4) != 1 { + term = defaults.V(28) + } + receiptKey := key(uid, group, id) + now := s.now().UnixMilli() + if last := s.state.CafeteriaLast[receiptKey]; last > 0 && now-last < int64(term)*1000 { + return nil, errors.New("eventactions: cafeteria interaction cooldown") + } + count := row.V(11) + if count > cap-s.state.CafeteriaCurrency { + count = cap - s.state.CafeteriaCurrency + } + rewards := []gamedata.Reward{{Type: row.V(13), ID: row.V(12), Count: count}} + bundle, err := s.economy.Apply(identity, nil, rewards) + if err != nil { + return nil, err + } + s.state.CafeteriaCurrency += count + s.state.CafeteriaLast[receiptKey] = now + out := wire.AppendBytes(nil, 1, bundle) + out = wire.AppendVarint(out, 2, s.state.CafeteriaCurrency) + return out, nil +} diff --git a/go/internal/server/eventactions/cafeteria_test.go b/go/internal/server/eventactions/cafeteria_test.go new file mode 100644 index 0000000..cdb7973 --- /dev/null +++ b/go/internal/server/eventactions/cafeteria_test.go @@ -0,0 +1,62 @@ +package eventactions + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "bytes" + "testing" + "time" +) + +func TestCafeteriaActiveScheduleDailyCapCooldownAndReplay(t *testing.T) { + s, e, store := setup(t) + now := s.now() + s.now = func() time.Time { return now } + s.design.Tables["CafeteriaDefaultTable"] = []gamedata.EventActionRow{row(map[int]uint64{8: 5, 3: 2, 28: 2})} + s.design.Tables["CafeteriaEventTable"] = []gamedata.EventActionRow{row(map[int]uint64{5: 1, 6: 1, 4: 1, 11: 3, 13: 44})} + b := wire.AppendVarint(req(1), 2, 1) + b = wire.AppendVarint(b, 3, 1) + if _, _, _, err := s.Handle("/CafeteriaEventNpcInteractionReward", b); err == nil { + t.Fatal("inactive cafeteria accepted") + } + r := events.NewRegistry() + if err := r.Replace([]events.Schedule{{UID: 77, Type: 25, ID: 3007, SubID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()}}); err != nil { + t.Fatal(err) + } + s.registry = r + code, reply, _, err := s.Handle("/CafeteriaEventNpcInteractionReward", b) + if err != nil || code != 414 || e.calls != 1 { + t.Fatalf("cafeteria%d err%v", code, err) + } + n, err := Open(store, s.design, r, e) + if err != nil { + t.Fatal(err) + } + n.now = s.now + n.BeginSession("test") + _, again, _, err := n.Handle("/CafeteriaEventNpcInteractionReward", b) + if err != nil || !bytes.Equal(reply, again) || e.calls != 1 { + t.Fatal("replay grants twice") + } + b2 := wire.AppendVarint(req(2), 2, 1) + b2 = wire.AppendVarint(b2, 3, 1) + if _, _, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b2); err == nil { + t.Fatal("cooldown bypass") + } + now = now.Add(2 * time.Second) + _, reply, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b2) + if err != nil { + t.Fatal(err) + } + count, _, _ := wire.Varint(reply, 2) + if count != 5 || e.rewards[len(e.rewards)-1].Count != 2 { + t.Fatal("daily cap not clamped") + } + b3 := wire.AppendVarint(req(3), 2, 1) + b3 = wire.AppendVarint(b3, 3, 1) + now = now.Add(24 * time.Hour) + if _, _, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b3); err != nil || n.state.CafeteriaCurrency != 3 { + t.Fatal("daily limit failed to reset") + } +} diff --git a/go/internal/server/eventactions/service.go b/go/internal/server/eventactions/service.go new file mode 100644 index 0000000..19a9c11 --- /dev/null +++ b/go/internal/server/eventactions/service.go @@ -0,0 +1,551 @@ +// Package eventactions owns repeatable event interaction state. It uses the +// injected calendar and shared transactional reward service. +package eventactions + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +type Economy interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) +} +type receipt struct { + Digest string + Reply []byte +} +type spawn struct { + UID, Group, ID uint64 + Start int64 + Caught map[string]bool +} +type vote struct{ Round, Candidate, Normal, Additional uint64 } +type snapshot struct { + Version string + BattleUID, BattleStage, BattleDeck uint64 + Claims map[string]bool + Favorites map[uint64]bool + Votes map[string]*vote + NormalVoted map[string]bool + VoteRewards map[string]bool + Spawns map[uint64]*spawn + DailyNormal, DailySpecial uint64 + CafeteriaCurrency uint64 + CafeteriaLast map[string]int64 + Day string + Tactics map[uint64][]uint64 + Deck []byte + Receipts map[string]receipt +} +type Service struct { + mu sync.Mutex + store stateio.Store + design *gamedata.EventActionsDesign + registry events.Resolver + economy Economy + state snapshot + now func() time.Time + session string + friendship func(uint64) uint64 + chargeInfo func() ([]byte, error) + owned func(uint64, uint64) bool + progress func(uint64, uint64, uint64) error + voteTotals func(uint64, uint64) (map[uint64]uint64, error) +} + +func Open(store stateio.Store, d *gamedata.EventActionsDesign, r events.Resolver, e Economy) (*Service, error) { + if store == nil || d == nil || r == nil || e == nil { + return nil, errors.New("eventactions: missing dependency") + } + s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now} + b, err := store.Load("eventactions") + if err != nil { + return nil, err + } + if b != nil { + if err = stateio.RequireExactJSONObject(b, "Version", "BattleUID", "BattleStage", "BattleDeck", "Claims", "Favorites", "Votes", "NormalVoted", "VoteRewards", "Spawns", "DailyNormal", "DailySpecial", "CafeteriaCurrency", "CafeteriaLast", "Day", "Tactics", "Deck", "Receipts"); err != nil { + return nil, err + } + if err = json.Unmarshal(b, &s.state); err != nil { + return nil, err + } + if s.state.Version != versionconfig.State() || s.state.Claims == nil || s.state.Favorites == nil || s.state.Votes == nil || s.state.NormalVoted == nil || s.state.VoteRewards == nil || s.state.Spawns == nil || s.state.Tactics == nil || s.state.Receipts == nil || s.state.CafeteriaLast == nil { + return nil, errors.New("eventactions: invalid state layout") + } + for _, v := range s.state.Votes { + if v == nil || v.Candidate == 0 || v.Round == 0 { + return nil, errors.New("eventactions: invalid saved vote") + } + } + for _, p := range s.state.Spawns { + if p == nil || p.Caught == nil || p.ID == 0 { + return nil, errors.New("eventactions: invalid saved spawn") + } + } + } else { + s.state.Version = versionconfig.State() + } + s.init() + return s, nil +} +func (s *Service) init() { + if s.state.CafeteriaLast == nil { + s.state.CafeteriaLast = map[string]int64{} + } + if s.state.Claims == nil { + s.state.Claims = map[string]bool{} + } + if s.state.Favorites == nil { + s.state.Favorites = map[uint64]bool{} + } + if s.state.Votes == nil { + s.state.Votes = map[string]*vote{} + } + if s.state.NormalVoted == nil { + s.state.NormalVoted = map[string]bool{} + } + if s.state.VoteRewards == nil { + s.state.VoteRewards = map[string]bool{} + } + if s.state.Spawns == nil { + s.state.Spawns = map[uint64]*spawn{} + } + if s.state.Tactics == nil { + s.state.Tactics = map[uint64][]uint64{} + } + if s.state.Receipts == nil { + s.state.Receipts = map[string]receipt{} + } +} +func (s *Service) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id } +func (s *Service) AttachFriendshipLevel(f func(uint64) uint64) { s.friendship = f } +func (s *Service) AttachChargeInfo(f func() ([]byte, error)) { s.chargeInfo = f } +func (s *Service) AttachOwnedCharacter(f func(uint64, uint64) bool) { s.owned = f } +func (s *Service) AttachProgress(f func(uint64, uint64, uint64) error) { s.progress = f } + +// The current server owns one account save. Its real votes are the default +// leaderboard; a future account aggregator can supply broader server totals. +func (s *Service) AttachVoteTotals(f func(uint64, uint64) (map[uint64]uint64, error)) { + s.voteTotals = f +} +func (s *Service) save() error { + b, e := json.Marshal(s.state) + if e != nil { + return e + } + return s.store.Save("eventactions", b) +} +func val(p []byte, n int) uint64 { v, _, _ := wire.Varint(p, n); return v } +func key(v ...uint64) string { + r := []string{} + for _, n := range v { + r = append(r, fmt.Sprint(n)) + } + return strings.Join(r, "/") +} +func (s *Service) active(v events.Schedule) bool { + return s.now().UnixMilli() >= v.Start && s.now().UnixMilli() < v.End +} +func (s *Service) resolve(uid, t uint64) (events.Schedule, error) { + v, e := s.registry.Resolve(uid) + if e != nil { + return v, e + } + if v.Type != t || !s.active(v) { + return v, errors.New("eventactions: event inactive or wrong type") + } + return v, nil +} +func (s *Service) find(t uint64) (events.Schedule, bool) { + for _, v := range s.registry.List() { + if v.Type == t && s.active(v) { + return v, true + } + } + return events.Schedule{}, false +} +func (s *Service) roll() { + day := s.now().UTC().Format("2006-01-02") + if s.state.Day != day { + s.state.Day = day + s.state.DailyNormal = 0 + s.state.DailySpecial = 0 + s.state.CafeteriaCurrency = 0 + s.state.CafeteriaLast = map[string]int64{} + s.state.NormalVoted = map[string]bool{} + } +} +func (s *Service) Handle(path string, b []byte) (int, []byte, bool, error) { + codes := map[string]int{"/FieldEventSpawnInfo": 551, "/FieldEventSpawnStart": 552, "/FieldEventSpawnReward": 553, "/FireWorksInfo": 554, "/FireWorksReward": 555, "/CharVoteInfo": 581, "/CharVoteSave": 582, "/CharVoteRanking": 583, "/CharVoteSeasonRanking": 584, "/CharVoteFavoriteAdd": 585, "/CharVoteFavoriteDelete": 586, "/CharVoteTotalRanking": 587, "/FriendshipSpecialEpisodeInfo": 623, "/FriendshipSpecialEpisodeClear": 624, "/ChargeCostInfo": 123, "/NpcQuizInfo": 535, "/NpcQuizClear": 536, "/TacticsBingoInfo": 546, "/TacticsBingoDeckSave": 550} + code, ok := codes[path] + if path == "/CafeteriaEventNpcInteractionReward" { + code, ok = 414, true + } + if !ok { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + seq, found, e := wire.Varint(b, 1) + if e != nil || !found || seq == 0 { + return code, nil, true, errors.New("eventactions: invalid request") + } + if e = wire.Walk(b, func(wire.Field) error { return nil }); e != nil { + return code, nil, true, e + } + sum := sha256.Sum256(append([]byte(path), b...)) + digest := hex.EncodeToString(sum[:]) + rk := s.session + ":" + path + ":" + key(seq) + if r, ok := s.state.Receipts[rk]; ok { + if r.Digest != digest { + return code, nil, true, errors.New("eventactions: replay changed") + } + return code, r.Reply, true, nil + } + before, _ := json.Marshal(s.state) + s.roll() + out, e := s.handle(path, b, rk) + if e != nil { + _ = json.Unmarshal(before, &s.state) + return code, nil, true, e + } + s.state.Receipts[rk] = receipt{digest, out} + if e = s.save(); e != nil { + _ = json.Unmarshal(before, &s.state) + return code, nil, true, e + } + return code, out, true, nil +} +func (s *Service) row(table string, groupField, idField int, g, id uint64) (gamedata.EventActionRow, bool) { + for _, r := range s.design.Tables[table] { + if r.V(groupField) == g && r.V(idField) == id { + return r, true + } + } + return gamedata.EventActionRow{}, false +} +func (s *Service) spawnWire(p *spawn) []byte { + if p == nil { + return nil + } + b := wire.AppendVarint(nil, 1, uint64(p.Start)) + b = wire.AppendVarint(b, 2, p.UID) + b = wire.AppendVarint(b, 3, p.ID) + b = wire.AppendVarint(b, 4, p.Group) + var ids []string + for k := range p.Caught { + ids = append(ids, k) + } + sort.Strings(ids) + for _, k := range ids { + var g, id uint64 + _, _ = fmt.Sscanf(k, "%d/%d", &g, &id) + x := wire.AppendVarint(nil, 1, p.ID) + x = wire.AppendVarint(x, 2, g) + x = wire.AppendVarint(x, 3, id) + b = wire.AppendBytes(b, 5, x) + } + return b +} +func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) { + switch path { + case "/CafeteriaEventNpcInteractionReward": + return s.cafeteriaReward(b, identity) + case "/ChargeCostInfo": + if s.chargeInfo == nil { + return nil, errors.New("eventactions: charge state source unavailable") + } + return s.chargeInfo() + case "/FieldEventSpawnInfo": + uid := val(b, 2) + if _, e := s.resolve(uid, 21); e != nil { + return nil, e + } + out := wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[uid])) + out = wire.AppendVarint(out, 2, s.state.DailyNormal) + out = wire.AppendVarint(out, 3, s.state.DailySpecial) + return out, nil + case "/FieldEventSpawnStart": + v, e := s.resolve(val(b, 2), 21) + if e != nil { + return nil, e + } + g, id := val(b, 3), val(b, 4) + r, ok := s.row("FieldSpawnEventTable", 4, 5, g, id) + if !ok || g != v.ID { + return nil, errors.New("eventactions: invalid spawn event") + } + if p := s.state.Spawns[v.UID]; p != nil { + defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0) + expired := defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000 + if expired { + delete(s.state.Spawns, v.UID) + } else { + if p.Group != g || p.ID != id { + return nil, errors.New("eventactions: spawn already started") + } + return wire.AppendBytes(nil, 1, s.spawnWire(p)), nil + } + } + _ = r + s.state.Spawns[v.UID] = &spawn{v.UID, g, id, s.now().UnixMilli(), map[string]bool{}} + return wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[v.UID])), nil + case "/FieldEventSpawnReward": + v, e := s.resolve(val(b, 6), 21) + if e != nil { + return nil, e + } + p := s.state.Spawns[v.UID] + if p == nil || p.ID != val(b, 2) || p.Group != val(b, 4) { + return nil, errors.New("eventactions: spawn not started") + } + spawnRow, ok := s.row("FieldSpawnEventTable", 4, 5, p.Group, p.ID) + if !ok { + return nil, errors.New("eventactions: spawn definition absent") + } + g, id := val(b, 5), val(b, 3) + r, ok := s.row("FieldEventMonsterTable", 3, 4, g, id) + if !ok || g != spawnRow.V(2) { + return nil, errors.New("eventactions: invalid spawn monster") + } + ck := key(g, id) + if p.Caught[ck] { + return nil, errors.New("eventactions: monster already caught") + } + defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0) + if defaults.V(12) > 0 && s.now().UnixMilli()-p.Start > int64(defaults.V(12))*1000 { + return nil, errors.New("eventactions: spawn time expired") + } + special := r.V(2) != 0 + if special && s.state.DailySpecial >= defaults.V(5) || !special && s.state.DailyNormal >= defaults.V(4) { + return nil, errors.New("eventactions: daily catch limit") + } + reward, ok := s.design.SpawnRewards[[2]uint64{r.V(5), r.V(1)}] + if !ok || reward.Count == 0 { + return nil, errors.New("eventactions: spawn reward missing") + } + bundle, e := s.economy.Apply(identity, nil, []gamedata.Reward{reward}) + if e != nil { + return nil, e + } + p.Caught[ck] = true + if special { + s.state.DailySpecial++ + } else { + s.state.DailyNormal++ + } + if s.progress != nil { + if e = s.progress(349, 0, 1); e != nil { + return nil, e + } + } + return wire.AppendBytes(nil, 1, bundle), nil + case "/FireWorksInfo": + var out []byte + for k := range s.state.Claims { + var uid, g uint64 + if _, e := fmt.Sscanf(k, "fire:%d/%d", &uid, &g); e == nil { + out = wire.AppendVarint(out, 1, g) + } + } + return out, nil + case "/FireWorksReward": + v, e := s.resolve(val(b, 2), 22) + if e != nil { + return nil, e + } + g := val(b, 3) + if g != v.ID { + return nil, errors.New("eventactions: wrong fireworks group") + } + ck := "fire:" + key(v.UID, g) + if s.state.Claims[ck] { + return nil, errors.New("eventactions: fireworks already received") + } + var rs []gamedata.Reward + for _, r := range s.design.Tables["FireworksTable"] { + if r.V(4) == g { + rs = append(rs, r.Rewards...) + break + } + } + if len(rs) == 0 { + return nil, errors.New("eventactions: fireworks design missing") + } + bundle, e := s.economy.Apply(identity, nil, rs) + if e != nil { + return nil, e + } + s.state.Claims[ck] = true + return wire.AppendBytes(nil, 1, bundle), nil + case "/FriendshipSpecialEpisodeInfo": + var out []byte + for k := range s.state.Claims { + var g, id uint64 + if _, e := fmt.Sscanf(k, "friend:%d/%d", &g, &id); e == nil { + x := wire.AppendVarint(nil, 1, g) + x = wire.AppendVarint(x, 2, id) + out = wire.AppendBytes(out, 1, x) + } + } + return out, nil + case "/FriendshipSpecialEpisodeClear": + g, id := val(b, 2), val(b, 3) + r, ok := s.row("FriendshipSpecialEpisodeTable", 5, 6, g, id) + if !ok { + return nil, errors.New("eventactions: unknown episode") + } + if s.friendship == nil || s.friendship(g) < r.V(12) { + return nil, errors.New("eventactions: friendship level insufficient") + } + if id > 1 && !s.state.Claims["friend:"+key(g, id-1)] { + return nil, errors.New("eventactions: previous episode incomplete") + } + ck := "friend:" + key(g, id) + if s.state.Claims[ck] { + return nil, errors.New("eventactions: episode already cleared") + } + rs := append([]gamedata.Reward(nil), r.Rewards...) + for _, v := range s.registry.List() { + if v.Type == 24 && v.ID == g && s.active(v) { + rs = append(rs, r.EventRewards...) + break + } + } + bundle, e := s.economy.Apply(identity, nil, rs) + if e != nil { + return nil, e + } + s.state.Claims[ck] = true + x := wire.AppendVarint(nil, 1, g) + x = wire.AppendVarint(x, 2, id) + out := wire.AppendBytes(nil, 1, bundle) + out = wire.AppendBytes(out, 2, x) + return out, nil + case "/NpcQuizInfo": + uid := val(b, 2) + v, e := s.registry.Resolve(uid) + if e != nil { + return nil, e + } + var out []byte + for _, r := range s.design.Tables["NpcQuizTable"] { + if r.V(1) != v.ID { + continue + } + if s.state.Claims["quiz:"+key(uid, r.V(1), r.V(2))] { + x := wire.AppendVarint(nil, 1, uid) + x = wire.AppendVarint(x, 2, r.V(1)) + x = wire.AppendVarint(x, 3, r.V(2)) + out = wire.AppendBytes(out, 1, x) + } + } + return out, nil + case "/NpcQuizClear": + uid, g, id := val(b, 2), val(b, 3), val(b, 4) + v, e := s.registry.Resolve(uid) + if e != nil { + return nil, e + } + if !s.active(v) { + return nil, errors.New("eventactions: quiz event inactive") + } + r, ok := s.row("NpcQuizTable", 1, 2, g, id) + if !ok || g != v.ID || s.now().UnixMilli() < v.Start+int64(r.V(8))*86400000 { + return nil, errors.New("eventactions: quiz locked") + } + ck := "quiz:" + key(uid, g, id) + if s.state.Claims[ck] { + return nil, errors.New("eventactions: quiz already cleared") + } + bundle, e := s.economy.Apply(identity, nil, r.Rewards) + if e != nil { + return nil, e + } + s.state.Claims[ck] = true + x := wire.AppendVarint(nil, 1, uid) + x = wire.AppendVarint(x, 2, g) + x = wire.AppendVarint(x, 3, id) + out := wire.AppendBytes(nil, 1, bundle) + return wire.AppendBytes(out, 2, x), nil + case "/TacticsBingoInfo": + uid := val(b, 2) + v, e := s.registry.Resolve(uid) + if e != nil { + return nil, e + } + if v.Type != 20 { + return nil, errors.New("eventactions: wrong tactics event") + } + out := wire.AppendVarint(nil, 1, uid) + out = wire.AppendVarint(out, 2, v.ID) + for _, id := range s.state.Tactics[uid] { + out = wire.AppendVarint(out, 3, id) + } + if !s.active(v) { + out = wire.AppendVarint(out, 4, 1) + } + return out, nil + case "/TacticsBingoDeckSave": + seen := map[uint64]bool{} + count := 0 + err := wire.Walk(b, func(f wire.Field) error { + if f.Number != 2 || f.Type != 2 { + return nil + } + idx, id := val(f.Value, 1), val(f.Value, 2) + if idx == 0 || id == 0 || seen[idx] || s.owned == nil || !s.owned(idx, id) || val(f.Value, 4) > 14 || val(f.Value, 5) == 0 { + return errors.New("eventactions: invalid tactics deck") + } + seen[idx] = true + count++ + return nil + }) + if err != nil { + return nil, err + } + if count > 5 { + return nil, errors.New("eventactions: tactics deck too large") + } + s.state.Deck = append([]byte(nil), b...) + return nil, nil + default: + return s.voteHandle(path, b, identity) + } +} + +func (s *Service) RecordTacticsClear(uid, stage uint64) error { + s.mu.Lock() + defer s.mu.Unlock() + v, e := s.resolve(uid, 20) + if e != nil { + return e + } + group, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + if !ok { + return errors.New("eventactions: tactics group missing") + } + if _, ok = s.row("TacticsBingoTable", 4, 5, group.V(1), stage); !ok { + return errors.New("eventactions: invalid tactics stage") + } + for _, id := range s.state.Tactics[uid] { + if id == stage { + return nil + } + } + s.state.Tactics[uid] = append(s.state.Tactics[uid], stage) + sort.Slice(s.state.Tactics[uid], func(i, j int) bool { return s.state.Tactics[uid][i] < s.state.Tactics[uid][j] }) + return s.save() +} diff --git a/go/internal/server/eventactions/service_test.go b/go/internal/server/eventactions/service_test.go new file mode 100644 index 0000000..da30651 --- /dev/null +++ b/go/internal/server/eventactions/service_test.go @@ -0,0 +1,139 @@ +package eventactions + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "testing" + "time" +) + +type economyStub struct { + calls int + costs, rewards []gamedata.Reward +} + +func (e *economyStub) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { + e.calls++ + e.costs = append(e.costs, c...) + e.rewards = append(e.rewards, r...) + return []byte{10, 0}, nil +} +func row(v map[int]uint64) gamedata.EventActionRow { return gamedata.EventActionRow{Values: v} } +func setup(t *testing.T) (*Service, *economyStub, stateio.Store) { + t.Helper() + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + r := events.NewRegistry() + if e := r.Replace([]events.Schedule{{UID: 1, Type: 23, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}, {UID: 2, Type: 20, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}, {UID: 3, Type: 21, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}}); e != nil { + t.Fatal(e) + } + d := &gamedata.EventActionsDesign{Tables: map[string][]gamedata.EventActionRow{"VotingEventTable": {row(map[int]uint64{6: 1, 10: 1, 14: 1, 7: 1, 8: 6001, 9: 8, 1: 1, 2: 6002, 3: 8})}, "VotingCandidateTable": {row(map[int]uint64{1: 1, 2: 9})}, "VotingRoundTable": {row(map[int]uint64{2: 1, 3: 1})}, "TacticsBingoGroupTable": {row(map[int]uint64{3: 1, 1: 5, 4: 2})}, "TacticsBingoTable": {row(map[int]uint64{4: 5, 5: 1, 2: 99})}, "FieldSpawnEventTable": {row(map[int]uint64{4: 1, 5: 1, 2: 1})}, "FieldEventMonsterTable": {row(map[int]uint64{3: 1, 4: 1, 1: 3001, 5: 3009})}, "FieldEventDefaultTable": {row(map[int]uint64{4: 10, 5: 2, 12: 40})}}, SpawnRewards: map[[2]uint64]gamedata.Reward{{3009, 3001}: {Type: 4, Count: 10000}}} + economy := &economyStub{} + store := stateio.NewMemory() + s, e := Open(store, d, r, economy) + if e != nil { + t.Fatal(e) + } + s.now = func() time.Time { return now } + s.BeginSession("test") + return s, economy, store +} +func req(n uint64) []byte { return wire.AppendVarint(nil, 1, n) } +func TestVoteConsumesDesignCostRejectsRepeatAndExactReplay(t *testing.T) { + s, e, store := setup(t) + b := req(1) + b = wire.AppendVarint(b, 2, 9) + b = wire.AppendVarint(b, 4, 1) + _, reply, _, err := s.Handle("/CharVoteSave", b) + if err != nil { + t.Fatal(err) + } + if e.calls != 1 || e.costs[0].ID != 6001 { + t.Fatal("vote cost not applied") + } + next, err := Open(store, s.design, s.registry, e) + if err != nil { + t.Fatal(err) + } + next.now = s.now + next.BeginSession("test") + _, replay, _, err := next.Handle("/CharVoteSave", b) + if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 { + t.Fatal("replay duplicated vote") + } + changed := wire.AppendVarint(b, 5, 1) + if _, _, _, err = next.Handle("/CharVoteSave", changed); err == nil { + t.Fatal("changed replay accepted") + } + b = wire.AppendVarint(req(2), 2, 9) + b = wire.AppendVarint(b, 4, 1) + if _, _, _, err = next.Handle("/CharVoteSave", b); err == nil { + t.Fatal("second normal vote accepted") + } +} +func TestTacticsBattleRequiresStageAndBindsWinReceipt(t *testing.T) { + s, _, _ := setup(t) + enter := req(1) + enter = wire.AppendVarint(enter, 4, 99) + enter = wire.AppendVarint(enter, 5, 29) + enter = wire.AppendVarint(enter, 8, 5) + enter = wire.AppendVarint(enter, 9, 1) + if _, err := s.EnterBattle(enter, "enter1"); err != nil { + t.Fatal(err) + } + end := wire.AppendVarint(req(2), 2, 1) + reply, err := s.CompleteBattle(end, "end2") + if err != nil || val(reply, 29) != 1 { + t.Fatal("stage not cleared") + } + replay, err := s.CompleteBattle(end, "end2") + if err != nil || !bytes.Equal(reply, replay) { + t.Fatal("battle retry lost") + } + loss := wire.AppendVarint(req(2), 2, 2) + if _, err = s.CompleteBattle(loss, "end2"); err == nil { + t.Fatal("changed battle result accepted") + } +} +func TestSpawnNeedsStartAndRejectsRecatch(t *testing.T) { + s, e, _ := setup(t) + reward := req(1) + for f, v := range map[int]uint64{2: 1, 3: 1, 4: 1, 5: 1, 6: 3} { + reward = wire.AppendVarint(reward, f, v) + } + if _, _, _, err := s.Handle("/FieldEventSpawnReward", reward); err == nil { + t.Fatal("unstarted catch accepted") + } + start := wire.AppendVarint(req(2), 2, 3) + start = wire.AppendVarint(start, 3, 1) + start = wire.AppendVarint(start, 4, 1) + if _, _, _, err := s.Handle("/FieldEventSpawnStart", start); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.Handle("/FieldEventSpawnReward", reward); err != nil { + t.Fatal(err) + } + if e.calls != 1 || s.state.DailyNormal != 1 { + t.Fatal("catch not settled") + } +} + +func TestVotingNextRoundAdvancesRealTopCandidates(t *testing.T) { + s, _, _ := setup(t) + s.design.Tables["VotingCandidateTable"] = append(s.design.Tables["VotingCandidateTable"], row(map[int]uint64{1: 1, 2: 10})) + s.design.Tables["VotingRoundTable"] = []gamedata.EventActionRow{row(map[int]uint64{2: 1, 3: 1, 1: 1}), row(map[int]uint64{2: 1, 3: 2, 4: 1})} + s.state.Votes[key(1, 1, 9)] = &vote{Round: 1, Candidate: 9, Normal: 1} + s.state.Votes[key(1, 1, 10)] = &vote{Round: 1, Candidate: 10, Additional: 2} + v, r, _ := s.voteEvent() + advanced := s.advancedCandidates(v, r, 2, s.candidates(r)) + if len(advanced) != 1 || advanced[0] != 10 { + t.Fatalf("wrong advanced list %v", advanced) + } + s.state.Votes[key(1, 1, 9)].Additional = 1 + rows := s.rankRows(1, 1, s.candidates(r)) + if val(rows[0], 3) != 1 || val(rows[1], 3) != 1 { + t.Fatal("equal votes did not share rank") + } +} diff --git a/go/internal/server/eventactions/voting.go b/go/internal/server/eventactions/voting.go new file mode 100644 index 0000000..05e4fa7 --- /dev/null +++ b/go/internal/server/eventactions/voting.go @@ -0,0 +1,321 @@ +package eventactions + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "errors" + "fmt" + "sort" + "time" +) + +func (s *Service) voteEvent() (events.Schedule, gamedata.EventActionRow, bool) { + v, ok := s.find(23) + if !ok { + return v, gamedata.EventActionRow{}, false + } + r, ok := s.design.Row("VotingEventTable", 6, v.ID) + return v, r, ok +} +func (s *Service) rounds(v events.Schedule, r gamedata.EventActionRow) (uint64, []gamedata.EventActionRow) { + var rows []gamedata.EventActionRow + current := uint64(0) + for _, x := range s.design.Tables["VotingRoundTable"] { + if x.V(2) == r.V(14) { + rows = append(rows, x) + } + } + sort.Slice(rows, func(i, j int) bool { return rows[i].V(3) < rows[j].V(3) }) + for _, x := range rows { + if s.now().UnixMilli() >= v.Start+int64(x.V(4))*86400000 { + current = x.V(3) + } + } + return current, rows +} +func (s *Service) candidates(r gamedata.EventActionRow) []uint64 { + var ids []uint64 + for _, x := range s.design.Tables["VotingCandidateTable"] { + if x.V(1) == r.V(10) { + ids = append(ids, x.V(2)) + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} +func contains(ids []uint64, id uint64) bool { + for _, v := range ids { + if v == id { + return true + } + } + return false +} +func voteWire(v *vote) []byte { + b := wire.AppendVarint(nil, 1, v.Round) + b = wire.AppendVarint(b, 2, v.Candidate) + b = wire.AppendVarint(b, 3, v.Normal+v.Additional) + b = wire.AppendVarint(b, 4, v.Normal) + return wire.AppendVarint(b, 5, v.Additional) +} +func (s *Service) voteHandle(path string, b []byte, identity string) ([]byte, error) { + v, r, ok := s.voteEvent() + if !ok { + if path == "/CharVoteInfo" || path == "/CharVoteSeasonRanking" { + return nil, nil + } + if path == "/CharVoteTotalRanking" { + return s.totalRanking(val(b, 2)), nil + } + return nil, errors.New("eventactions: voting event inactive") + } + round, rounds := s.rounds(v, r) + ids := s.candidates(r) + ids = s.advancedCandidates(v, r, round, ids) + switch path { + case "/CharVoteInfo": + out := wire.AppendVarint(nil, 1, round) + for _, id := range ids { + out = wire.AppendVarint(out, 2, id) + } + for k, x := range s.state.Votes { + var uid, ro, c uint64 + if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == v.UID { + out = wire.AppendBytes(out, 3, voteWire(x)) + } + } + for k := range s.state.VoteRewards { + var uid, id uint64 + if _, e := fmt.Sscanf(k, "%d/%d", &uid, &id); e == nil && uid == v.UID { + x := wire.AppendVarint(nil, 1, v.ID) + x = wire.AppendVarint(x, 2, id) + out = wire.AppendBytes(out, 4, x) + } + } + for _, id := range ids { + if s.state.Favorites[id] { + out = wire.AppendVarint(out, 5, id) + } + if s.state.NormalVoted[key(v.UID, id)] { + out = wire.AppendVarint(out, 6, id) + } + } + out = wire.AppendVarint(out, 7, uint64(s.now().UTC().Truncate(24*time.Hour).Add(24*time.Hour).UnixMilli())) + for i, x := range rounds { + start := v.Start + int64(x.V(4))*86400000 + end := v.End + if i+1 < len(rounds) { + end = v.Start + int64(rounds[i+1].V(4))*86400000 + } + a := wire.AppendVarint(nil, 1, x.V(3)) + a = wire.AppendVarint(a, 2, uint64(start)) + a = wire.AppendVarint(a, 3, uint64(end)) + out = wire.AppendBytes(out, 9, a) + } + return out, nil + case "/CharVoteFavoriteAdd", "/CharVoteFavoriteDelete": + id := val(b, 2) + if !contains(ids, id) { + return nil, errors.New("eventactions: unknown candidate") + } + if path == "/CharVoteFavoriteAdd" { + s.state.Favorites[id] = true + } else { + delete(s.state.Favorites, id) + } + return nil, nil + case "/CharVoteSave": + id, count, typ := val(b, 2), val(b, 4), val(b, 5) + if !contains(ids, id) || count == 0 || count > 2147483647 || typ > 1 || round == 0 { + return nil, errors.New("eventactions: invalid vote") + } + normalKey := key(v.UID, id) + if typ == 0 && (s.state.NormalVoted[normalKey] || count != 1) { + return nil, errors.New("eventactions: daily normal vote exhausted") + } + cost := gamedata.Reward{Type: r.V(9), ID: r.V(8), Count: r.V(7) * count} + if typ == 1 { + cost = gamedata.Reward{Type: r.V(3), ID: r.V(2), Count: r.V(1) * count} + } + // A first vote may use the additional ticket when the normal ticket + // is unavailable; the client still sends vote_type=0. + if typ == 0 { + var submittedID uint64 + _ = wire.Walk(b, func(f wire.Field) error { + if f.Number == 3 && f.Type == 2 { + submittedID = val(f.Value, 2) + } + return nil + }) + if submittedID == r.V(2) { + cost = gamedata.Reward{Type: r.V(3), ID: r.V(2), Count: r.V(1) * count} + } + } + if cost.Type == 0 || cost.Count == 0 { + return nil, errors.New("eventactions: missing vote cost") + } + vk := key(v.UID, round, id) + current := s.state.Votes[vk] + if current == nil { + current = &vote{Round: round, Candidate: id} + } + var total uint64 + for k, x := range s.state.Votes { + var uid, ro, c uint64 + if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == v.UID { + total += x.Normal + x.Additional + } + } + var rewards []gamedata.Reward + var newIDs []uint64 + for _, rr := range s.design.Tables["VotingCountRewardTable"] { + if rr.V(1) == r.V(11) && rr.V(6) <= total+count && !s.state.VoteRewards[key(v.UID, rr.V(2))] { + rewards = append(rewards, rr.Rewards...) + newIDs = append(newIDs, rr.V(2)) + } + } + bundle, e := s.economy.Apply(identity, []gamedata.Reward{cost}, rewards) + if e != nil { + return nil, e + } + s.state.Votes[vk] = current + if typ == 0 { + current.Normal += count + s.state.NormalVoted[normalKey] = true + } else { + current.Additional += count + } + out := wire.AppendBytes(nil, 1, bundle) + for _, id := range newIDs { + s.state.VoteRewards[key(v.UID, id)] = true + x := wire.AppendVarint(nil, 1, v.ID) + x = wire.AppendVarint(x, 2, id) + out = wire.AppendBytes(out, 2, x) + } + out = wire.AppendBytes(out, 4, voteWire(current)) + return out, nil + case "/CharVoteRanking": + wanted := val(b, 2) + if wanted == 0 { + wanted = round + } + out := wire.AppendVarint(nil, 1, v.ID) + out = wire.AppendVarint(out, 2, wanted) + for _, x := range s.rankRows(v.UID, wanted, ids) { + out = wire.AppendBytes(out, 3, x) + } + return out, nil + case "/CharVoteTotalRanking": + return s.totalRanking(val(b, 2)), nil + case "/CharVoteSeasonRanking": + var out []byte + for _, schedule := range s.registry.List() { + if schedule.Type != 23 { + continue + } + for _, id := range ids { + var total uint64 + for k, x := range s.state.Votes { + var uid, ro, c uint64 + if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == schedule.UID && c == id { + total += x.Normal + x.Additional + } + } + if total == 0 { + continue + } + x := wire.AppendVarint(nil, 1, schedule.ID) + x = wire.AppendVarint(x, 2, id) + x = wire.AppendVarint(x, 3, total) + x = wire.AppendVarint(x, 4, total) + out = wire.AppendBytes(out, 1, x) + } + } + return out, nil + } + return nil, errors.New("eventactions: unknown voting operation") +} +func (s *Service) rankRows(uid, round uint64, ids []uint64) [][]byte { + type result struct{ id, count, last uint64 } + var rows []result + var totals map[uint64]uint64 + if s.voteTotals != nil { + totals, _ = s.voteTotals(uid, round) + } + for _, id := range ids { + r := result{id: id} + for k, x := range s.state.Votes { + var u, ro, c uint64 + if _, e := fmt.Sscanf(k, "%d/%d/%d", &u, &ro, &c); e == nil && u == uid && c == id && (round == 0 || round == ro) { + r.count += x.Normal + x.Additional + if ro > r.last { + r.last = ro + } + } + } + if totals != nil { + r.count = totals[id] + } + rows = append(rows, r) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].count == rows[j].count { + return rows[i].id < rows[j].id + } + return rows[i].count > rows[j].count + }) + var out [][]byte + var lastCount, rank uint64 + for i, r := range rows { + if i == 0 || r.count != lastCount { + rank = uint64(i + 1) + } + lastCount = r.count + x := wire.AppendVarint(nil, 1, r.id) + x = wire.AppendVarint(x, 2, r.count) + x = wire.AppendVarint(x, 3, rank) + x = wire.AppendVarint(x, 4, r.last) + out = append(out, x) + } + return out +} + +func (s *Service) advancedCandidates(v events.Schedule, event gamedata.EventActionRow, round uint64, ids []uint64) []uint64 { + for previous := uint64(1); previous < round; previous++ { + var count uint64 + for _, r := range s.design.Tables["VotingRoundTable"] { + if r.V(2) == event.V(14) && r.V(3) == previous { + count = r.V(1) + } + } + if count == 0 { + continue + } + ranked := s.rankRows(v.UID, previous, ids) + var next []uint64 + for _, b := range ranked { + if val(b, 3) <= count { + next = append(next, val(b, 1)) + } + } + ids = next + } + return ids +} +func (s *Service) totalRanking(event uint64) []byte { + out := wire.AppendVarint(nil, 1, event) + for _, v := range s.registry.List() { + if v.Type != 23 || v.ID != event { + continue + } + r, ok := s.design.Row("VotingEventTable", 6, event) + if !ok { + continue + } + for _, row := range s.rankRows(v.UID, 0, s.candidates(r)) { + out = wire.AppendBytes(out, 2, row) + } + } + return out +} diff --git a/go/internal/server/eventexchange/service.go b/go/internal/server/eventexchange/service.go new file mode 100644 index 0000000..b15db6f --- /dev/null +++ b/go/internal/server/eventexchange/service.go @@ -0,0 +1,400 @@ +package eventexchange + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "math" + "math/big" + "sort" + "strconv" + "sync" + "time" +) + +type Runtime interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) + ConsumeAndGrant(string, []player.Item, []gamedata.BattleReward) ([]byte, error) +} +type progress struct { + Group, Page, Key, Free uint64 + Reset int64 + Counts map[string]uint64 +} +type receipt struct { + Fingerprint, Response []byte + Code int +} +type snapshot struct { + Version string `json:"version"` + Progress map[string]progress `json:"progress"` + Receipts map[string]receipt `json:"receipts"` +} +type Service struct { + mu sync.Mutex + store stateio.Store + design *gamedata.EventExchangeCatalog + registry events.Resolver + runtime Runtime + now func() time.Time + draw func(uint64) (uint64, error) +} + +func Open(store stateio.Store, design *gamedata.EventExchangeCatalog, registry events.Resolver, runtime Runtime) (*Service, error) { + if store == nil || design == nil || registry == nil || runtime == nil { + return nil, fmt.Errorf("eventexchange: invalid configuration") + } + s := &Service{store: store, design: design, registry: registry, runtime: runtime, now: time.Now} + s.draw = func(n uint64) (uint64, error) { + if n == 0 { + return 0, fmt.Errorf("eventexchange: empty reward pool") + } + v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) + if e != nil { + return 0, e + } + return v.Uint64(), nil + } + _, err := s.load() + return s, err +} +func (s *Service) load() (snapshot, error) { + st := snapshot{versionconfig.State(), map[string]progress{}, map[string]receipt{}} + b, err := s.store.Load("eventexchange") + if err != nil { + return st, err + } + if b == nil { + return st, nil + } + if err = stateio.RequireExactJSONObject(b, "version", "progress", "receipts"); err != nil { + return st, err + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err = dec.Decode(&st); err != nil { + return st, err + } + if st.Version != versionconfig.State() || st.Progress == nil || st.Receipts == nil { + return st, fmt.Errorf("eventexchange: incompatible state") + } + for uid, p := range st.Progress { + n, e := strconv.ParseUint(uid, 10, 64) + g, ok := s.design.Groups[p.Group] + if e != nil || n == 0 || !ok || p.Page < g.StartPage || (!g.Repeat && p.Page > g.EndPage) || p.Counts == nil || p.Free > g.FreeCount { + return st, fmt.Errorf("eventexchange: invalid saved progress") + } + known := map[string]uint64{} + for _, entry := range g.Page(p.Page) { + known[strconv.FormatUint(entry.ID, 10)] = entry.SetCount + } + for id, count := range p.Counts { + limit, ok := known[id] + if !ok || count > limit { + return st, fmt.Errorf("eventexchange: invalid reward count") + } + } + } + return st, nil +} +func (s *Service) save(st snapshot) error { + b, err := json.Marshal(st) + if err != nil { + return err + } + return s.store.Save("eventexchange", b) +} +func (s *Service) active(uid uint64) (events.Schedule, gamedata.EventExchangeGroup, error) { + a, e := s.registry.Resolve(uid) + if e != nil { + return a, gamedata.EventExchangeGroup{}, e + } + g, ok := s.design.Groups[a.ID] + now := s.now().UnixMilli() + if !ok || a.Type != 7 || now < a.Start || now >= a.End { + return a, g, fmt.Errorf("eventexchange: event unavailable") + } + return a, g, nil +} +func (s *Service) init(st *snapshot, a events.Schedule, g gamedata.EventExchangeGroup) progress { + k := strconv.FormatUint(a.UID, 10) + p, ok := st.Progress[k] + if !ok { + p = progress{Group: g.ID, Page: g.StartPage, Free: g.FreeCount, Counts: map[string]uint64{}} + } + if g.FreeType == 2 { + now := s.now() + if p.Reset <= now.UnixMilli() { + p.Free = g.FreeCount + p.Reset = now.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour).UnixMilli() + } + } + return p +} +func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { + return s.HandleSession(path, req, "local") +} +func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { + code, ok := map[string]int{"/EventExchangeInfo": 183, "/EventExchangeReward": 184, "/EventExchangeNextPageOpen": 185}[path] + if !ok { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + seq, found, err := wire.Varint(req, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return code, nil, true, fmt.Errorf("eventexchange: invalid sequence") + } + st, err := s.load() + if err != nil { + return code, nil, true, err + } + key := session + ":" + strconv.FormatUint(seq, 10) + fingerprint := append([]byte(path), req...) + if r, ok := st.Receipts[key]; ok { + if !bytes.Equal(fingerprint, r.Fingerprint) { + return code, nil, true, fmt.Errorf("eventexchange: sequence conflict") + } + return r.Code, r.Response, true, nil + } + var out []byte + if path == "/EventExchangeInfo" { + rows := s.registry.List() + sort.Slice(rows, func(i, j int) bool { return rows[i].UID < rows[j].UID }) + for _, a := range rows { + _, g, e := s.active(a.UID) + if e != nil { + continue + } + p := s.init(&st, a, g) + st.Progress[strconv.FormatUint(a.UID, 10)] = p + out = wire.AppendBytes(out, 1, progressWire(a.UID, p)) + for _, entry := range g.Page(p.Page) { + out = wire.AppendBytes(out, 2, countWire(a.UID, g.ID, entry.ID, p.Counts[strconv.FormatUint(entry.ID, 10)])) + } + } + if err = s.save(st); err != nil { + return code, nil, true, err + } + return code, out, true, nil + } + uid, _, e := wire.Varint(req, 2) + if e != nil || uid == 0 { + return code, nil, true, fmt.Errorf("eventexchange: invalid event UID") + } + a, g, err := s.active(uid) + if err != nil { + return code, nil, true, err + } + p := s.init(&st, a, g) + if path == "/EventExchangeNextPageOpen" { + group, _, e := wire.Varint(req, 3) + if e != nil || group != g.ID { + return code, nil, true, fmt.Errorf("eventexchange: group mismatch") + } + if !canAdvance(g, p) { + return code, nil, true, fmt.Errorf("eventexchange: next page locked") + } + if err = advance(g, &p); err != nil { + return code, nil, true, err + } + } else { + count, _, e := wire.Varint(req, 3) + if e != nil || count == 0 || count > 10 { + return code, nil, true, fmt.Errorf("eventexchange: count must be 1..10") + } + uses, e := parseUses(req) + if e != nil { + return code, nil, true, e + } + free := len(uses) == 0 + if free { + if g.FreeCount == 0 || p.Free < count { + return code, nil, true, fmt.Errorf("eventexchange: free draws exhausted") + } + p.Free -= count + } else { + if g.Cost.Count > math.MaxInt32/count { + return code, nil, true, fmt.Errorf("eventexchange: cost overflow") + } + want := g.Cost.Count * count + var total uint64 + for _, item := range uses { + if item.Type != g.Cost.Type || item.ID != g.Cost.ID || item.Count == 0 || item.Count > math.MaxInt32 || total > math.MaxInt32-item.Count { + return code, nil, true, fmt.Errorf("eventexchange: invalid consume items") + } + total += item.Count + } + if total != want { + return code, nil, true, fmt.Errorf("eventexchange: consume count mismatch") + } + } + var rewards []gamedata.Reward + var changed []byte + for n := uint64(0); n < count; n++ { + entries := g.Page(p.Page) + var weights []uint64 + var sum uint64 + for _, entry := range entries { + remaining := entry.SetCount - p.Counts[strconv.FormatUint(entry.ID, 10)] + weight := entry.Ratio + var draws uint64 + for _, c := range p.Counts { + draws += c + } + if g.UnlockRatio > 0 && draws+1 < g.UnlockRatio { + weight = entry.LimitedRatio + } + if remaining == 0 { + weight = 0 + } + if sum > math.MaxUint64-weight { + return code, nil, true, fmt.Errorf("eventexchange: weight sum overflow") + } + sum += weight + weights = append(weights, weight) + } + if sum == 0 { + return code, nil, true, fmt.Errorf("eventexchange: reward pool exhausted") + } + v, e := s.draw(sum) + if e != nil || v >= sum { + return code, nil, true, fmt.Errorf("eventexchange: random source failure") + } + for i, entry := range entries { + if v < weights[i] { + id := strconv.FormatUint(entry.ID, 10) + p.Counts[id]++ + if entry.KeyType == 1 && p.Counts[id] == entry.SetCount { + p.Key++ + } + rewards = append(rewards, entry.Reward) + changed = wire.AppendBytes(changed, 2, countWire(uid, g.ID, entry.ID, p.Counts[id])) + break + } + v -= weights[i] + } + if exhausted(g, p) && (p.Page < g.EndPage || g.Repeat) { + if e := advance(g, &p); e != nil { + return code, nil, true, e + } + } + } + var bundle []byte + if free { + bundle, err = s.runtime.Apply("eventexchange:"+key, nil, rewards) + } else { + currency := g.Cost.Type == 2 || g.Cost.Type == 3 || g.Cost.Type == 4 || g.Cost.Type == 12 || g.Cost.Type == 20 + if currency { + cost := g.Cost + cost.Count *= count + bundle, err = s.runtime.Apply("eventexchange:"+key, []gamedata.Reward{cost}, rewards) + } else { + rs := make([]gamedata.BattleReward, len(rewards)) + for i, r := range rewards { + rs[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} + } + bundle, err = s.runtime.ConsumeAndGrant("eventexchange:"+key, uses, rs) + } + } + if err != nil { + return code, nil, true, err + } + out = wire.AppendBytes(out, 1, bundle) + out = append(out, changed...) + out = wire.AppendBytes(out, 3, progressWire(uid, p)) + } + st.Progress[strconv.FormatUint(uid, 10)] = p + st.Receipts[key] = receipt{fingerprint, out, code} + if err = s.save(st); err != nil { + return code, nil, true, err + } + return code, out, true, nil +} +func exhausted(g gamedata.EventExchangeGroup, p progress) bool { + for _, e := range g.Page(p.Page) { + if p.Counts[strconv.FormatUint(e.ID, 10)] < e.SetCount { + return false + } + } + return true +} +func canAdvance(g gamedata.EventExchangeGroup, p progress) bool { + if p.Page >= g.EndPage && !g.Repeat { + return false + } + var keys uint64 + for _, e := range g.Page(p.Page) { + if e.KeyType == 1 { + keys++ + } + } + return exhausted(g, p) || (keys > 0 && p.Key >= keys) +} +func advance(g gamedata.EventExchangeGroup, p *progress) error { + if p.Page >= math.MaxInt32 { + return fmt.Errorf("eventexchange: page overflow") + } + if p.Page >= g.EndPage && !g.Repeat { + return fmt.Errorf("eventexchange: final page") + } + p.Page++ + p.Key = 0 + p.Counts = map[string]uint64{} + return nil +} +func progressWire(uid uint64, p progress) []byte { + var out []byte + for _, f := range []struct { + n int + v uint64 + }{{1, uid}, {2, p.Group}, {3, p.Page}, {4, p.Key}, {5, p.Free}, {6, uint64(p.Reset)}} { + if f.v > 0 { + out = wire.AppendVarint(out, f.n, f.v) + } + } + return out +} +func countWire(uid, group, id, count uint64) []byte { + b := wire.AppendVarint(nil, 1, uid) + b = wire.AppendVarint(b, 2, group) + b = wire.AppendVarint(b, 3, id) + if count > 0 { + b = wire.AppendVarint(b, 4, count) + } + return b +} +func parseUses(req []byte) ([]player.Item, error) { + var out []player.Item + seen := map[uint64]bool{} + err := wire.Walk(req, func(f wire.Field) error { + if f.Number != 4 { + return nil + } + if f.Type != 2 { + return fmt.Errorf("eventexchange: malformed use item") + } + var item player.Item + for n, p := range map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count} { + v, _, e := wire.Varint(f.Value, n) + if e != nil { + return e + } + *p = v + } + if item.Type == 0 || item.Count == 0 || item.InvenIndex > math.MaxInt64 || seen[item.InvenIndex] { + return fmt.Errorf("eventexchange: invalid or duplicate consume item") + } + seen[item.InvenIndex] = true + out = append(out, item) + return nil + }) + return out, err +} diff --git a/go/internal/server/eventexchange/service_test.go b/go/internal/server/eventexchange/service_test.go new file mode 100644 index 0000000..71dfdd8 --- /dev/null +++ b/go/internal/server/eventexchange/service_test.go @@ -0,0 +1,131 @@ +package eventexchange + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "fmt" + "testing" + "time" +) + +type fakeRuntime struct { + paid, grants uint64 + fail bool + uses []player.Item +} + +func (r *fakeRuntime) Apply(_ string, costs, rewards []gamedata.Reward) ([]byte, error) { + if r.fail { + return nil, fmt.Errorf("failed") + } + for _, c := range costs { + r.paid += c.Count + } + r.grants += uint64(len(rewards)) + return []byte{10, 0}, nil +} +func (r *fakeRuntime) ConsumeAndGrant(_ string, uses []player.Item, rs []gamedata.BattleReward) ([]byte, error) { + if r.fail { + return nil, fmt.Errorf("failed") + } + r.uses = uses + r.grants += uint64(len(rs)) + return []byte{10, 0}, nil +} +func exchangeFixture(t *testing.T) (*Service, *fakeRuntime) { + t.Helper() + reg := events.NewRegistry() + if e := reg.Replace([]events.Schedule{{UID: 99, Type: 7, ID: 1, Start: 1, End: 9999999999999}}); e != nil { + t.Fatal(e) + } + g := gamedata.EventExchangeGroup{ID: 1, StartPage: 1, EndPage: 2, Repeat: true, Cost: gamedata.Reward{Type: 8, ID: 77, Count: 2}, FreeCount: 1, FreeType: 2, Entries: []gamedata.EventExchangeEntry{{ID: 1, Page: 1, KeyType: 1, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 4, Count: 100}}, {ID: 2, Page: 1, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 1}}, {ID: 3, Page: 2, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 4, Count: 5}}}} + r := &fakeRuntime{} + s, e := Open(stateio.NewMemory(), &gamedata.EventExchangeCatalog{Groups: map[uint64]gamedata.EventExchangeGroup{1: g}}, reg, r) + if e != nil { + t.Fatal(e) + } + s.now = func() time.Time { return time.UnixMilli(10000) } + s.draw = func(uint64) (uint64, error) { return 0, nil } + return s, r +} +func exchangeRequest(seq, count uint64, paid bool) []byte { + b := wire.AppendVarint(nil, 1, seq) + b = wire.AppendVarint(b, 2, 99) + b = wire.AppendVarint(b, 3, count) + if paid { + item := wire.AppendVarint(nil, 1, 123) + item = wire.AppendVarint(item, 2, 77) + item = wire.AppendVarint(item, 3, 8) + item = wire.AppendVarint(item, 4, count*2) + b = wire.AppendBytes(b, 4, item) + } + return b +} +func TestFreePaidReplayAndKeyAdvance(t *testing.T) { + s, r := exchangeFixture(t) + req := exchangeRequest(1, 1, false) + _, a, _, e := s.HandleSession("/EventExchangeReward", req, "session") + if e != nil { + t.Fatal(e) + } + _, b, _, e := s.HandleSession("/EventExchangeReward", req, "session") + if e != nil || !bytes.Equal(a, b) || r.grants != 1 { + t.Fatalf("replay %v %d", e, r.grants) + } + if _, _, _, e = s.HandleSession("/EventExchangeReward", exchangeRequest(2, 1, false), "session"); e == nil { + t.Fatal("free reused") + } + next := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 99), 3, 1) + if _, _, _, e = s.HandleSession("/EventExchangeNextPageOpen", next, "session"); e != nil { + t.Fatal(e) + } + if _, _, _, e = s.HandleSession("/EventExchangeReward", exchangeRequest(4, 1, true), "session"); e != nil { + t.Fatal(e) + } + st, _ := s.load() + if st.Progress["99"].Page != 3 || len(r.uses) != 1 || r.uses[0].InvenIndex != 123 { + t.Fatalf("advance %+v uses %+v", st, r.uses) + } +} +func TestInvalidCostAndFailedGrantKeepPool(t *testing.T) { + s, r := exchangeFixture(t) + bad := exchangeRequest(1, 1, true) + bad = wire.AppendBytes(bad, 4, wire.AppendVarint(nil, 3, 8)) + if _, _, _, e := s.HandleSession("/EventExchangeReward", bad, "session"); e == nil { + t.Fatal("invalid cost accepted") + } + r.fail = true + if _, _, _, e := s.HandleSession("/EventExchangeReward", exchangeRequest(2, 1, false), "session"); e == nil { + t.Fatal("failed grant accepted") + } + st, _ := s.load() + if len(st.Progress) != 0 || len(st.Receipts) != 0 { + t.Fatal("failed draw saved progress") + } +} +func TestLuckyPrizeLockedUntilUnlockDraw(t *testing.T) { + s, _ := exchangeFixture(t) + g := s.design.Groups[1] + g.UnlockRatio = 3 + g.Entries[0].LimitedRatio = 0 + g.Entries[1].LimitedRatio = 1 + g.Entries[1].SetCount = 4 + s.design.Groups[1] = g + for i := uint64(1); i <= 3; i++ { + if _, _, _, e := s.HandleSession("/EventExchangeReward", exchangeRequest(i, 1, true), "session"); e != nil { + t.Fatal(e) + } + st, _ := s.load() + count := st.Progress["99"].Counts["1"] + if i < 3 && count != 0 { + t.Fatal("prize unlocked early") + } + if i == 3 && count != 1 { + t.Fatal("prize not unlocked") + } + } +} diff --git a/go/internal/server/eventgames/SPEC.md b/go/internal/server/eventgames/SPEC.md new file mode 100644 index 0000000..47cdf8d --- /dev/null +++ b/go/internal/server/eventgames/SPEC.md @@ -0,0 +1,46 @@ +# Seasonal minigame rules + +The registry supplies event identity and dates. This package never creates, +extends or activates a season. Each schedule UID owns independent progress. +Queries initialize a valid new board and return persisted progress. Mutations +require a live matching schedule. Requests are identified by session, path and +sequence; identical retries replay the entire stored response and changed +retries fail. + +GameData supplies costs, move ranges, board size, reward sets, completion +thresholds, free roulette draws, special slots and pity thresholds. Client +protobuf and packet callbacks supply field shapes. RouletteInfo capture is a +comparison sample, not runtime data. + +LocalText 460038 describes Bingo opening rewards and completed-line rewards. +LocalText 460040 describes puzzle tile rewards, word completion rewards and +the final stage's prohibition on renewal. These meanings drive the following +server rules: + +- Dice starts at the first designed scaffold. Each configured controller + samples uniformly inside its inclusive range. Crossing the end advances + completion count; all newly crossed completion rewards and the final + landing scaffold reward settle together. +- Bingo shuffles the reward IDs in its current clear-count reward set. + Opening samples unopened zero-based positions without replacement. + Protocol line types are diagonal 0, row 1 and column 2. Each line pays once + per board. Finishing advances count and creates a fresh board; after the + last designed reward tier, that tier repeats. +- Puzzle starts at designed stage 1. Reward IDs form the board. Opening a + tile pays its reward; opening all members of a word pays that word once. + Renewal requires all current-stage word members, as the client checks, + and advances only while another designed stage exists. Unopened incidental + tiles may be abandoned on renewal. The final stage cannot renew. +- Roulette free draws reset at UTC midnight (KST 09:00); accumulated tries + and special/pity state survive daily reset. Free and paid draws enforce + their separate costs. Weighted sampling uses the design's primary weights, + then secondary weights after a special reward where provided. Pity forces + a designed special slot. Accumulated threshold rewards pay when crossed. + +Randomness uses crypto/rand and can be injected in package tests. All submitted +consumed item records must match the server-computed design cost. State is +cloned, an entire batch is simulated and validated, and then economic rewards +and the snapshot are saved. Production must run this handler under the +account repository's request transaction; failed requests roll back item, +wallet and snapshot changes together. The main server passes an entry-backed +snapshot store, so no existing domain core layout changes. diff --git a/go/internal/server/eventgames/games.go b/go/internal/server/eventgames/games.go new file mode 100644 index 0000000..7e0f3b9 --- /dev/null +++ b/go/internal/server/eventgames/games.go @@ -0,0 +1,402 @@ +// Package eventgames implements this server's persistent seasonal minigames. +package eventgames + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "crypto/rand" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "math/big" + "strconv" + "strings" + "sync" + "time" +) + +type Resolver interface { + Resolve(uint64) (events.Schedule, error) +} +type Rewards interface { + ConsumeAndGrant(string, []player.Item, []gamedata.BattleReward) ([]byte, error) +} +type GameState struct { + UID, Type, ID, Position, Clear, Free, Reset, Tries, SinceSpecial uint64 + Board, Opened, Lines []uint64 + Special bool +} +type reply struct{ Request, Body []byte } +type snapshot struct { + Version int `json:"version"` + Games map[string]GameState `json:"games"` + Replies map[string]reply `json:"replies"` +} +type Service struct { + mu sync.Mutex + store stateio.Store + resolver Resolver + rewards Rewards + state snapshot + load func(uint64, uint64) (*gamedata.EventGame, error) + sample func(uint64) (uint64, error) + now func() time.Time +} + +func Open(store stateio.Store, root, version string, resolver Resolver, rewards Rewards) (*Service, error) { + if store == nil || resolver == nil || rewards == nil { + return nil, fmt.Errorf("eventgames: incomplete runtime") + } + s := &Service{store: store, resolver: resolver, rewards: rewards, now: time.Now, state: snapshot{1, map[string]GameState{}, map[string]reply{}}} + cache := map[string]*gamedata.EventGame{} + s.load = func(kind, id uint64) (*gamedata.EventGame, error) { + k := fmt.Sprintf("%d:%d", kind, id) + if d := cache[k]; d != nil { + return d, nil + } + d, e := gamedata.LoadEventGame(root, version, kind, id) + if e == nil { + cache[k] = d + } + return d, e + } + s.sample = func(max uint64) (uint64, error) { + if max == 0 { + return 0, fmt.Errorf("eventgames: empty sample") + } + n, e := rand.Int(rand.Reader, new(big.Int).SetUint64(max)) + if e != nil { + return 0, e + } + return n.Uint64(), nil + } + raw, e := store.Load("eventgames") + if e != nil { + return nil, e + } + if raw != nil { + if e = stateio.RequireExactJSONObject(raw, "version", "games", "replies"); e != nil { + return nil, e + } + if e = json.Unmarshal(raw, &s.state); e != nil { + return nil, e + } + if s.state.Version != 1 || s.state.Games == nil || s.state.Replies == nil { + return nil, fmt.Errorf("eventgames: incompatible state") + } + } + for key, g := range s.state.Games { + d, e := s.load(g.Type, g.ID) + if e != nil { + return nil, e + } + if e = validateSaved(key, g, d); e != nil { + return nil, e + } + } + return s, nil +} +func nums(b []byte, n int) ([]uint64, error) { + var out []uint64 + e := wire.Walk(b, func(f wire.Field) error { + if f.Number != n { + return nil + } + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + out = append(out, v) + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + for p := f.Value; len(p) > 0; { + v, k := binary.Uvarint(p) + if k <= 0 { + return wire.ErrMalformed + } + out = append(out, v) + p = p[k:] + } + return nil + }) + return out, e +} +func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } +func contains(a []uint64, v uint64) bool { + for _, x := range a { + if x == v { + return true + } + } + return false +} +func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { + return s.HandleSession(path, req, "local") +} + +var routes = map[string]struct { + Code int + Kind uint64 +}{"/MiniGameBoardInfo": {223, 12}, "/MiniGameBoardPlay": {224, 12}, "/MiniGameBingoInfo": {256, 13}, "/MiniGameBingoPlay": {257, 13}, "/MiniPuzzleInfo": {343, 17}, "/MiniPuzzleOpen": {344, 17}, "/MiniPuzzleAllOpen": {345, 17}, "/MiniPuzzleRenew": {346, 17}, "/MiniGameRouletteInfo": {415, 19}, "/MiniGameRouletteDraw": {416, 19}} + +func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { + r, ok := routes[path] + if !ok { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + fail := func(e error) (int, []byte, bool, error) { return r.Code, nil, true, e } + if e := wire.Walk(req, func(f wire.Field) error { + if f.Type == 0 && f.Number != 3 { + v, _ := binary.Uvarint(f.Value) + if v > math.MaxInt32 { + return wire.ErrMalformed + } + } + return nil + }); e != nil { + return fail(e) + } + seq := scalar(req, 1) + if seq == 0 || seq > math.MaxInt32 { + return fail(fmt.Errorf("eventgames: invalid sequence")) + } + key := fmt.Sprintf("%s:%s:%d", session, path, seq) + if v, ok := s.state.Replies[key]; ok { + if !bytes.Equal(v.Request, req) { + return fail(fmt.Errorf("eventgames: changed request retry")) + } + return r.Code, v.Body, true, nil + } + raw, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(raw, &next) + uids, e := nums(req, 2) + if e != nil || len(uids) == 0 && !strings.HasSuffix(path, "Info") { + return fail(fmt.Errorf("eventgames: missing schedule")) + } + if len(uids) == 0 { + return r.Code, nil, true, nil + } + if !strings.HasSuffix(path, "Info") && len(uids) != 1 { + return fail(fmt.Errorf("eventgames: operation requires one schedule")) + } + var out []byte + seen := map[uint64]bool{} + for _, uid := range uids { + if uid == 0 || seen[uid] { + return fail(fmt.Errorf("eventgames: invalid duplicate schedule")) + } + seen[uid] = true + c, e := s.resolver.Resolve(uid) + if e != nil { + return fail(e) + } + if c.UID != uid || c.Type != r.Kind { + return fail(fmt.Errorf("eventgames: schedule/game mismatch")) + } + d, e := s.load(c.Type, c.ID) + if e != nil { + return fail(e) + } + k := strconv.FormatUint(uid, 10) + g, exists := next.Games[k] + if !exists { + g = GameState{UID: uid, Type: c.Type, ID: c.ID, Free: d.Free} + if e = s.initialize(&g, d); e != nil { + return fail(e) + } + } + if g.Type != c.Type || g.ID != c.ID { + return fail(fmt.Errorf("eventgames: saved game identity changed")) + } + now := uint64(s.now().UnixMilli()) + if c.Type == 19 && g.Reset <= now { + g.Free = d.Free + g.Reset = (now/86400000 + 1) * 86400000 + } + if strings.HasSuffix(path, "Info") { + out = wire.AppendBytes(out, 1, stateWire(g, d)) + next.Games[k] = g + continue + } + if int64(now) < c.Start || int64(now) >= c.End { + return fail(fmt.Errorf("eventgames: event is closed")) + } + before := stateWire(g, d) + body, cost, rewards, e := s.play(path, req, &g, d) + if e != nil { + return fail(e) + } + consumeField := 3 + if path == "/MiniPuzzleOpen" { + consumeField = 4 + } + if path == "/MiniGameRouletteDraw" { + consumeField = 5 + } + items, e := consume(req, consumeField, d, cost) + if e != nil { + return fail(e) + } + bundles := map[int][]byte{} + fields := []int{3, 4, 5, 6, 7} + if c.Type == 19 { + fields = []int{2, 3} + } + for _, field := range fields { + rr := rewards[field] + if len(rr) == 0 { + continue + } + consumed := []player.Item(nil) + if len(items) > 0 { + consumed = items + items = nil + } + b, e := s.rewards.ConsumeAndGrant(fmt.Sprintf("eventgames:%s:%d", key, field), consumed, rr) + if e != nil { + return fail(e) + } + bundles[field] = b + } + if len(items) > 0 { + if _, e = s.rewards.ConsumeAndGrant("eventgames:"+key+":cost", items, nil); e != nil { + return fail(e) + } + } + switch c.Type { + case 12: + body = wire.AppendBytes(body, 1, stateWire(g, d)) + case 13, 17: + body = wire.AppendBytes(body, 1, before) + body = wire.AppendBytes(body, 2, stateWire(g, d)) + case 19: + body = wire.AppendBytes(body, 1, stateWire(g, d)) + } + for _, field := range fields { + if b := bundles[field]; len(b) > 0 { + body = wire.AppendBytes(body, field, b) + } + } + out = body + next.Games[k] = g + } + next.Replies[key] = reply{append([]byte(nil), req...), out} + payload, e := json.Marshal(next) + if e != nil { + return fail(e) + } + if e = s.store.Save("eventgames", payload); e != nil { + return fail(e) + } + s.state = next + return r.Code, out, true, nil +} +func consume(req []byte, n int, d *gamedata.EventGame, count uint64) ([]player.Item, error) { + var out []player.Item + total := uint64(0) + seen := map[uint64]bool{} + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != n { + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + id, typ, qty, index := scalar(f.Value, 2), scalar(f.Value, 3), scalar(f.Value, 4), scalar(f.Value, 1) + if count == 0 || id != d.CostID || typ != d.CostType || qty == 0 || index == 0 || seen[index] || qty > math.MaxInt32 || total > math.MaxUint64-qty { + return fmt.Errorf("eventgames: invalid submitted cost") + } + seen[index] = true + total += qty + out = append(out, player.Item{InvenIndex: index, ID: id, Type: typ, Count: qty}) + return nil + }) + if e != nil { + return nil, e + } + if d.Cost > 0 && count > math.MaxUint64/d.Cost || total != count*d.Cost { + return nil, fmt.Errorf("eventgames: cost does not match design") + } + return out, nil +} +func stateWire(g GameState, d *gamedata.EventGame) []byte { + b := wire.AppendVarint(nil, 1, g.UID) + switch g.Type { + case 12: + b = wire.AppendVarint(b, 2, d.ScaffoldGroup) + b = wire.AppendVarint(b, 3, d.Cells[g.Position].ID) + b = wire.AppendVarint(b, 4, g.Clear) + case 13, 17: + b = wire.AppendVarint(b, 2, g.Clear) + for _, v := range g.Board { + b = wire.AppendVarint(b, 3, v) + } + for _, v := range g.Opened { + b = wire.AppendVarint(b, 4, v) + } + case 19: + b = wire.AppendVarint(b, 2, g.Free) + b = wire.AppendVarint(b, 3, g.Reset) + if g.Special { + b = wire.AppendVarint(b, 4, 1) + } + b = wire.AppendVarint(b, 5, g.Tries) + } + return b +} +func (s *Service) initialize(g *GameState, d *gamedata.EventGame) error { + if d.Type != 13 && d.Type != 17 { + return nil + } + count := g.Clear + if d.Type == 17 && count == 0 { + count = 1 + g.Clear = 1 + } + var cells []gamedata.EventGameReward + max := uint64(0) + for _, r := range d.Cells { + if r.Count > max { + max = r.Count + } + } + if count > max { + count = max + } + for _, r := range d.Cells { + if r.Count == count { + cells = append(cells, r) + } + } + if len(cells) == 0 { + return fmt.Errorf("eventgames: board rewards missing") + } + g.Board = nil + g.Opened = nil + g.Lines = nil + for _, r := range cells { + g.Board = append(g.Board, r.ID) + } + if d.Type == 13 { + for i := len(g.Board) - 1; i > 0; i-- { + j, e := s.sample(uint64(i + 1)) + if e != nil { + return e + } + if j >= uint64(i+1) { + return fmt.Errorf("eventgames: sampler out of range") + } + g.Board[i], g.Board[j] = g.Board[j], g.Board[i] + } + } + return nil +} diff --git a/go/internal/server/eventgames/games_test.go b/go/internal/server/eventgames/games_test.go new file mode 100644 index 0000000..2a9b4ad --- /dev/null +++ b/go/internal/server/eventgames/games_test.go @@ -0,0 +1,201 @@ +package eventgames + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "fmt" + "testing" + "time" +) + +type resolve struct{ kind uint64 } + +func (r resolve) Resolve(id uint64) (events.Schedule, error) { + if id != 7 { + return events.Schedule{}, fmt.Errorf("unknown") + } + return events.Schedule{UID: 7, Type: r.kind, ID: 1, Start: 1, End: 9999999999999}, nil +} + +type economy struct{ spent, granted uint64 } + +func (e *economy) ConsumeAndGrant(_ string, c []player.Item, r []gamedata.BattleReward) ([]byte, error) { + for _, x := range c { + e.spent += x.Count + } + for _, x := range r { + e.granted += x.Count + } + return wire.AppendVarint(nil, 1, 1), nil +} +func fixture(t *testing.T, d *gamedata.EventGame) (*Service, *economy) { + t.Helper() + e := &economy{} + s, err := Open(stateio.NewMemory(), "", "", resolve{d.Type}, e) + if err != nil { + t.Fatal(err) + } + s.load = func(uint64, uint64) (*gamedata.EventGame, error) { return d, nil } + s.sample = func(n uint64) (uint64, error) { + if n == 0 { + return 0, fmt.Errorf("empty") + } + return 0, nil + } + s.now = func() time.Time { return time.UnixMilli(1000) } + return s, e +} +func request(seq, n, cost uint64) []byte { + b := wire.AppendVarint(nil, 1, seq) + b = wire.AppendVarint(b, 2, 7) + item := wire.AppendVarint(nil, 1, 1) + item = wire.AppendVarint(item, 2, 5) + item = wire.AppendVarint(item, 3, 8) + item = wire.AppendVarint(item, 4, cost) + return wire.AppendBytes(b, int(n), item) +} +func TestBoardCostStateAndWholeReplyRetry(t *testing.T) { + d := &gamedata.EventGame{ID: 1, Type: 12, Cost: 1, CostID: 5, CostType: 8, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2, Rewards: []gamedata.BattleReward{{Type: 8, ID: 9, Count: 3}}}}, Moves: []struct{ ID, Min, Max uint64 }{{1, 1, 1}}} + s, e := fixture(t, d) + req := request(1, 3, 1) + _, a, _, err := s.HandleSession("/MiniGameBoardPlay", req, "sid") + if err != nil { + t.Fatal(err) + } + _, b, _, err := s.HandleSession("/MiniGameBoardPlay", req, "sid") + if err != nil || !bytes.Equal(a, b) || e.spent != 1 || e.granted != 3 { + t.Fatalf("retry error=%v spent=%d granted=%d", err, e.spent, e.granted) + } + bad := wire.AppendVarint(req, 4, 1) + if _, _, _, err = s.HandleSession("/MiniGameBoardPlay", bad, "sid"); err == nil { + t.Fatal("changed retry accepted") + } + if s.state.Games["7"].Position != 1 { + t.Fatal("move lost") + } +} +func TestPuzzleWordRenewAndNoDuplicateOpen(t *testing.T) { + d := &gamedata.EventGame{ID: 1, Type: 17, Cost: 1, CostID: 5, CostType: 8, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1, Count: 1, Slot: 12}, {ID: 2, Count: 1}, {ID: 1, Count: 2}}, Complete: []gamedata.EventGameReward{{ID: 1, Count: 1, Members: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 4, Count: 5}}}}} + s, e := fixture(t, d) + req := request(1, 4, 1) + req = wire.AppendVarint(req, 3, 1) + if _, _, _, err := s.HandleSession("/MiniPuzzleOpen", req, "sid"); err != nil { + t.Fatal(err) + } + if e.spent != 1 || e.granted != 5 { + t.Fatal("word award missing") + } + bad := request(2, 4, 1) + bad = wire.AppendVarint(bad, 3, 1) + if _, _, _, err := s.HandleSession("/MiniPuzzleOpen", bad, "sid"); err == nil { + t.Fatal("duplicate tile accepted") + } + renew := wire.AppendVarint(nil, 1, 3) + renew = wire.AppendVarint(renew, 2, 7) + if _, _, _, err := s.HandleSession("/MiniPuzzleRenew", renew, "sid"); err != nil { + t.Fatal(err) + } + if s.state.Games["7"].Clear != 2 || e.spent != 1 { + t.Fatal("renew cost/stage wrong") + } +} +func TestRouletteFreeCountAndMilestone(t *testing.T) { + d := &gamedata.EventGame{ID: 1, Type: 19, Cost: 1, CostID: 5, CostType: 8, Free: 1, Cells: []gamedata.EventGameReward{{ID: 1, Weight: 1, Rewards: []gamedata.BattleReward{{Count: 2}}}}, Complete: []gamedata.EventGameReward{{ID: 1, Count: 1, Rewards: []gamedata.BattleReward{{Count: 3}}}}} + s, e := fixture(t, d) + req := wire.AppendVarint(nil, 1, 1) + req = wire.AppendVarint(req, 2, 7) + req = wire.AppendVarint(req, 4, 1) + if _, _, _, err := s.HandleSession("/MiniGameRouletteDraw", req, "sid"); err != nil { + t.Fatal(err) + } + if e.spent != 0 || e.granted != 5 { + t.Fatal("free rewards wrong") + } + req, _, _ = wire.ReplaceVarint(req, 1, 2) + if _, _, _, err := s.HandleSession("/MiniGameRouletteDraw", req, "sid"); err == nil { + t.Fatal("extra free draw accepted") + } +} +func TestBingoLinesUseProtocolEnums(t *testing.T) { + g := GameState{Board: make([]uint64, 9), Opened: []uint64{0, 4, 8}} + if !completeLine(&g, 3, 0, 0) || completeLine(&g, 3, 1, 0) { + t.Fatal("diagonal/row enum wrong") + } + g.Opened = []uint64{0, 1, 2} + if !completeLine(&g, 3, 1, 0) { + t.Fatal("row wrong") + } + g.Opened = []uint64{0, 3, 6} + if !completeLine(&g, 3, 2, 0) { + t.Fatal("column wrong") + } +} + +func TestBingoLineRewardExactlyOnceAndBeforeAfterWire(t *testing.T) { + d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}, Lines: []gamedata.EventGameReward{{Count: 0, LineType: 1, LineIndex: 0, Rewards: []gamedata.BattleReward{{Count: 7}}}}} + s, _ := fixture(t, d) + g := GameState{UID: 7, Type: 13, ID: 1, Board: []uint64{1, 2, 3, 4}, Opened: []uint64{0}} + body, _, rs, err := s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d) + if err != nil || len(rs[6]) != 1 { + t.Fatalf("line reward %v %+v", err, rs) + } + line, found, e := wire.Bytes(body, 4) + if e != nil || !found || scalar(line, 1) != 1 || scalar(line, 2) != 0 { + t.Fatalf("line wire %x", body) + } + _, _, rs, err = s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d) + if err != nil || len(rs[6]) != 0 { + t.Fatal("same line granted twice") + } +} +func TestMiniGameSamplerOutOfRangeRejected(t *testing.T) { + d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}} + s, _ := fixture(t, d) + s.sample = func(n uint64) (uint64, error) { return n, nil } + g := GameState{} + if err := s.initialize(&g, d); err == nil { + t.Fatal("shuffle accepted bad RNG") + } + g.Board = []uint64{1, 2, 3, 4} + if _, _, _, err := s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d); err == nil { + t.Fatal("draw accepted bad RNG") + } +} + +func TestSavedMiniGameBoardAndCompletedLineRejected(t *testing.T) { + d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}, Lines: []gamedata.EventGameReward{{LineType: 1, LineIndex: 0}}} + g := GameState{UID: 7, Type: 13, ID: 1, Board: []uint64{1, 2, 3}} + if e := validateSaved("7", g, d); e == nil { + t.Fatal("truncated board accepted") + } + g.Board = append(g.Board, 4) + g.Lines = []uint64{1000} + if e := validateSaved("7", g, d); e == nil { + t.Fatal("uncompleted line claimed") + } + g.Opened = []uint64{0, 1} + if e := validateSaved("7", g, d); e != nil { + t.Fatal(e) + } +} + +func TestRoulettePityAndAccumulatedWire(t *testing.T) { + d := &gamedata.EventGame{ID: 1, Type: 19, RewardGroup: 77, Pity: 3, Cells: []gamedata.EventGameReward{{ID: 1, Weight: 100, Weight2: 100}, {ID: 2, Slot: 1, Weight: 1, Weight2: 1}}, Complete: []gamedata.EventGameReward{{Count: 3, Rewards: []gamedata.BattleReward{{Count: 5}}}}} + s, _ := fixture(t, d) + g := GameState{UID: 7, Type: 19, ID: 1, Tries: 2, SinceSpecial: 2} + req := wire.AppendVarint(wire.AppendVarint(nil, 3, 1), 4, 1) + body, _, rewards, e := s.play("/MiniGameRouletteDraw", req, &g, d) + if e != nil || !g.Special || g.SinceSpecial != 0 || g.Tries != 3 || len(rewards[3]) != 1 { + t.Fatalf("pity state %+v rewards%v err%v", g, rewards, e) + } + info, found, _ := wire.Bytes(body, 4) + group, _, _ := wire.Varint(info, 1) + id, _, _ := wire.Varint(info, 2) + if !found || group != 77 || id != 2 { + t.Fatalf("roulette field4 %x", body) + } +} diff --git a/go/internal/server/eventgames/play.go b/go/internal/server/eventgames/play.go new file mode 100644 index 0000000..98fec81 --- /dev/null +++ b/go/internal/server/eventgames/play.go @@ -0,0 +1,294 @@ +package eventgames + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "fmt" + "math" +) + +func (s *Service) play(path string, req []byte, g *GameState, d *gamedata.EventGame) ([]byte, uint64, map[int][]gamedata.BattleReward, error) { + var body []byte + cost := uint64(1) + rewards := map[int][]gamedata.BattleReward{} + fail := func(e error) ([]byte, uint64, map[int][]gamedata.BattleReward, error) { return nil, 0, nil, e } + switch d.Type { + case 12: + if len(d.Moves) == 0 { + return fail(fmt.Errorf("eventgames: move rules missing")) + } + for _, m := range d.Moves { + if m.Max < m.Min { + return fail(fmt.Errorf("eventgames: invalid move range")) + } + x, e := s.sample(m.Max - m.Min + 1) + if e != nil { + return fail(e) + } + if x >= m.Max-m.Min+1 { + return fail(fmt.Errorf("eventgames: sampler out of range")) + } + steps := m.Min + x + v := wire.AppendVarint(nil, 1, m.ID) + v = wire.AppendVarint(v, 2, steps) + body = wire.AppendBytes(body, 2, v) + old := g.Clear + g.Clear += (g.Position + steps) / uint64(len(d.Cells)) + g.Position = (g.Position + steps) % uint64(len(d.Cells)) + for _, r := range d.Complete { + if r.Count > old && r.Count <= g.Clear { + rewards[3] = append(rewards[3], r.Rewards...) + } + } + } + rewards[4] = append(rewards[4], d.Cells[g.Position].Rewards...) + case 13: + plays := scalar(req, 4) + if plays == 0 || plays > uint64(len(g.Board)-len(g.Opened)) { + return fail(fmt.Errorf("eventgames: invalid bingo play count")) + } + cost = plays + for i := uint64(0); i < plays; i++ { + var available []uint64 + for pos := range g.Board { + if !contains(g.Opened, uint64(pos)) { + available = append(available, uint64(pos)) + } + } + pick, e := s.sample(uint64(len(available))) + if e != nil { + return fail(e) + } + if pick >= uint64(len(available)) { + return fail(fmt.Errorf("eventgames: sampler out of range")) + } + pos := available[pick] + g.Opened = append(g.Opened, pos) + body = wire.AppendVarint(body, 3, pos) + for _, r := range d.Cells { + if r.ID == g.Board[pos] && r.Count == rewardCount(d, g.Clear) { + rewards[5] = append(rewards[5], r.Rewards...) + } + } + } + for _, r := range d.Lines { + if r.Count != rewardCount(d, g.Clear) { + continue + } + key := r.LineType*1000 + r.LineIndex + if contains(g.Lines, key) { + continue + } + if completeLine(g, d.Columns, r.LineType, r.LineIndex) { + g.Lines = append(g.Lines, key) + v := wire.AppendVarint(nil, 1, r.LineType) + v = wire.AppendVarint(v, 2, r.LineIndex) + body = wire.AppendBytes(body, 4, v) + rewards[6] = append(rewards[6], r.Rewards...) + } + } + if len(g.Opened) == len(g.Board) { + g.Clear++ + for _, r := range d.Complete { + if r.Count == g.Clear { + rewards[7] = append(rewards[7], r.Rewards...) + } + } + if e := s.initialize(g, d); e != nil { + return fail(e) + } + } + case 17: + if path == "/MiniPuzzleRenew" { + wordReady := false + for _, r := range d.Complete { + if r.Count != g.Clear { + continue + } + wordReady = true + for _, id := range r.Members { + if !contains(g.Opened, id) { + return fail(fmt.Errorf("eventgames: puzzle word incomplete")) + } + } + } + if !wordReady { + return fail(fmt.Errorf("eventgames: puzzle word rules missing")) + } + max := uint64(0) + for _, r := range d.Cells { + if r.Count > max { + max = r.Count + } + } + if g.Clear >= max { + return fail(fmt.Errorf("eventgames: final puzzle completed")) + } + g.Clear++ + if e := s.initialize(g, d); e != nil { + return fail(e) + } + return body, 0, rewards, nil + } + var opens []uint64 + if path == "/MiniPuzzleAllOpen" { + for _, id := range g.Board { + if !contains(g.Opened, id) { + opens = append(opens, id) + } + } + } else { + id := scalar(req, 3) + if !contains(g.Board, id) || contains(g.Opened, id) { + return fail(fmt.Errorf("eventgames: puzzle tile unavailable")) + } + opens = []uint64{id} + } + if len(opens) == 0 { + return fail(fmt.Errorf("eventgames: puzzle already open")) + } + cost = uint64(len(opens)) + for _, id := range opens { + g.Opened = append(g.Opened, id) + for _, r := range d.Cells { + if r.ID == id && r.Count == g.Clear { + rewards[4] = append(rewards[4], r.Rewards...) + if r.Slot != 0 { + v := wire.AppendVarint(nil, 1, id) + v = wire.AppendVarint(v, 2, r.Slot) + body = wire.AppendBytes(body, 3, v) + } + } + } + } + for _, r := range d.Complete { + if r.Count != g.Clear || contains(g.Lines, r.ID) { + continue + } + all := true + for _, id := range r.Members { + all = all && contains(g.Opened, id) + } + if all { + g.Lines = append(g.Lines, r.ID) + rewards[5] = append(rewards[5], r.Rewards...) + } + } + case 19: + n := scalar(req, 4) + typ := scalar(req, 3) + if n == 0 || n > 100 || typ > 1 { + return fail(fmt.Errorf("eventgames: invalid roulette draw")) + } + if typ == 0 { + if n > g.Free { + return fail(fmt.Errorf("eventgames: insufficient free draws")) + } + g.Free -= n + cost = 0 + } else { + cost = n + if d.MaxConsume > 0 && n > d.MaxConsume { + return fail(fmt.Errorf("eventgames: roulette draw exceeds limit")) + } + } + for i := uint64(0); i < n; i++ { + force := d.Pity > 0 && g.SinceSpecial+1 >= d.Pity + var candidates []gamedata.EventGameReward + total := uint64(0) + for _, r := range d.Cells { + if force && r.Slot != 1 { + continue + } + w := r.Weight + if g.Special { + w = r.Weight2 + } + if w == 0 { + continue + } + if total > math.MaxUint64-w { + return fail(fmt.Errorf("eventgames: roulette weights overflow")) + } + total += w + candidates = append(candidates, r) + } + if total == 0 { + return fail(fmt.Errorf("eventgames: roulette eligible pool empty")) + } + x, e := s.sample(total) + if e != nil { + return fail(e) + } + if x >= total { + return fail(fmt.Errorf("eventgames: sampler out of range")) + } + var chosen gamedata.EventGameReward + for _, r := range candidates { + w := r.Weight + if g.Special { + w = r.Weight2 + } + if x < w { + chosen = r + break + } + x -= w + } + g.Tries++ + g.SinceSpecial++ + if chosen.Slot == 1 { + g.Special = true + g.SinceSpecial = 0 + } + rewards[2] = append(rewards[2], chosen.Rewards...) + v := wire.AppendVarint(nil, 1, d.RewardGroup) + v = wire.AppendVarint(v, 2, chosen.ID) + body = wire.AppendBytes(body, 4, v) + for _, r := range d.Complete { + if r.Count == g.Tries { + rewards[3] = append(rewards[3], r.Rewards...) + } + } + } + } + return body, cost, rewards, nil +} +func rewardCount(d *gamedata.EventGame, count uint64) uint64 { + max := uint64(0) + for _, r := range d.Cells { + if r.Count > max { + max = r.Count + } + } + if count > max { + return max + } + return count +} +func completeLine(g *GameState, n, typ, index uint64) bool { + if n == 0 { + return false + } + for i := uint64(0); i < n; i++ { + var pos uint64 + switch typ { + case 1: + pos = index*n + i + case 2: + pos = i*n + index + case 0: + if index == 0 { + pos = i*n + i + } else { + pos = i*n + (n - 1 - i) + } + default: + return false + } + if pos >= uint64(len(g.Board)) || !contains(g.Opened, pos) { + return false + } + } + return true +} diff --git a/go/internal/server/eventgames/validation.go b/go/internal/server/eventgames/validation.go new file mode 100644 index 0000000..fa94b84 --- /dev/null +++ b/go/internal/server/eventgames/validation.go @@ -0,0 +1,87 @@ +package eventgames + +import ( + "bd2server/internal/server/gamedata" + "fmt" + "strconv" +) + +func validateSaved(key string, g GameState, d *gamedata.EventGame) error { + if key != strconv.FormatUint(g.UID, 10) || g.ID != d.ID || g.Type != d.Type { + return fmt.Errorf("eventgames: invalid saved identity") + } + if g.Type == 12 { + if g.Position >= uint64(len(d.Cells)) { + return fmt.Errorf("eventgames: invalid saved position") + } + return nil + } + if g.Type == 19 { + if g.Free > d.Free || g.SinceSpecial > g.Tries { + return fmt.Errorf("eventgames: invalid saved roulette counters") + } + return nil + } + expected := 0 + for _, r := range d.Cells { + if r.Count == rewardCount(d, g.Clear) { + expected++ + } + } + if len(g.Board) != expected || expected == 0 { + return fmt.Errorf("eventgames: saved board size differs from design") + } + if g.Type == 13 && (d.Columns == 0 || uint64(expected) != d.Columns*d.Columns) { + return fmt.Errorf("eventgames: invalid bingo board dimensions") + } + seen := map[uint64]bool{} + for _, id := range g.Board { + if seen[id] { + return fmt.Errorf("eventgames: duplicate saved board tile") + } + seen[id] = true + found := false + for _, r := range d.Cells { + if r.ID == id && r.Count == rewardCount(d, g.Clear) { + found = true + break + } + } + if !found { + return fmt.Errorf("eventgames: saved tile outside design") + } + } + seen = map[uint64]bool{} + for _, id := range g.Opened { + if seen[id] || g.Type == 13 && id >= uint64(len(g.Board)) || g.Type == 17 && !contains(g.Board, id) { + return fmt.Errorf("eventgames: invalid saved open tile") + } + seen[id] = true + } + lineSeen := map[uint64]bool{} + for _, key := range g.Lines { + if lineSeen[key] { + return fmt.Errorf("eventgames: duplicate saved line") + } + lineSeen[key] = true + found := false + for _, r := range d.Lines { + if g.Type == 13 && r.Count == rewardCount(d, g.Clear) && key == r.LineType*1000+r.LineIndex && completeLine(&g, d.Columns, r.LineType, r.LineIndex) { + found = true + } + } + for _, r := range d.Complete { + if g.Type == 17 && r.Count == g.Clear && r.ID == key { + all := true + for _, id := range r.Members { + all = all && contains(g.Opened, id) + } + found = all + } + } + if !found { + return fmt.Errorf("eventgames: saved completed line invalid") + } + } + return nil +} diff --git a/go/internal/server/eventplay/SPEC.md b/go/internal/server/eventplay/SPEC.md new file mode 100644 index 0000000..6cd30fd --- /dev/null +++ b/go/internal/server/eventplay/SPEC.md @@ -0,0 +1,40 @@ +# Event play runtime policy + +Registry dates and UIDs are provided by server configuration. No calendar is +invented or enabled here. Every mutable operation is saved with its session, +path, sequence and exact request, and retries replay the complete response. +Production runs the economy and snapshot writes in one repository transaction. + +Event stories use installed PackEventStoryTable rows. Seasonal clears and +archive replay clears have separate once-only reward ledgers. Event battles +use mode 17 and the client's custom stage fields 8/9, not monster field 3. +Installed PackEventBattleTable supplies the matching deck, win cost and repeat +rewards. Previous stages must be cleared. Failed/abandoned battle closes the +run without victory cost or rewards. Only server-proven stage clears are +returned by PackEventBattleInfo. BattleEnd reward bundle is field 5 and event +stage info is field 16. + +Client-simulated minigames own a persistent run per session and family. Start +locks schedule and stage; End requires that run and commits its record before +the run closes. Rankings project local records, not official global scores. +Run/Field use FieldMiniGameRewardTable score thresholds; higher daily records +pay per-resource positive differences. Quick reward is only available for a +stored best score not already paid for that UTC day. + +Field scores are computed from installed pack FieldMiniGameObjectTable IDs; +objects may be scored only once per run. Rhythm stage and mode select the +installed maximum score. Hopscotch captured area is range checked. Survival +start validates character/map and supplies the installed starting skill; +play coin/experience is recorded and End cannot exceed committed progress. +Upgrade costs come from FieldMiniGameUpgradeTable and currency 43. Skill +offers come from installed FieldMiniGameSkillGroupTable IDs. Defense stages +pay installed MGDRewardTable wave rewards once per saved record. + +EventHub settings are projected from static PackEventListTable and configured +child schedules. Mini hub slots use their distinct protobuf shape. A registry +with no configured events yields empty Info responses rather than activating +unconfigured content. + +This package intentionally does not turn a plain RandomBox reward into an +immediate opening. Reward type 9 remains inventory; manual box opening belongs +to the shared economy's box-opening operation. diff --git a/go/internal/server/eventplay/battle.go b/go/internal/server/eventplay/battle.go new file mode 100644 index 0000000..454b627 --- /dev/null +++ b/go/internal/server/eventplay/battle.go @@ -0,0 +1,175 @@ +package eventplay + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "bytes" + "encoding/json" + "fmt" + "strings" +) + +func (s *Service) HandlesBattle(mode uint64) bool { return mode == 17 } +func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if num(req, 5) != 17 || receipt == "" { + return nil, fmt.Errorf("eventplay: invalid event battle") + } + group, id, deck := num(req, 8), num(req, 9), num(req, 4) + var row []byte + for _, r := range s.design.Rows("PackEventBattleTable", 4, group) { + if num(r, 5) == id && num(r, 1) == deck { + row = r + break + } + } + if row == nil { + return nil, fmt.Errorf("eventplay: event battle deck/stage mismatch") + } + var uid uint64 + for _, c := range s.registry.List() { + if c.ID == group && (c.Type == 9 || c.Type == 8) && s.now().UnixMilli() >= c.Start && s.now().UnixMilli() < c.End { + uid = c.UID + break + } + } + if uid == 0 { + return nil, fmt.Errorf("eventplay: event battle schedule unavailable") + } + previous := uint64(0) + for _, r := range s.design.Rows("PackEventBattleTable", 4, group) { + stage := num(r, 5) + if stage < id && stage > previous { + previous = stage + } + } + if previous != 0 && !s.state.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, group, previous)] { + return nil, fmt.Errorf("eventplay: previous event battle not cleared") + } + if validator, ok := s.economy.(interface{ CanApply([]gamedata.Reward) error }); ok { + cost := num(row, 3) + if cost > 0 { + if e := validator.CanApply([]gamedata.Reward{{Type: 30, Count: cost}}); e != nil { + return nil, e + } + } + } + payload, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(payload, &next) + next.Runs["battle:"+receipt] = Run{UID: uid, Game: group, Stage: id, Mode: 17, Started: uint64(s.now().UnixMilli()), Family: "Battle", Session: strings.Split(receipt, ":")[0]} + payload, _ = json.Marshal(next) + if e := s.store.Save("eventplay", payload); e != nil { + return nil, e + } + s.state = next + return nil, nil +} +func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := "eventbattle:" + receipt + if r, ok := s.state.Replies[key]; ok { + if !bytes.Equal(r.Request, req) { + return nil, fmt.Errorf("eventplay: changed battle retry") + } + return r.Body, nil + } + a, ok := s.state.Runs["battle:"+receipt] + if !ok { + return nil, fmt.Errorf("eventplay: no entered event battle") + } + var row []byte + for _, r := range s.design.Rows("PackEventBattleTable", 4, a.Game) { + if num(r, 5) == a.Stage { + row = r + break + } + } + if row == nil { + return nil, fmt.Errorf("eventplay: saved battle design missing") + } + raw, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(raw, &next) + var out []byte + if num(req, 2) == 1 { + rs, e := gamedata.EventPlayRewards(row, 9, 10, 8) + if e != nil { + return nil, e + } + definitions := s.battleChallenges[num(row, 1)] + claims, e := gamedata.VerifySubmittedChallenges(req, definitions) + if e != nil { + return nil, e + } + for _, index := range claims { + marker := fmt.Sprintf("challenge:%d:%d:%d:%d", a.UID, a.Game, a.Stage, index) + if !next.Stories[marker] { + rs = append(rs, definitions[index].Reward) + next.Stories[marker] = true + } + } + rewards := []gamedata.Reward{} + for _, r := range rs { + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + } + cost := num(row, 3) + var costs []gamedata.Reward + if cost > 0 { + costs = []gamedata.Reward{{Type: 30, Count: cost}} + } + bundle, e := s.economy.Apply(key, costs, rewards) + if e != nil { + return nil, e + } + out = wire.AppendBytes(out, 5, bundle) + info := wire.AppendVarint(nil, 1, a.UID) + info = wire.AppendVarint(info, 2, a.Game) + info = wire.AppendVarint(info, 3, a.Stage) + for i := range definitions { + if next.Stories[fmt.Sprintf("challenge:%d:%d:%d:%d", a.UID, a.Game, a.Stage, i)] { + info = wire.AppendVarint(info, 4, uint64(i)) + } + } + out = wire.AppendBytes(out, 16, info) + next.Stories[fmt.Sprintf("battle:%d:%d:%d", a.UID, a.Game, a.Stage)] = true + } + delete(next.Runs, "battle:"+receipt) + next.Replies[key] = reply{append([]byte(nil), req...), out} + raw, _ = json.Marshal(next) + if e := s.store.Save("eventplay", raw); e != nil { + return nil, e + } + s.state = next + return out, nil +} + +func (s *Service) AttachBattleChallenges(d gamedata.EventBattleChallenges) { + s.mu.Lock() + defer s.mu.Unlock() + s.battleChallenges = d +} + +func (s *Service) BattleChallengeIndexes(uid, group, stage uint64) []uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.battleChallengeIndexesLocked(uid, group, stage) +} +func (s *Service) battleChallengeIndexesLocked(uid, group, stage uint64) []uint64 { + var deck uint64 + for _, row := range s.design.Rows("PackEventBattleTable", 4, group) { + if num(row, 5) == stage { + deck = num(row, 1) + break + } + } + var out []uint64 + for i := range s.battleChallenges[deck] { + if s.state.Stories[fmt.Sprintf("challenge:%d:%d:%d:%d", uid, group, stage, i)] { + out = append(out, uint64(i)) + } + } + return out +} diff --git a/go/internal/server/eventplay/hub_calendar_test.go b/go/internal/server/eventplay/hub_calendar_test.go new file mode 100644 index 0000000..a616d82 --- /dev/null +++ b/go/internal/server/eventplay/hub_calendar_test.go @@ -0,0 +1,53 @@ +package eventplay + +import ( + "bytes" + "testing" + + "bd2server/internal/server/readonly" + "bd2server/internal/server/wire" +) + +func TestProjectHubCalendarRetainsPlayEndAndMultipleUIDs(t *testing.T) { + s, _ := makeService(t) + scalar := func(n int, v uint64) readonly.Field { + return readonly.Field{Number: n, Type: 0, Varint: v} + } + hub := readonly.Field{Number: 1, Type: 2, Fields: []readonly.Field{ + scalar(1, 74), scalar(2, 57), scalar(3, 100), scalar(4, 200), scalar(5, 300), + {Number: 6, Type: 2, Fields: []readonly.Field{ + scalar(1, 1), scalar(2, 3), scalar(3, 77), scalar(3, 78), + }}, + }} + seed := &readonly.Seed{Responses: map[string]readonly.Response{ + "/EventHubInfo": {Fields: []readonly.Field{hub}}, + "/MiniGameHubInfo": {PacketCode: 390, Fields: []readonly.Field{ + {Number: 1, Type: 2, Fields: []readonly.Field{scalar(1, 4), scalar(2, 9), scalar(3, 0)}}, + }}, + }} + s.AttachHubCalendars(seed) + for _, path := range []string{"/EventHubInfo", "/MiniGameHubInfo"} { + req := wire.AppendVarint(nil, 1, 1) + wantCode, want, _, err := seed.Handle(path, req) + if err != nil { + t.Fatal(err) + } + code, got, handled, err := s.HandleSession(path, req, "session") + if err != nil || !handled || code != wantCode || !bytes.Equal(got, want) { + t.Fatalf("path=%s code=%d handled=%v err=%v body=%x want=%x", path, code, handled, err, got, want) + } + if path == "/EventHubInfo" { + raw, _, _ := wire.Bytes(got, 1) + playEnd, _, _ := wire.Varint(raw, 4) + end, _, _ := wire.Varint(raw, 5) + setting, _, _ := wire.Bytes(raw, 6) + ids, err := list(setting, 3) + if err != nil || playEnd != 200 || end != 300 || len(ids) != 2 || ids[0] != 77 || ids[1] != 78 { + t.Fatalf("hub play_end=%d end=%d refs=%v err=%v", playEnd, end, ids, err) + } + } + } + if len(s.state.Replies) != 0 { + t.Fatal("public calendars persisted as player replies") + } +} diff --git a/go/internal/server/eventplay/play.go b/go/internal/server/eventplay/play.go new file mode 100644 index 0000000..7042d65 --- /dev/null +++ b/go/internal/server/eventplay/play.go @@ -0,0 +1,775 @@ +// Package eventplay owns event stories and client-simulated minigame runs. +package eventplay + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/readonly" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "sort" + "strings" + "sync" + "time" +) + +type Rewards interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) +} +type Run struct { + UID, Game, Stage, Mode, Started, Score, HP uint64 + Family, Session string + Objects, Skills []uint64 + Coin, Exp, Char, MapGroup uint64 + SkillLevels map[uint64]uint64 + MaxHP, Rerolls, Level, SkillCredits uint64 + Offers []uint64 + LevelExp uint64 + Killed, Picked, Available map[uint64]uint64 +} +type record struct { + UID, Game, Stage, Mode, Best, Paid, Date uint64 + Family string + Clears map[string]bool + PendingBundle []byte +} +type reply struct{ Request, Body []byte } +type snapshot struct { + Version int `json:"version"` + Records map[string]record `json:"records"` + Runs map[string]Run `json:"runs"` + Replies map[string]reply `json:"replies"` + Stories map[string]bool `json:"stories"` + Upgrades map[string]uint64 `json:"upgrades"` +} +type Service struct { + battleChallenges gamedata.EventBattleChallenges + mu sync.Mutex + store stateio.Store + registry events.Resolver + economy Rewards + design *gamedata.EventPlayCatalog + state snapshot + now func() time.Time + rooms RoomRuntime + onProgress func(uint64, uint64, uint64) error + hubCalendars *readonly.Seed +} + +func Open(store stateio.Store, root, version string, registry events.Resolver, economy Rewards) (*Service, error) { + if store == nil || registry == nil || economy == nil { + return nil, fmt.Errorf("eventplay: incomplete configuration") + } + d, e := gamedata.LoadEventPlayCatalog(root, version) + if e != nil { + return nil, e + } + s := &Service{store: store, registry: registry, economy: economy, design: d, now: time.Now, state: snapshot{1, map[string]record{}, map[string]Run{}, map[string]reply{}, map[string]bool{}, map[string]uint64{}}} + raw, e := store.Load("eventplay") + if e != nil { + return nil, e + } + if raw != nil { + if e = stateio.RequireExactJSONObject(raw, "version", "records", "runs", "replies", "stories", "upgrades"); e != nil { + return nil, e + } + if e = json.Unmarshal(raw, &s.state); e != nil { + return nil, e + } + if s.state.Version != 1 || s.state.Records == nil || s.state.Runs == nil || s.state.Replies == nil || s.state.Stories == nil || s.state.Upgrades == nil { + return nil, fmt.Errorf("eventplay: incompatible save") + } + } + for _, r := range s.state.Records { + if r.UID == 0 || r.Game == 0 || r.Best > math.MaxInt32 || r.Clears == nil { + return nil, fmt.Errorf("eventplay: invalid saved record") + } + if _, e = s.design.Row("PackEventMiniGameTable", 8, r.Game); e != nil { + return nil, e + } + } + return s, nil +} +func num(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } + +// AttachHubCalendars installs project-maintained hub layouts, including the +// separate play/end windows and slots referencing multiple domain identities. +func (s *Service) AttachHubCalendars(seed *readonly.Seed) { + s.hubCalendars = seed +} +func list(b []byte, n int) ([]uint64, error) { + var out []uint64 + e := wire.Walk(b, func(f wire.Field) error { + if f.Number != n { + return nil + } + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + out = append(out, v) + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + for p := f.Value; len(p) > 0; { + v, k := binary.Uvarint(p) + if k <= 0 { + return wire.ErrMalformed + } + out = append(out, v) + p = p[k:] + } + return nil + }) + return out, e +} +func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { + return s.HandleSession(path, req, "local") +} +func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { + code, ok := codes[path] + if !ok { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + fail := func(e error) (int, []byte, bool, error) { return code, nil, true, e } + seq := num(req, 1) + if seq == 0 || seq > math.MaxInt32 { + return fail(fmt.Errorf("eventplay: invalid sequence")) + } + if e := wire.Walk(req, func(f wire.Field) error { + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + if v > math.MaxInt64 { + return wire.ErrMalformed + } + } + return nil + }); e != nil { + return fail(e) + } + key := fmt.Sprintf("%s:%s:%d", session, path, seq) + if r, ok := s.state.Replies[key]; ok { + if !bytes.Equal(r.Request, req) { + return fail(fmt.Errorf("eventplay: changed request retry")) + } + return code, r.Body, true, nil + } + raw, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(raw, &next) + var out []byte + var e error + switch path { + case "/MiniGameUserRecordInfo", "/MiniGameRanking": + out = summaryWire(&next, path == "/MiniGameUserRecordInfo") + case "/MiniGameRelayServerInfo": + return fail(fmt.Errorf("eventplay: native relay channels are not configured")) + case "/EventHubInfo", "/MiniEventHubInfo", "/MiniGameHubInfo": + if s.hubCalendars != nil && path != "/MiniEventHubInfo" { + return s.hubCalendars.Handle(path, req) + } + out, e = s.hubs(path) + case "/PackEventStoryInfo", "/PackEventBattleInfo": + uids, err := list(req, 2) + if err != nil { + return fail(err) + } + for _, uid := range uids { + c, err := s.registry.Resolve(uid) + if err != nil { + return fail(err) + } + if path == "/PackEventStoryInfo" { + for _, r := range s.design.Rows("PackEventStoryTable", 1, c.ID) { + id := num(r, 2) + if next.Stories[fmt.Sprintf("%d:%d:%d", uid, c.ID, id)] { + v := wire.AppendVarint(nil, 1, uid) + v = wire.AppendVarint(v, 2, c.ID) + v = wire.AppendVarint(v, 3, id) + out = wire.AppendBytes(out, 1, v) + } + } + } else { + for _, r := range s.design.Rows("PackEventBattleTable", 4, c.ID) { + id := num(r, 5) + if !next.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, c.ID, id)] { + continue + } + v := wire.AppendVarint(nil, 1, uid) + v = wire.AppendVarint(v, 2, c.ID) + v = wire.AppendVarint(v, 3, id) + for _, idx := range s.battleChallengeIndexesLocked(uid, c.ID, id) { + v = wire.AppendVarint(v, 4, idx) + } + out = wire.AppendBytes(out, 1, v) + } + } + } + case "/PackEventStoryClear", "/PackEventStoryReplayClear": + uid, group, id := num(req, 2), num(req, 3), num(req, 4) + if path == "/PackEventStoryReplayClear" { + group, id = num(req, 2), num(req, 3) + uid = 0 + } + if uid != 0 { + c, err := s.registry.Resolve(uid) + if err != nil { + return fail(err) + } + if c.ID != group || s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End { + return fail(fmt.Errorf("eventplay: story schedule unavailable")) + } + } + r, err := s.design.Story(group, id) + if err != nil { + return fail(err) + } + storyKey := fmt.Sprintf("%d:%d:%d", uid, group, id) + if !next.Stories[storyKey] { + rs, err := gamedata.EventPlayRewards(r, 11, 10, 9) + if err != nil { + return fail(err) + } + out, e = s.grant("eventplay:story:"+storyKey, rs) + if e != nil { + return fail(e) + } + out = wire.AppendBytes(nil, 1, out) + next.Stories[storyKey] = true + } + if path == "/PackEventStoryClear" { + v := wire.AppendVarint(nil, 1, uid) + v = wire.AppendVarint(v, 2, group) + v = wire.AppendVarint(v, 3, id) + out = wire.AppendBytes(out, 2, v) + } + default: + out, e = s.game(path, req, session, &next, key) + } + if e != nil { + return fail(e) + } + next.Replies[key] = reply{append([]byte(nil), req...), out} + b, e := json.Marshal(next) + if e != nil { + return fail(e) + } + if e = s.store.Save("eventplay", b); e != nil { + return fail(e) + } + s.state = next + return code, out, true, nil +} +func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) { + rs := make([]gamedata.Reward, 0, len(rewards)) + for _, r := range rewards { + rs = append(rs, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + } + return s.economy.Apply(identity, nil, rs) +} +func (s *Service) hubs(path string) ([]byte, error) { + var out []byte + rows := s.registry.List() + sort.Slice(rows, func(i, j int) bool { return rows[i].UID < rows[j].UID }) + for _, c := range rows { + if c.Type != 8 && c.Type != 11 { + continue + } + if path == "/MiniGameHubInfo" { + if c.Type != 11 { + continue + } + v := wire.AppendVarint(nil, 1, c.SubID) + v = wire.AppendVarint(v, 2, c.UID) + v = wire.AppendVarint(v, 3, 1) + out = wire.AppendBytes(out, 1, v) + continue + } + if path == "/MiniEventHubInfo" { + v := wire.AppendVarint(nil, 1, c.UID) + v = wire.AppendVarint(v, 2, c.ID) + v = wire.AppendVarint(v, 3, uint64(c.Start)) + v = wire.AppendVarint(v, 4, uint64(c.End)) + v = wire.AppendVarint(v, 5, uint64(c.End)) + for _, child := range rows { + if child.UID == c.UID || child.SubID != c.ID { + continue + } + slot := wire.AppendVarint(nil, 1, child.SubID) + slot = wire.AppendVarint(slot, 2, child.Type) + slot = wire.AppendVarint(slot, 3, child.ID) + slot = wire.AppendVarint(slot, 4, child.UID) + slot = wire.AppendVarint(slot, 5, uint64(child.Start)) + slot = wire.AppendVarint(slot, 6, uint64(child.End)) + v = wire.AppendBytes(v, 6, slot) + } + out = wire.AppendBytes(out, 1, v) + continue + } + hub, e := s.design.Row("PackEventHubTable", 14, c.ID) + if e != nil { + return nil, e + } + _ = hub + v := wire.AppendVarint(nil, 1, c.UID) + v = wire.AppendVarint(v, 2, c.ID) + v = wire.AppendVarint(v, 3, uint64(c.Start)) + v = wire.AppendVarint(v, 4, uint64(c.End)) + v = wire.AppendVarint(v, 5, uint64(c.End)) + for _, r := range s.design.Rows("PackEventListTable", 6, c.ID) { + typ, id, slot := num(r, 9), num(r, 7), num(r, 11) + for _, child := range rows { + if child.ID != id { + continue + } + setting := wire.AppendVarint(nil, 1, slot) + setting = wire.AppendVarint(setting, 2, typ) + setting = wire.AppendVarint(setting, 3, child.UID) + v = wire.AppendBytes(v, 6, setting) + break + } + } + out = wire.AppendBytes(out, 1, v) + } + return out, nil +} +func recordWire(r record) []byte { + b := wire.AppendVarint(nil, 1, r.UID) + b = wire.AppendVarint(b, 2, r.Paid) + b = wire.AppendVarint(b, 3, r.Best) + if r.Best > 0 && r.Paid < r.Best { + b = wire.AppendVarint(b, 4, 1) + } + return b +} +func family(path string) string { + p := strings.TrimPrefix(path, "/MiniGame") + for _, f := range []string{"Hopscotch", "Survival", "Defense", "Rhythm", "Sichuan", "Action", "Field", "Run"} { + if strings.HasPrefix(p, f) { + return f + } + } + return "" +} +func recKey(uid, stage, mode uint64, f string) string { + return fmt.Sprintf("%d:%s:%d:%d", uid, f, stage, mode) +} +func (s *Service) calendar(uid uint64, f string) (events.Schedule, error) { + if uid == 0 { + for _, c := range s.registry.List() { + if c.Type == 11 { + d, e := s.design.Row("PackEventMiniGameTable", 8, c.ID) + if e != nil { + continue + } + kind := num(d, 3) + expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f] + if f == "Run" && (kind == 2 || kind == 4) { + expected = kind + } + if kind != expected { + continue + } + uid = c.UID + break + } + } + } + c, e := s.registry.Resolve(uid) + if e != nil { + return c, e + } + if c.Type != 11 { + return c, fmt.Errorf("eventplay: minigame schedule mismatch") + } + if _, e = s.design.Row("PackEventMiniGameTable", 8, c.ID); e != nil { + return c, e + } + d, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID) + kind := num(d, 3) + expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f] + if f == "Run" && (kind == 2 || kind == 4) { + expected = kind + } + if kind != expected { + return c, fmt.Errorf("eventplay: configured minigame family mismatch") + } + return c, nil +} +func (s *Service) game(path string, req []byte, session string, next *snapshot, key string) ([]byte, error) { + f := family(path) + if f == "" { + return nil, fmt.Errorf("eventplay: unknown family") + } + start := strings.HasSuffix(path, "Start") + end := strings.HasSuffix(path, "End") + runKey := session + ":" + f + uid := num(req, 2) + if end || path == "/MiniGameFieldScore" || path == "/MiniGameSurvivalPlay" || strings.Contains(path, "Skill") { + if a, ok := next.Runs[runKey]; ok { + uid = a.UID + } else { + return nil, fmt.Errorf("eventplay: no active run") + } + } + c, e := s.calendar(uid, f) + if e != nil { + return nil, e + } + uid = c.UID + stage, mode := num(req, 3), num(req, 4) + if f == "Rhythm" { + mode = num(req, 5) + } + if f == "Survival" { + stage = num(req, 5) + } + if f == "Sichuan" { + stage = num(req, 4) + } + rk := recKey(uid, stage, mode, f) + r := next.Records[rk] + if r.Clears == nil { + r = record{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Family: f, Clears: map[string]bool{}} + } + if path == "/MiniGameSurvivalCharUpgrade" || path == "/MiniGameSurvivalCharUpgradeReset" { + return s.upgrade(path, req, uid, next, key) + } + if path == "/MiniGameDefenseMatching" { + matching := num(req, 3) + if matching > 2 { + return nil, fmt.Errorf("eventplay: unknown matching mode") + } + next.Runs[runKey+":matching"] = Run{UID: uid, Game: c.ID, Family: f, Session: session} + return nil, nil + } + if start { + if s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End { + return nil, fmt.Errorf("eventplay: season closed") + } + if _, active := next.Runs[runKey]; active { + return nil, fmt.Errorf("eventplay: run already active") + } + if e = s.validateStage(f, stage, mode); e != nil { + return nil, e + } + a := Run{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Started: uint64(s.now().UnixMilli()), Family: f, Session: session, HP: 100} + if f == "Action" { + game, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID) + group := num(game, 4) + rows := s.design.Rows("ActionGameStageTable", 3, group) + if len(rows) == 0 { + return nil, fmt.Errorf("eventplay: action stage group missing") + } + a.Stage = num(rows[0], 4) + a.Mode = group + a.Char = num(rows[0], 1) + } + if f == "Action" || f == "Defense" { + if locked, ok := next.Runs[runKey+":native"]; ok { + a.Stage, a.Mode, a.Char = locked.Stage, locked.Mode, locked.Char + a.Session = locked.Session + if s.rooms != nil { + if e = s.rooms.ValidateRoom(session, a.Session, uid); e != nil { + return nil, e + } + } + } + } + var startReply []byte + if f == "Field" { + d, e := s.design.Field(c.ID) + if e != nil { + return nil, e + } + a.HP = d.HP + ids := make([]uint64, 0, len(d.Objects)) + for id := range d.Objects { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + for _, id := range ids { + startReply = wire.AppendVarint(startReply, 1, id) + } + } + if f == "Survival" { + a.Char = num(req, 3) + a.MapGroup = num(req, 4) + char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char) + if e != nil { + return nil, e + } + a.HP = num(char, 20) + a.MaxHP = a.HP + a.Rerolls = num(char, 19) + a.Level = 1 + a.SkillCredits = 0 + a.Killed = map[uint64]uint64{} + a.Picked = map[uint64]uint64{} + a.Available = map[uint64]uint64{} + skillGroup := num(char, 3) + skills := s.design.Rows("FieldMiniGameSkillTable", 7, skillGroup) + if len(skills) == 0 { + return nil, fmt.Errorf("eventplay: character starting skill missing") + } + a.Skills = []uint64{num(skills[0], 8)} + a.SkillLevels = map[uint64]uint64{skillGroup: 1} + found := false + for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { + if num(m, 3) == a.Stage { + found = true + } + } + if !found { + return nil, fmt.Errorf("eventplay: survival map missing") + } + } + next.Runs[runKey] = a + if s.onProgress != nil { + if e = s.onProgress(343, c.ID, 1); e != nil { + return nil, e + } + if e = s.onProgress(315, c.ID, 1); e != nil { + return nil, e + } + } + next.Records[rk] = r + if f == "Survival" { + v := wire.AppendVarint(nil, 1, a.Skills[0]) + v = wire.AppendVarint(v, 2, 1) + out := wire.AppendBytes(nil, 1, v) + return wire.AppendVarint(out, 2, seqSeed(a.Started)), nil + } + return startReply, nil + } + if end { + a := next.Runs[runKey] + if a.UID != uid { + return nil, fmt.Errorf("eventplay: run schedule changed") + } + if f == "Action" && (num(req, 3) != a.Mode || num(req, 4) != a.Stage || num(req, 6) != a.Char) { + return nil, fmt.Errorf("eventplay: action submitted stage/monster differs from configured start") + } + if f == "Action" || f == "Defense" { + guid, _, err := wire.Bytes(req, 2) + if err != nil { + return nil, err + } + if a.Session != session && !bytes.Equal(guid, []byte(a.Session)) { + return nil, fmt.Errorf("eventplay: native room guid mismatch") + } + if s.rooms != nil { + if e = s.rooms.ValidateRoom(session, string(guid), uid); e != nil { + return nil, e + } + } + } + score, e := s.endScore(f, req, a) + if e != nil { + return nil, e + } + if s.onProgress != nil { + switch f { + case "Survival": + for condition, count := range map[uint64]uint64{316: a.Level, 317: score} { + if e = s.onProgress(condition, c.ID, count); e != nil { + return nil, e + } + } + if num(req, 2) == 0 { + if e = s.onProgress(320, a.Stage, 1); e != nil { + return nil, e + } + } + case "Defense": + if e = s.onProgress(322, c.ID, score); e != nil { + return nil, e + } + case "Action": + if e = s.onProgress(323, a.Char, 1); e != nil { + return nil, e + } + for condition, field := range map[uint64]int{326: 9, 327: 10, 329: 12, 330: 13, 333: 14, 334: 15, 335: 16, 336: 17} { + if count := num(req, field); count > 0 { + if e = s.onProgress(condition, a.Char, count); e != nil { + return nil, e + } + } + } + } + } + rk = recKey(uid, a.Stage, a.Mode, f) + r = next.Records[rk] + if r.Clears == nil { + r.Clears = map[string]bool{} + } + if score > r.Best { + r.Best = score + } + var out []byte + point, rewards, e := s.scoreRewards(c.ID, score) + if e != nil { + return nil, e + } + if f == "Defense" { + for _, row := range s.design.Tables["MGDRewardTable"] { + id, wave := num(row, 1), num(row, 6) + ck := fmt.Sprintf("wave:%d", id) + if wave <= score && !r.Clears[ck] { + rr, err := gamedata.EventPlayRewards(row, 5, 3, 2) + if err != nil { + return nil, err + } + b, err := s.grant("eventplay:"+key+":"+ck, rr) + if err != nil { + return nil, err + } + out = wire.AppendBytes(out, 1, b) + out = wire.AppendVarint(out, 2, id) + r.Clears[ck] = true + } + } + } + if f == "Survival" { + if num(req, 5) > a.Coin || num(req, 6) > a.Exp { + return nil, fmt.Errorf("eventplay: end coin/exp exceeds committed play progress") + } + out = wire.AppendVarint(out, 2, a.Coin) + out = wire.AppendVarint(out, 3, a.Exp) + if num(req, 2) == 0 { + r.Clears[fmt.Sprintf("map:%d:%d", a.MapGroup, a.Stage)] = true + for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { + if num(m, 3) != a.Stage { + continue + } + rr, err := gamedata.EventPlayRewards(m, 8, 7, 6) + if err != nil { + return nil, err + } + if len(rr) > 0 { + b, err := s.grant(fmt.Sprintf("eventplay:map:%d:%d:%d", uid, a.MapGroup, a.Stage), rr) + if err != nil { + return nil, err + } + out = wire.AppendBytes(out, 1, b) + } + } + } + if a.Coin > 0 { + b, err := s.grant("eventplay:"+key+":coin", []gamedata.BattleReward{{Type: 43, Count: a.Coin}}) + if err != nil { + return nil, err + } + out = wire.AppendBytes(out, 1, b) + } + } + day := uint64(s.now().UnixMilli()) / 86400000 + if r.Date != day { + r.Paid = 0 + } + if point > r.Paid { + _, previous, e := s.scoreRewards(c.ID, r.Paid) + if e != nil { + return nil, e + } + bundle, e := s.grant("eventplay:"+key, rewardDifference(previous, rewards)) + if e != nil { + return nil, e + } + field := 1 + if f == "Run" { + field = 3 + } else if f == "Field" { + field = 5 + } else if f == "Action" { + field = 3 + } else if f == "Hopscotch" { + field = 4 + } + if f == "Rhythm" { + r.PendingBundle = append(r.PendingBundle, bundle...) + } else { + out = wire.AppendBytes(out, field, bundle) + } + r.Paid = point + r.Date = day + } + switch f { + case "Run": + out = wire.AppendVarint(out, 1, r.Best) + out = wire.AppendVarint(out, 2, r.Paid) + case "Field": + out = wire.AppendVarint(out, 1, uid) + out = wire.AppendVarint(out, 2, score) + out = wire.AppendVarint(out, 3, r.Best) + out = wire.AppendVarint(out, 4, r.Paid) + case "Sichuan": + out = wire.AppendDouble(out, 4, float64(r.Best)) + case "Rhythm": + v := wire.AppendVarint(nil, 1, a.Stage) + v = wire.AppendVarint(v, 2, a.Mode) + v = wire.AppendVarint(v, 3, r.Best) + v = wire.AppendVarint(v, 4, num(req, 2)) + v = wire.AppendVarint(v, 5, num(req, 6)) + out = wire.AppendBytes(out, 1, v) + case "Action": + out = wire.AppendVarint(out, 1, r.Best) + case "Hopscotch": + v := wire.AppendVarint(nil, 1, a.Stage) + if score > 0 { + v = wire.AppendVarint(v, 2, 1) + } + out = wire.AppendBytes(out, 2, v) + } + next.Records[rk] = r + delete(next.Runs, runKey) + if s.rooms != nil && (f == "Action" || f == "Defense") { + if e = s.rooms.CompleteRoom(session, a.Session, score); e != nil { + return nil, e + } + } + return out, nil + } + if strings.Contains(path, "Ranking") || strings.Contains(path, "RecordInfo") { + return rankingWire(next, uid, f, strings.Contains(path, "RecordInfo")), nil + } + if strings.HasSuffix(path, "Info") { + var out []byte + if f == "Rhythm" { + for k, v := range next.Records { + if v.UID == uid && v.Family == f && len(v.PendingBundle) > 0 { + out = wire.AppendBytes(out, 2, v.PendingBundle) + v.PendingBundle = nil + next.Records[k] = v + } + } + } + if f != "Run" && f != "Field" { + if _, exists := next.Records[rk]; !exists { + next.Records[rk] = r + } + return append(out, specializedInfo(next, uid, f)...), nil + } + for _, v := range next.Records { + if v.UID == uid && v.Family == f { + out = wire.AppendBytes(out, 1, recordWire(v)) + } + } + if len(out) == 0 { + next.Records[rk] = r + out = wire.AppendBytes(out, 1, recordWire(r)) + } + return out, nil + } + if strings.Contains(path, "QuickReward") || path == "/MiniGameFieldReward" { + return s.claimScore(path, uid, f, next, key) + } + return s.progress(path, req, runKey, next, key) +} diff --git a/go/internal/server/eventplay/play_test.go b/go/internal/server/eventplay/play_test.go new file mode 100644 index 0000000..499e9e6 --- /dev/null +++ b/go/internal/server/eventplay/play_test.go @@ -0,0 +1,219 @@ +package eventplay + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "encoding/binary" + "testing" + "time" +) + +func nums(b []byte, n int) ([]uint64, error) { return list(b, n) } + +type econ struct{ cost, granted uint64 } + +func (e *econ) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { + for _, x := range c { + e.cost += x.Count + } + for _, x := range r { + e.granted += x.Count + } + return wire.AppendVarint(nil, 1, 99), nil +} +func makeService(t *testing.T) (*Service, *econ) { + t.Helper() + reg := events.NewRegistry() + if e := reg.Replace([]events.Schedule{{UID: 7, Type: 9, ID: 1, Start: 1, End: 9999999}, {UID: 8, Type: 10, ID: 1, Start: 1, End: 9999999}, {UID: 9, Type: 11, ID: 1, Start: 1, End: 9999999}}); e != nil { + t.Fatal(e) + } + e := &econ{} + s := &Service{store: stateio.NewMemory(), registry: reg, economy: e, now: func() time.Time { return time.UnixMilli(100) }, design: &gamedata.EventPlayCatalog{Tables: map[string][][]byte{}}, state: snapshot{1, map[string]record{}, map[string]Run{}, map[string]reply{}, map[string]bool{}, map[string]uint64{}}} + return s, e +} +func TestBattleRewardFieldFiveClearInfoSixteenAndRetry(t *testing.T) { + s, e := makeService(t) + row := wire.AppendVarint(nil, 4, 1) + row = wire.AppendVarint(row, 5, 1) + row = wire.AppendVarint(row, 1, 88) + row = wire.AppendVarint(row, 3, 1) + row = wire.AppendVarint(row, 9, 4) + row = wire.AppendVarint(row, 8, 50) + s.design.Tables["PackEventBattleTable"] = [][]byte{row} + req := wire.AppendVarint(nil, 5, 17) + req = wire.AppendVarint(req, 8, 1) + req = wire.AppendVarint(req, 9, 1) + req = wire.AppendVarint(req, 4, 88) + if _, err := s.EnterBattle(req, "sid:1"); err != nil { + t.Fatal(err) + } + end := wire.AppendVarint(nil, 2, 1) + a, err := s.CompleteBattle(end, "sid:1") + if err != nil { + t.Fatal(err) + } + if _, ok, _ := wire.Bytes(a, 5); !ok { + t.Fatal("reward_bundle absent field5") + } + if _, ok, _ := wire.Bytes(a, 3); ok { + t.Fatal("bundle polluted CharInfo field3") + } + if _, ok, _ := wire.Bytes(a, 16); !ok { + t.Fatal("clear info absent") + } + b, err := s.CompleteBattle(end, "sid:1") + if err != nil || !bytes.Equal(a, b) || e.cost != 1 || e.granted != 50 { + t.Fatalf("retry %v cost%d grant%d", err, e.cost, e.granted) + } +} +func TestStoryClearPersistentOnceAndChangedRetry(t *testing.T) { + s, e := makeService(t) + r := wire.AppendVarint(nil, 1, 1) + r = wire.AppendVarint(r, 2, 1) + r = wire.AppendVarint(r, 11, 4) + r = wire.AppendVarint(r, 9, 10) + s.design.Tables["PackEventStoryTable"] = [][]byte{r} + req := wire.AppendVarint(nil, 1, 1) + req = wire.AppendVarint(req, 2, 8) + req = wire.AppendVarint(req, 3, 1) + req = wire.AppendVarint(req, 4, 1) + if _, _, _, err := s.HandleSession("/PackEventStoryClear", req, "sid"); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.HandleSession("/PackEventStoryClear", req, "sid"); err != nil { + t.Fatal(err) + } + if e.granted != 10 { + t.Fatal("duplicate story award") + } + bad := wire.AppendVarint(req, 9, 1) + if _, _, _, err := s.HandleSession("/PackEventStoryClear", bad, "sid"); err == nil { + t.Fatal("changed retry accepted") + } +} +func TestScoreRewardUpgradeDifference(t *testing.T) { + r := rewardDifference([]gamedata.BattleReward{{Type: 4, Count: 10}}, []gamedata.BattleReward{{Type: 4, Count: 15}}) + if len(r) != 1 || r[0].Count != 5 { + t.Fatal(r) + } +} + +func TestEndScoreWireFieldsAcrossFamilies(t *testing.T) { + s, _ := makeService(t) + for _, tc := range []struct { + family string + field int + value uint64 + }{{"Run", 3, 20}, {"Action", 7, 30}, {"Sichuan", 4, 40}, {"Hopscotch", 2, 50}, {"Defense", 3, 60}} { + got, e := s.endScore(tc.family, wire.AppendVarint(nil, tc.field, tc.value), Run{}) + if e != nil || got != tc.value { + t.Fatalf("%s score=%d err%v", tc.family, got, e) + } + } + if _, e := s.endScore("Hopscotch", wire.AppendVarint(nil, 2, 10001), Run{}); e == nil { + t.Fatal("captured area accepted beyond100percent") + } +} +func TestRhythmEndScoreRejectsStaticScoreAndJudgmentOverflow(t *testing.T) { + s, _ := makeService(t) + row := wire.AppendVarint(nil, 5, 1) + row = wire.AppendVarint(row, 8, 1000) + row = wire.AppendVarint(row, 10, 3) + s.design.Tables["RhythmGameMusicTable"] = [][]byte{row} + if _, e := s.endScore("Rhythm", wire.AppendVarint(nil, 4, 1001), Run{Stage: 1}); e == nil { + t.Fatal("score above staticmax accepted") + } + judgment := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 4) + req := wire.AppendVarint(nil, 4, 500) + req = wire.AppendBytes(req, 3, judgment) + if _, e := s.endScore("Rhythm", req, Run{Stage: 1}); e == nil { + t.Fatal("more judgments than designed notes accepted") + } +} + +func TestEventBattleChallengesPersistAndAwardOnce(t *testing.T) { + s, e := makeService(t) + row := wire.AppendVarint(nil, 4, 1) + row = wire.AppendVarint(row, 5, 1) + row = wire.AppendVarint(row, 1, 88) + row = wire.AppendVarint(row, 3, 1) + row = wire.AppendVarint(row, 9, 4) + row = wire.AppendVarint(row, 8, 5) + s.design.Tables["PackEventBattleTable"] = [][]byte{row} + s.AttachBattleChallenges(gamedata.EventBattleChallenges{88: {{Type: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}, {Type: 2, Value1: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}}}) + start := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 5, 17), 8, 1), 9, 1), 4, 88) + if _, err := s.EnterBattle(start, "sid:first"); err != nil { + t.Fatal(err) + } + end := wire.AppendVarint(nil, 2, 1) + end = wire.AppendVarint(end, 5, 0) + body, err := s.CompleteBattle(end, "sid:first") + if err != nil { + t.Fatal(err) + } + info, _, _ := wire.Bytes(body, 16) + indexes, err := challengeTestIndexes(info) + if err != nil || len(indexes) != 1 || indexes[0] != 0 || e.granted != 30 { + t.Fatalf("challenge field16=%x reward%d err%v", info, e.granted, err) + } + if _, err = s.EnterBattle(start, "sid:second"); err != nil { + t.Fatal(err) + } + end = wire.AppendVarint(end, 5, 1) + body, err = s.CompleteBattle(end, "sid:second") + if err != nil { + t.Fatal(err) + } + info, _, _ = wire.Bytes(body, 16) + indexes, _ = challengeTestIndexes(info) + if len(indexes) != 2 || e.granted != 60 { + t.Fatalf("cumulative indexes %+v grant%d", indexes, e.granted) + } + if _, err = s.EnterBattle(start, "sid:third"); err != nil { + t.Fatal(err) + } + if _, err = s.CompleteBattle(end, "sid:third"); err != nil { + t.Fatal(err) + } + if e.granted != 65 { + t.Fatal("challenge reward paid twice") + } +} +func TestEventBattleChallengeUnknownAndDuplicateRejectedBeforeCost(t *testing.T) { + s, e := makeService(t) + row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 4, 1), 5, 1), 1, 88) + s.design.Tables["PackEventBattleTable"] = [][]byte{row} + s.AttachBattleChallenges(gamedata.EventBattleChallenges{88: {{Type: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}}}) + start := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 5, 17), 8, 1), 9, 1), 4, 88) + s.EnterBattle(start, "sid:x") + bad := wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 5, 1) + if _, err := s.CompleteBattle(bad, "sid:x"); err == nil { + t.Fatal("unknownchallenge") + } + bad = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 5, 0), 5, 0) + if _, err := s.CompleteBattle(bad, "sid:x"); err == nil { + t.Fatal("duplicatechallenge") + } + if e.cost != 0 || e.granted != 0 { + t.Fatal("badchallenge mutated economic state") + } +} + +func challengeTestIndexes(b []byte) ([]uint64, error) { + var out []uint64 + err := wire.Walk(b, func(f wire.Field) error { + if f.Number == 4 { + v, _, e := wire.Varint(wire.AppendVarint(nil, 4, numFieldTest(f)), 4) + if e != nil { + return e + } + out = append(out, v) + } + return nil + }) + return out, err +} +func numFieldTest(f wire.Field) uint64 { v, _ := binary.Uvarint(f.Value); return v } diff --git a/go/internal/server/eventplay/progress.go b/go/internal/server/eventplay/progress.go new file mode 100644 index 0000000..f1b42a2 --- /dev/null +++ b/go/internal/server/eventplay/progress.go @@ -0,0 +1,35 @@ +package eventplay + +import "bd2server/internal/server/events" + +func (s *Service) AttachProgress(fn func(condition, sub, count uint64) error) { s.onProgress = fn } +func (s *Service) AssociatedMissionGroup(c events.Schedule) (uint64, error) { + if c.Type != 11 { + return 0, nil + } + game, e := s.design.Row("PackEventMiniGameTable", 8, c.ID) + if e != nil { + return 0, e + } + switch num(game, 3) { + case 5: + row, e := s.design.Row("FieldMiniGameSurvivalTable", 9, num(game, 4)) + if e != nil { + return 0, e + } + return num(row, 8), nil + case 7: + if len(s.design.Tables["MGDDefaultTable"]) > 0 { + return num(s.design.Tables["MGDDefaultTable"][0], 5), nil + } + case 8: + if len(s.design.Tables["ActionGameDefaultTable"]) > 0 { + return num(s.design.Tables["ActionGameDefaultTable"][0], 8), nil + } + case 12: + if len(s.design.Tables["HopscotchDefaultTable"]) > 0 { + return num(s.design.Tables["HopscotchDefaultTable"][0], 20), nil + } + } + return 0, nil +} diff --git a/go/internal/server/eventplay/projection.go b/go/internal/server/eventplay/projection.go new file mode 100644 index 0000000..c34b430 --- /dev/null +++ b/go/internal/server/eventplay/projection.go @@ -0,0 +1,162 @@ +package eventplay + +import ( + "bd2server/internal/server/wire" + "fmt" + "sort" + "strings" +) + +func specializedInfo(st *snapshot, uid uint64, f string) []byte { + var out []byte + if f == "Action" || f == "Survival" || f == "Defense" { + out = wire.AppendVarint(out, 1, uid) + } + for _, r := range st.Records { + if r.UID != uid || r.Family != f { + continue + } + switch f { + case "Sichuan": + v := wire.AppendVarint(nil, 1, uid) + v = wire.AppendVarint(v, 6, r.Best) + out = wire.AppendBytes(out, 1, v) + case "Rhythm": + v := wire.AppendVarint(nil, 1, r.Stage) + v = wire.AppendVarint(v, 2, r.Mode) + v = wire.AppendVarint(v, 3, r.Best) + out = wire.AppendBytes(out, 1, v) + case "Action": + v := wire.AppendVarint(nil, 1, r.Stage) + v = wire.AppendVarint(v, 2, r.Best) + out = wire.AppendBytes(out, 2, v) + case "Hopscotch": + v := wire.AppendVarint(nil, 1, r.Stage) + if r.Best > 0 { + v = wire.AppendVarint(v, 2, 1) + } + out = wire.AppendBytes(out, 2, v) + case "Survival": + out = wire.AppendVarint(out, 4, r.Best) + for stage, clear := range r.Clears { + if !clear { + continue + } + var group, id uint64 + if _, e := fmt.Sscanf(stage, "map:%d:%d", &group, &id); e == nil { + v := wire.AppendVarint(nil, 1, group) + v = wire.AppendVarint(v, 2, id) + out = wire.AppendBytes(out, 8, v) + } + } + } + } + if f == "Survival" { + prefix := fmt.Sprintf("%d:", uid) + for key, level := range st.Upgrades { + if !strings.HasPrefix(key, prefix) { + continue + } + var id uint64 + _, _ = fmt.Sscanf(key[len(prefix):], "%d", &id) + v := wire.AppendVarint(nil, 1, id) + v = wire.AppendVarint(v, 2, level) + out = wire.AppendBytes(out, 10, v) + } + } + return out +} +func rankingWire(st *snapshot, uid uint64, f string, record bool) []byte { + keys := []string{} + for k, r := range st.Records { + if r.UID == uid && r.Family == f && r.Best > 0 { + keys = append(keys, k) + } + } + sort.Strings(keys) + var out []byte + for _, k := range keys { + r := st.Records[k] + var b []byte + switch f { + case "Survival": + b = wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Best) + b = wire.AppendDouble(b, 2, 100) + } + case "Hopscotch": + b = wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Stage) + b = wire.AppendVarint(b, 2, r.Best) + b = wire.AppendDouble(b, 4, 100) + } + case "Action": + b = wire.AppendVarint(nil, 3, 1) + b = wire.AppendVarint(b, 4, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Stage) + b = wire.AppendVarint(b, 2, r.Best) + b = wire.AppendDouble(b, 3, 100) + } + case "Sichuan": + b = wire.AppendVarint(nil, 7, 1) + b = wire.AppendVarint(b, 8, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Best) + b = wire.AppendDouble(b, 2, 100) + } + case "Rhythm": + b = wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Best) + b = wire.AppendVarint(b, 2, r.Stage) + b = wire.AppendDouble(b, 3, 100) + } + default: + b = wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, r.Best) + } + out = wire.AppendBytes(out, 1, b) + } + return out +} +func (s *Service) claimScore(path string, uid uint64, f string, next *snapshot, key string) ([]byte, error) { + for k, r := range next.Records { + if r.UID != uid || r.Family != f || r.Best == 0 { + continue + } + point, rewards, e := s.scoreRewards(r.Game, r.Best) + if e != nil { + return nil, e + } + day := uint64(s.now().UnixMilli()) / 86400000 + if r.Date != day { + r.Paid = 0 + } + if point <= r.Paid { + return nil, fmt.Errorf("eventplay: daily best reward already paid") + } + _, old, e := s.scoreRewards(r.Game, r.Paid) + if e != nil { + return nil, e + } + bundle, e := s.grant("eventplay:"+key, rewardDifference(old, rewards)) + if e != nil { + return nil, e + } + r.Paid = point + r.Date = day + next.Records[k] = r + if strings.HasSuffix(path, "QuickReward") { + return wire.AppendBytes(nil, 1, bundle), nil + } + out := wire.AppendVarint(nil, 1, point) + return wire.AppendBytes(out, 2, bundle), nil + } + return nil, fmt.Errorf("eventplay: no completed score to reward") +} diff --git a/go/internal/server/eventplay/room.go b/go/internal/server/eventplay/room.go new file mode 100644 index 0000000..b15e8fc --- /dev/null +++ b/go/internal/server/eventplay/room.go @@ -0,0 +1,39 @@ +package eventplay + +import ( + "encoding/json" + "fmt" +) + +// RoomRuntime is implemented by the native matching/relay service. HTTP +// gameplay settles only runs whose native room is authenticated. +type RoomRuntime interface { + ValidateRoom(session, guid string, uid uint64) error + CompleteRoom(session, guid string, score uint64) error +} + +func (s *Service) AttachRooms(r RoomRuntime) { s.rooms = r } + +// LockRoomRun installs the stage selected by native room matching before the +// client's HTTP Start. It does not award rewards or trust HTTP stage changes. +func (s *Service) LockRoomRun(session, family, guid string, uid, group, stage, monster uint64) error { + s.mu.Lock() + defer s.mu.Unlock() + if family != "Defense" && family != "Action" { + return fmt.Errorf("eventplay: invalid room family") + } + c, e := s.calendar(uid, family) + if e != nil { + return e + } + raw, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(raw, &next) + next.Runs[session+":"+family+":native"] = Run{UID: uid, Game: c.ID, Stage: stage, Mode: group, Char: monster, Family: family, Session: guid} + raw, _ = json.Marshal(next) + if e = s.store.Save("eventplay", raw); e != nil { + return e + } + s.state = next + return nil +} diff --git a/go/internal/server/eventplay/routes.go b/go/internal/server/eventplay/routes.go new file mode 100644 index 0000000..14f912c --- /dev/null +++ b/go/internal/server/eventplay/routes.go @@ -0,0 +1,58 @@ +package eventplay + +var codes = map[string]int{ + "/MiniGameUserRecordInfo": 635, + "/MiniGameRanking": 391, + "/MiniGameRelayServerInfo": 0, + "/EventHubInfo": 222, + "/MiniEventHubInfo": 534, + "/MiniGameActionEnd": 423, + "/MiniGameActionInfo": 421, + "/MiniGameActionRanking": 0, + "/MiniGameActionStart": 422, + "/MiniGameActionUserRecordInfo": 638, + "/MiniGameDefenseEnd": 395, + "/MiniGameDefenseInfo": 393, + "/MiniGameDefenseMatching": 0, + "/MiniGameDefenseStart": 394, + "/MiniGameFieldEnd": 250, + "/MiniGameFieldInfo": 247, + "/MiniGameFieldQuickReward": 251, + "/MiniGameFieldReward": 0, + "/MiniGameFieldScore": 249, + "/MiniGameFieldStart": 248, + "/MiniGameHopscotchGameEnd": 544, + "/MiniGameHopscotchGameStart": 543, + "/MiniGameHopscotchInfo": 541, + "/MiniGameHopscotchRankingInfo": 542, + "/MiniGameHopscotchUserRecordInfo": 640, + "/MiniGameHubInfo": 390, + "/MiniGameRhythmInfo": 428, + "/MiniGameRhythmPlayEnd": 430, + "/MiniGameRhythmPlayStart": 429, + "/MiniGameRhythmRanking": 0, + "/MiniGameRhythmUserRecordInfo": 639, + "/MiniGameRunEnd": 330, + "/MiniGameRunInfo": 328, + "/MiniGameRunQuickReward": 331, + "/MiniGameRunStart": 329, + "/MiniGameSichuanEnd": 380, + "/MiniGameSichuanInfo": 378, + "/MiniGameSichuanRanking": 383, + "/MiniGameSichuanStart": 379, + "/MiniGameSichuanUserRecordInfo": 637, + "/MiniGameSurvivalCharUpgrade": 369, + "/MiniGameSurvivalCharUpgradeReset": 389, + "/MiniGameSurvivalEnd": 350, + "/MiniGameSurvivalInfo": 348, + "/MiniGameSurvivalPlay": 364, + "/MiniGameSurvivalRanking": 368, + "/MiniGameSurvivalSkillSelectList": 370, + "/MiniGameSurvivalSkillUp": 365, + "/MiniGameSurvivalStart": 349, + "/MiniGameSurvivalUserRecordInfo": 636, + "/PackEventBattleInfo": 214, + "/PackEventStoryClear": 221, + "/PackEventStoryInfo": 220, + "/PackEventStoryReplayClear": 0, +} diff --git a/go/internal/server/eventplay/runtime.go b/go/internal/server/eventplay/runtime.go new file mode 100644 index 0000000..2ed3f11 --- /dev/null +++ b/go/internal/server/eventplay/runtime.go @@ -0,0 +1,237 @@ +package eventplay + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "fmt" + "math" + "strings" +) + +func (s *Service) validateStage(f string, id, mode uint64) error { + switch f { + case "Rhythm": + r, e := s.design.Row("RhythmGameMusicTable", 5, id) + if e != nil { + return e + } + if mode > 2 { + return fmt.Errorf("eventplay: unknown rhythm mode") + } + _ = r + case "Hopscotch": + _, e := s.design.Row("HopscotchStageTable", 6, id) + return e + case "Sichuan": + _, e := s.design.Row("SichuanStageTable", 2, id) + return e + case "Survival": + if len(s.design.Tables["FieldMiniGameSurvivalTable"]) == 0 { + return fmt.Errorf("eventplay: survival design missing") + } + case "Action": + if len(s.design.Tables["ActionGameDefaultTable"]) == 0 { + return fmt.Errorf("eventplay: action design missing") + } + } + return nil +} +func (s *Service) endScore(f string, req []byte, a Run) (uint64, error) { + var score uint64 + switch f { + case "Run": + score = num(req, 3) + case "Field": + score = a.Score + case "Action": + score = num(req, 7) + case "Rhythm": + score = num(req, 4) + r, e := s.design.Row("RhythmGameMusicTable", 5, a.Stage) + if e != nil { + return 0, e + } + maxField := 8 + if a.Mode == 1 { + maxField = 2 + } else if a.Mode == 2 { + maxField = 1 + } + if score > num(r, maxField) { + return 0, fmt.Errorf("eventplay: rhythm score exceeds static maximum") + } + judgments := uint64(0) + err := wire.Walk(req, func(f wire.Field) error { + if f.Number == 3 { + if f.Type != 2 { + return wire.ErrMalformed + } + typ, count := num(f.Value, 1), num(f.Value, 2) + if typ > 10 || count > math.MaxInt32 { + return fmt.Errorf("eventplay: invalid note judgment") + } + judgments += count + } + return nil + }) + if err != nil { + return 0, err + } + noteField := 10 + if a.Mode > 0 { + noteField = 4 + } + if judgments > num(r, noteField) { + return 0, fmt.Errorf("eventplay: judgment count exceeds designed notes") + } + case "Sichuan": + score = num(req, 4) + case "Hopscotch": + score = num(req, 2) + if score > 10000 { + return 0, fmt.Errorf("eventplay: captured area exceeds 100 percent") + } + case "Survival": + score = num(req, 3) + d := s.design.Tables["FieldMiniGameSurvivalTable"][0] + if score > num(d, 24) { + return 0, fmt.Errorf("eventplay: survival time exceeds design") + } + case "Defense": + score = num(req, 3) + } + if score > math.MaxInt32 { + return 0, fmt.Errorf("eventplay: submitted score exceeds protocol range") + } + return score, nil +} +func (s *Service) scoreRewards(game, score uint64) (uint64, []gamedata.BattleReward, error) { + d, e := s.design.Row("PackEventMiniGameTable", 8, game) + if e != nil { + return 0, nil, e + } + return s.design.ScoreRewards(num(d, 10), score) +} +func (s *Service) progress(path string, req []byte, rk string, next *snapshot, key string) ([]byte, error) { + a, ok := next.Runs[rk] + if !ok { + return nil, fmt.Errorf("eventplay: no active run") + } + var out []byte + switch path { + case "/MiniGameFieldScore": + d, e := s.design.Field(a.Game) + if e != nil { + return nil, e + } + ids, e := list(req, 3) + if e != nil || len(ids) == 0 { + return nil, fmt.Errorf("eventplay: missing field objects") + } + for _, id := range ids { + v, ok := d.Objects[id] + if !ok || has(a.Objects, id) { + return nil, fmt.Errorf("eventplay: object invalid or already scored") + } + a.Objects = append(a.Objects, id) + if v.Type == 1 { + a.Score += v.Point + } else if v.Type == 2 { + a.HP += v.Point + } + } + out = wire.AppendVarint(nil, 1, a.Score) + out = wire.AppendVarint(out, 2, a.HP) + case "/MiniGameSurvivalPlay": + exp, coin, e := s.survivalProgress(req, &a) + if e != nil { + return nil, e + } + if s.onProgress != nil { + total := uint64(0) + for id, count := range a.Killed { + previous := next.Runs[rk].Killed[id] + if count > previous { + total += count - previous + } + } + if total > 0 { + if e = s.onProgress(319, a.Game, total); e != nil { + return nil, e + } + } + } + out = wire.AppendVarint(nil, 1, coin) + out = wire.AppendVarint(out, 2, exp) + case "/MiniGameSurvivalSkillSelectList": + var e error + out, e = s.survivalOffers(&a) + if e != nil { + return nil, e + } + case "/MiniGameSurvivalSkillUp": + id := num(req, 2) + if a.SkillCredits == 0 || !has(a.Offers, id) { + return nil, fmt.Errorf("eventplay: skill not earned or offered") + } + group, err := s.design.Row("FieldMiniGameSkillGroupTable", 1, id) + if err != nil { + return nil, fmt.Errorf("eventplay: invalid skill") + } + if item := num(req, 3); item != 0 { + if _, e := s.design.Row("FieldMiniGameSurvivalItemTable", 1, item); e != nil { + return nil, e + } + } + if a.SkillLevels == nil { + a.SkillLevels = map[uint64]uint64{} + } + level := a.SkillLevels[id] + 1 + if level > num(group, 2) { + return nil, fmt.Errorf("eventplay: survival skill at maximum") + } + capacity := uint64(0) + for _, r := range s.design.Tables["FieldMiniGameSurvivalTable"] { + if num(r, 21) > capacity { + capacity = num(r, 21) + } + } + if !has(a.Skills, id) && uint64(len(a.SkillLevels)) >= capacity { + return nil, fmt.Errorf("eventplay: survival skill capacity reached") + } + a.SkillLevels[id] = level + a.SkillCredits-- + a.Offers = nil + if level == num(group, 2) && s.onProgress != nil { + if e := s.onProgress(318, a.Game, 1); e != nil { + return nil, e + } + if e := s.onProgress(321, id, 1); e != nil { + return nil, e + } + } + if !has(a.Skills, id) { + a.Skills = append(a.Skills, id) + } + v := wire.AppendVarint(nil, 1, id) + v = wire.AppendVarint(v, 2, level) + out = wire.AppendBytes(nil, 1, v) + default: + if strings.Contains(path, "QuickReward") || strings.Contains(path, "Reward") { + return nil, fmt.Errorf("eventplay: reward requires a completed unpaid score record") + } + return nil, fmt.Errorf("eventplay: unsupported run transition") + } + next.Runs[rk] = a + _ = key + return out, nil +} +func has(a []uint64, id uint64) bool { + for _, x := range a { + if x == id { + return true + } + } + return false +} +func seqSeed(n uint64) uint64 { return n & 2147483647 } diff --git a/go/internal/server/eventplay/summary.go b/go/internal/server/eventplay/summary.go new file mode 100644 index 0000000..c24ace9 --- /dev/null +++ b/go/internal/server/eventplay/summary.go @@ -0,0 +1,64 @@ +package eventplay + +import ( + "bd2server/internal/server/wire" + "sort" +) + +func summaryWire(st *snapshot, record bool) []byte { + keys := []string{} + for k, v := range st.Records { + if v.Best > 0 { + keys = append(keys, k) + } + } + sort.Strings(keys) + var out []byte + for _, k := range keys { + v := st.Records[k] + field := map[string]int{"Survival": 1, "Sichuan": 2, "Action": 3, "Rhythm": 5, "Hopscotch": 6}[v.Family] + if field == 0 { + continue + } + var b []byte + switch v.Family { + case "Survival": + b = wire.AppendVarint(nil, 2, v.Best) + case "Sichuan": + b = wire.AppendDouble(nil, 2, float64(v.Best)) + case "Action": + b = wire.AppendVarint(nil, 2, v.Best) + b = wire.AppendVarint(b, 3, v.Stage) + case "Rhythm": + b = wire.AppendVarint(nil, 2, v.Best) + b = wire.AppendVarint(b, 3, v.Stage) + case "Hopscotch": + b = wire.AppendVarint(nil, 1, v.Stage) + b = wire.AppendVarint(b, 3, v.Best) + } + if record { + switch v.Family { + case "Survival": + b = wire.AppendVarint(nil, 1, v.Best) + b = wire.AppendDouble(b, 2, 100) + case "Sichuan": + b = wire.AppendDouble(nil, 1, float64(v.Best)) + b = wire.AppendDouble(b, 2, 100) + case "Action": + b = wire.AppendVarint(nil, 1, v.Stage) + b = wire.AppendVarint(b, 2, v.Best) + b = wire.AppendDouble(b, 3, 100) + case "Rhythm": + b = wire.AppendVarint(nil, 1, v.Best) + b = wire.AppendVarint(b, 2, v.Stage) + b = wire.AppendDouble(b, 3, 100) + case "Hopscotch": + b = wire.AppendVarint(nil, 1, v.Stage) + b = wire.AppendVarint(b, 2, v.Best) + b = wire.AppendDouble(b, 4, 100) + } + } + out = wire.AppendBytes(out, field, b) + } + return out +} diff --git a/go/internal/server/eventplay/survival.go b/go/internal/server/eventplay/survival.go new file mode 100644 index 0000000..8702775 --- /dev/null +++ b/go/internal/server/eventplay/survival.go @@ -0,0 +1,219 @@ +package eventplay + +import ( + "bd2server/internal/server/wire" + "encoding/binary" + "fmt" + "math" + "sort" +) + +func (s *Service) survivalProgress(req []byte, a *Run) (uint64, uint64, error) { + char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char) + if e != nil { + return 0, 0, e + } + var mapRow []byte + for _, r := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { + if num(r, 3) == a.Stage { + mapRow = r + } + } + if mapRow == nil { + return 0, 0, fmt.Errorf("eventplay: survival map missing") + } + monsters := s.design.Rows("FieldMiniGameMonsterTable", 2, num(mapRow, 4)) + if a.Killed == nil { + a.Killed = map[uint64]uint64{} + } + if a.Picked == nil { + a.Picked = map[uint64]uint64{} + } + if a.Available == nil { + a.Available = map[uint64]uint64{} + } + elapsed := uint64(0) + if uint64(s.now().UnixMilli()) > a.Started { + elapsed = (uint64(s.now().UnixMilli()) - a.Started) / 1000 + } + e = wire.Walk(req, func(f wire.Field) error { + if f.Number != 6 { + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + id, count := num(f.Value, 1), num(f.Value, 2) + if count == 0 { + return fmt.Errorf("eventplay: empty kill count") + } + var row []byte + for _, r := range monsters { + if num(r, 5) == id { + row = r + break + } + } + if row == nil { + return fmt.Errorf("eventplay: monster outside survival map") + } + max := num(row, 12) + repeats := num(row, 11) + start := num(row, 16) + interval := num(row, 13) + if elapsed < start { + return fmt.Errorf("eventplay: monster not spawned yet") + } + waves := uint64(1) + if interval > 0 { + waves += (elapsed - start) / interval + } + if repeats > 0 && waves > repeats { + waves = repeats + } + if max == 0 || a.Killed[id] > max*waves || count > max*waves-a.Killed[id] { + return fmt.Errorf("eventplay: kill count exceeds designed spawns") + } + a.Killed[id] += count + box := num(row, 6) + if box != 0 { + items, err := s.boxItems(box) + if err != nil { + return err + } + for _, item := range items { + a.Available[item] += count + } + } + return nil + }) + if e != nil { + return 0, 0, e + } + exp, coin := uint64(0), uint64(0) + healing := uint64(0) + e = wire.Walk(req, func(f wire.Field) error { + if f.Number != 5 { + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + id, count := num(f.Value, 1), num(f.Value, 2) + if count == 0 || count > a.Available[id] { + return fmt.Errorf("eventplay: item count exceeds committed drops") + } + row, e := s.design.Row("FieldMiniGameSurvivalItemTable", 1, id) + if e != nil { + return e + } + v := num(row, 4) + if count > math.MaxInt32 || v > math.MaxInt32/count { + return fmt.Errorf("eventplay: item value overflow") + } + a.Available[id] -= count + a.Picked[id] += count + switch num(row, 3) { + case 0: + healing += v * count + case 3: + exp += uint64(math.Floor(float64(v*count) * doubleField(char, 8, 1))) + case 4: + coin += uint64(math.Floor(float64(v*count) * doubleField(char, 10, 1))) + case 5: + a.SkillCredits += count + } + return nil + }) + if e != nil { + return 0, 0, e + } + boxes, e := list(req, 7) + if e != nil { + return 0, 0, e + } + for _, id := range boxes { + if _, e := s.design.Row("FieldMiniGameSurvivalBoxTable", 2, id); e != nil { + return 0, 0, e + } + } + hp := num(req, 2) + if hp > a.MaxHP || hp > a.HP+healing { + return 0, 0, fmt.Errorf("eventplay: invalid survival HP") + } + a.HP = hp + a.Exp += exp + a.Coin += coin + a.LevelExp += exp + if a.Exp > math.MaxInt32 || a.Coin > math.MaxInt32 { + return 0, 0, fmt.Errorf("eventplay: survival progress exceeds protocol range") + } + growth := num(char, 2) + for { + var current, next []byte + for _, r := range s.design.Rows("FieldMiniGameCharLevelTable", 1, growth) { + if num(r, 2) == a.Level { + current = r + } + if num(r, 2) == a.Level+1 { + next = r + } + } + if current == nil || next == nil || num(current, 3) == 0 || a.LevelExp < num(current, 3) { + break + } + a.LevelExp -= num(current, 3) + a.Level++ + a.SkillCredits++ + } + return exp, coin, nil +} +func doubleField(b []byte, n int, def float64) float64 { + v := def + _ = wire.Walk(b, func(f wire.Field) error { + if f.Number == n && f.Type == 1 { + v = math.Float64frombits(binary.LittleEndian.Uint64(f.Value)) + } + return nil + }) + return v +} +func (s *Service) boxItems(id uint64) ([]uint64, error) { + r, e := s.design.Row("FieldMiniGameSurvivalBoxTable", 2, id) + if e != nil { + return nil, e + } + values, e := list(r, 3) + if e != nil { + return nil, e + } + return values, nil +} +func (s *Service) survivalOffers(a *Run) ([]byte, error) { + if a.SkillCredits == 0 { + return nil, fmt.Errorf("eventplay: no earned skill selection") + } + if len(a.Offers) > 0 { + if a.Rerolls == 0 { + return nil, fmt.Errorf("eventplay: no remaining rerolls") + } + a.Rerolls-- + } + a.Offers = nil + rows := append([][]byte(nil), s.design.Tables["FieldMiniGameSkillGroupTable"]...) + sort.Slice(rows, func(i, j int) bool { return num(rows[i], 1) < num(rows[j], 1) }) + for _, r := range rows { + id := num(r, 1) + if a.SkillLevels[id] < num(r, 2) { + a.Offers = append(a.Offers, id) + if len(a.Offers) == 3 { + break + } + } + } + out := wire.AppendVarint(nil, 1, a.Rerolls) + for _, id := range a.Offers { + out = wire.AppendVarint(out, 2, id) + } + return out, nil +} diff --git a/go/internal/server/eventplay/survival_test.go b/go/internal/server/eventplay/survival_test.go new file mode 100644 index 0000000..275aaf4 --- /dev/null +++ b/go/internal/server/eventplay/survival_test.go @@ -0,0 +1,60 @@ +package eventplay + +import ( + "bd2server/internal/server/wire" + "testing" +) + +func row(fields map[int]uint64) []byte { + var b []byte + for n, v := range fields { + b = wire.AppendVarint(b, n, v) + } + return b +} +func TestSurvivalProgressDerivesLootAndRejectsUnspawnedKills(t *testing.T) { + s, _ := makeService(t) + s.design.Tables["FieldMiniGameCharTable"] = [][]byte{row(map[int]uint64{13: 1, 2: 1})} + s.design.Tables["FieldMiniGameMapTable"] = [][]byte{row(map[int]uint64{2: 1, 3: 1, 4: 1})} + s.design.Tables["FieldMiniGameMonsterTable"] = [][]byte{row(map[int]uint64{2: 1, 5: 1, 12: 1, 6: 1})} + box := row(map[int]uint64{2: 1}) + box = wire.AppendVarint(box, 3, 3) + s.design.Tables["FieldMiniGameSurvivalBoxTable"] = [][]byte{box} + s.design.Tables["FieldMiniGameSurvivalItemTable"] = [][]byte{row(map[int]uint64{1: 3, 3: 3, 4: 10})} + s.design.Tables["FieldMiniGameCharLevelTable"] = [][]byte{row(map[int]uint64{1: 1, 2: 1, 3: 10}), row(map[int]uint64{1: 1, 2: 2, 3: 20})} + a := Run{Char: 1, MapGroup: 1, Stage: 1, HP: 10, MaxHP: 10, Started: 1, Killed: map[uint64]uint64{}, Available: map[uint64]uint64{}, Picked: map[uint64]uint64{}, SkillLevels: map[uint64]uint64{}, Level: 1} + req := wire.AppendVarint(nil, 2, 10) + kill := row(map[int]uint64{1: 1, 2: 1}) + req = wire.AppendBytes(req, 6, kill) + item := row(map[int]uint64{1: 3, 2: 1}) + req = wire.AppendBytes(req, 5, item) + req = wire.AppendVarint(req, 3, 999) + exp, coin, e := s.survivalProgress(req, &a) + if e != nil || exp != 10 || coin != 0 || a.SkillCredits != 1 { + t.Fatalf("exp%d coin%d credit%d err%v", exp, coin, a.SkillCredits, e) + } + if _, _, e = s.survivalProgress(req, &a); e == nil { + t.Fatal("kill exceeded spawn accepted") + } +} +func TestSurvivalSkillsRequireEarnedOfferAndRerolls(t *testing.T) { + s, _ := makeService(t) + s.design.Tables["FieldMiniGameSkillGroupTable"] = [][]byte{row(map[int]uint64{1: 1, 2: 3}), row(map[int]uint64{1: 2, 2: 3})} + a := Run{SkillCredits: 1, Rerolls: 1, SkillLevels: map[uint64]uint64{}} + if _, e := s.survivalOffers(&a); e != nil { + t.Fatal(e) + } + if len(a.Offers) != 2 { + t.Fatal("offer missing") + } + if _, e := s.survivalOffers(&a); e != nil || a.Rerolls != 0 { + t.Fatal("reroll not consumed") + } + if _, e := s.survivalOffers(&a); e == nil { + t.Fatal("infinite reroll") + } + a.SkillCredits = 0 + if _, e := s.survivalOffers(&a); e == nil { + t.Fatal("unearned skill selection") + } +} diff --git a/go/internal/server/eventplay/upgrade.go b/go/internal/server/eventplay/upgrade.go new file mode 100644 index 0000000..c2bc17b --- /dev/null +++ b/go/internal/server/eventplay/upgrade.go @@ -0,0 +1,66 @@ +package eventplay + +import ( + "bd2server/internal/server/gamedata" + "fmt" + "strconv" +) + +func (s *Service) upgrade(path string, req []byte, uid uint64, next *snapshot, key string) ([]byte, error) { + prefix := fmt.Sprintf("%d:", uid) + var costs, rewards []gamedata.Reward + if path == "/MiniGameSurvivalCharUpgrade" { + id, target := num(req, 2), num(req, 3) + k := prefix + strconv.FormatUint(id, 10) + if target == 0 || target != next.Upgrades[k]+1 { + return nil, fmt.Errorf("eventplay: upgrade level is not next") + } + var row []byte + for _, r := range s.design.Rows("FieldMiniGameUpgradeTable", 1, id) { + if num(r, 2) == target { + row = r + break + } + } + if row == nil { + return nil, fmt.Errorf("eventplay: upgrade design missing") + } + costs = []gamedata.Reward{{Type: 43, Count: num(row, 3)}} + if _, e := s.economy.Apply("eventplay:"+key, costs, nil); e != nil { + return nil, e + } + next.Upgrades[k] = target + return nil, nil + } + for _, r := range s.design.Tables["FieldMiniGameUpgradeTable"] { + id, level := num(r, 1), num(r, 2) + if next.Upgrades[prefix+strconv.FormatUint(id, 10)] >= level { + rewards = append(rewards, gamedata.Reward{Type: 43, Count: num(r, 3)}) + } + } + bundle, e := s.economy.Apply("eventplay:"+key, nil, rewards) + if e != nil { + return nil, e + } + for k := range next.Upgrades { + if len(k) >= len(prefix) && k[:len(prefix)] == prefix { + delete(next.Upgrades, k) + } + } + return bundle, nil +} +func rewardDifference(old, next []gamedata.BattleReward) []gamedata.BattleReward { + paid := map[[2]uint64]uint64{} + for _, r := range old { + paid[[2]uint64{r.Type, r.ID}] += r.Count + } + var out []gamedata.BattleReward + for _, r := range next { + n := paid[[2]uint64{r.Type, r.ID}] + if r.Count > n { + r.Count -= n + out = append(out, r) + } + } + return out +} diff --git a/go/internal/server/events/ap_refresh.go b/go/internal/server/events/ap_refresh.go new file mode 100644 index 0000000..074fc32 --- /dev/null +++ b/go/internal/server/events/ap_refresh.go @@ -0,0 +1,164 @@ +package events + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "encoding/json" + "fmt" + "math" + "time" +) + +type economyAPClock struct { + Next int64 `json:"next"` +} + +func (e *Economy) AttachAPRefresh(caps map[uint64]uint64, design gamedata.HuntingAPDesign) error { + e.mu.Lock() + defer e.mu.Unlock() + for typ, max := range caps { + if (typ != 30 && typ != 32) || max == 0 || max > math.MaxInt32 { + return fmt.Errorf("events: invalid free AP refresh") + } + } + e.apCaps = caps + e.resetSeconds = design.ResetSeconds + e.now = time.Now + return nil +} +func (e *Economy) refreshAP(s *economySnapshot) error { + if len(e.apCaps) == 0 { + return nil + } + now := e.now().UnixMilli() + offset := (e.resetSeconds - 9*3600) * 1000 + next := ((now-offset)/86400000+1)*86400000 + offset + b, err := e.store.Load("eventapclock") + if err != nil { + return err + } + clock := economyAPClock{Next: next} + reset := b == nil + if b != nil { + if err = stateio.RequireExactJSONObject(b, "next"); err != nil { + return err + } + if err = json.Unmarshal(b, &clock); err != nil { + return err + } + reset = now >= clock.Next + } + if !reset { + return nil + } + for typ, max := range e.apCaps { + s.Balances[typ] = max + } + clock.Next = next + raw, err := json.Marshal(clock) + if err != nil { + return err + } + if err = e.store.Save("eventapclock", raw); err != nil { + return err + } + raw, err = json.Marshal(s) + if err != nil { + return err + } + return e.store.Save("event_economy", raw) +} +func (e *Economy) CanApply(costs []gamedata.Reward) error { + e.mu.Lock() + defer e.mu.Unlock() + s, err := e.load() + if err != nil { + return err + } + if err = e.refreshAP(&s); err != nil { + return err + } + var wallet, ap []gamedata.Reward + var items []player.Item + totals := map[[2]uint64]uint64{} + for _, r := range costs { + k := [2]uint64{r.Type, r.ID} + if r.Count == 0 || r.Count > math.MaxInt32 || totals[k] > math.MaxInt32-r.Count { + return fmt.Errorf("events: invalid cost") + } + totals[k] += r.Count + } + for k, n := range totals { + r := gamedata.Reward{Type: k[0], ID: k[1], Count: n} + switch { + case walletCurrency(r.Type): + wallet = append(wallet, r) + case apCurrency(r.Type): + ap = append(ap, r) + case extraCurrency(r.Type): + if r.ID != 0 || s.Balances[r.Type] < n { + return fmt.Errorf("events: insufficient currency") + } + case inventoryType(r.Type): + selected, err := e.items.SelectMutable(r.Type, r.ID, n) + if err != nil { + return err + } + items = append(items, selected...) + default: + return fmt.Errorf("events: unsupported cost type %d", r.Type) + } + } + if err = e.wallet.CanExchange(wallet, nil); err != nil { + return err + } + if len(ap) > 0 { + if e.hunting == nil { + return fmt.Errorf("events: AP unavailable") + } + if err = e.hunting.CanExchangeAP(ap, nil); err != nil { + return err + } + } + if len(items) > 0 { + return e.items.CanConsume(items) + } + return nil +} + +func (e *Economy) ChargeInfo() ([]byte, error) { + e.mu.Lock() + defer e.mu.Unlock() + s, err := e.load() + if err != nil { + return nil, err + } + if err = e.refreshAP(&s); err != nil { + return nil, err + } + raw, err := e.store.Load("eventapclock") + if err != nil { + return nil, err + } + if raw == nil { + return nil, nil + } + var clock economyAPClock + if err = json.Unmarshal(raw, &clock); err != nil { + return nil, err + } + var out []byte + for _, typ := range []uint64{30, 32} { + if _, ok := e.apCaps[typ]; !ok { + continue + } + info := wire.AppendVarint(nil, 1, uint64(clock.Next-86400000)) + item := wire.AppendVarint(nil, 3, typ) + item = wire.AppendVarint(item, 4, s.Balances[typ]) + info = wire.AppendBytes(info, 2, item) + out = wire.AppendBytes(out, 1, info) + } + return out, nil +} diff --git a/go/internal/server/events/boxes.go b/go/internal/server/events/boxes.go new file mode 100644 index 0000000..d522c4d --- /dev/null +++ b/go/internal/server/events/boxes.go @@ -0,0 +1,86 @@ +package events + +import ( + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "encoding/json" + "fmt" + "math" + "strconv" + "sync" +) + +type BoxService struct { + mu sync.Mutex + items *player.Inventory + economy *Economy + store stateio.Store +} +type boxReceipt struct{ Request, Response []byte } + +func OpenBoxes(store stateio.Store, items *player.Inventory, economy *Economy) (*BoxService, error) { + if store == nil || items == nil || economy == nil { + return nil, fmt.Errorf("events: invalid box runtime") + } + return &BoxService{store: store, items: items, economy: economy}, nil +} +func (s *BoxService) Handle(path string, req []byte) (int, []byte, bool, error) { + return s.HandleSession(path, req, "local") +} +func (s *BoxService) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { + if path != "/UseRandomBox" { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + seq, _, e := wire.Varint(req, 1) + index, _, e2 := wire.Varint(req, 2) + count, _, e3 := wire.Varint(req, 3) + if e != nil || e2 != nil || e3 != nil || seq == 0 || index == 0 || count == 0 || count > 1000 || seq > math.MaxInt32 || index > math.MaxInt64 { + return 143, nil, true, fmt.Errorf("events: invalid box request") + } + key := session + ":" + strconv.FormatUint(seq, 10) + receipts := map[string]boxReceipt{} + raw, e := s.store.Load("eventboxes") + if e != nil { + return 143, nil, true, e + } + if raw != nil { + if e = json.Unmarshal(raw, &receipts); e != nil { + return 143, nil, true, e + } + } + if r, ok := receipts[key]; ok { + if !bytes.Equal(req, r.Request) { + return 143, nil, true, fmt.Errorf("events: box sequence conflict") + } + return 143, r.Response, true, nil + } + var box player.Item + for _, item := range s.items.All() { + if item.InvenIndex == index { + box = item + break + } + } + if box.Type != 9 || box.Count < count { + return 143, nil, true, fmt.Errorf("events: box unavailable") + } + box.Count = count + bundle, e := s.economy.OpenBox("usebox:"+key, box) + if e != nil { + return 143, nil, true, e + } + out := wire.AppendBytes(nil, 1, bundle) + receipts[key] = boxReceipt{append([]byte(nil), req...), out} + raw, e = json.Marshal(receipts) + if e != nil { + return 143, nil, true, e + } + if e = s.store.Save("eventboxes", raw); e != nil { + return 143, nil, true, e + } + return 143, out, true, nil +} diff --git a/go/internal/server/events/economy.go b/go/internal/server/events/economy.go new file mode 100644 index 0000000..bd21f30 --- /dev/null +++ b/go/internal/server/events/economy.go @@ -0,0 +1,435 @@ +package events + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "sync" + "time" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type HuntingAP interface { + CanExchangeAP(costs, rewards []gamedata.Reward) error + ExchangeAPOnce(identity string, costs, rewards []gamedata.Reward) error +} + +type rewardResolver interface { + Resolve([]gamedata.BattleReward) ([]gamedata.BattleReward, error) +} +type equipmentRoller interface { + RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) +} + +type economyReceipt struct{ Digest, Definition, Bundle []byte } +type economySnapshot struct { + Balances map[uint64]uint64 `json:"balances"` + Receipts map[string]economyReceipt `json:"receipts"` +} + +// Economy dispatches verified static rewards to their owning domains. It is +// called inside the same account transaction as the gameplay operation. +type Economy struct { + ownedDesign map[uint64]map[uint64]bool + mu sync.Mutex + store stateio.Store + items *player.Inventory + wallet *player.Wallet + collection *player.CollectionStore + equipment *player.EquipmentInventory + costumes player.CostumeDesignSource + options equipmentRoller + graph rewardResolver + hunting HuntingAP + prestige map[uint64]uint64 + apCaps map[uint64]uint64 + resetSeconds int64 + now func() time.Time + initial map[uint64]uint64 +} + +func NewEconomy(store stateio.Store, items *player.Inventory, wallet *player.Wallet, collection *player.CollectionStore, equipment *player.EquipmentInventory, costumes player.CostumeDesignSource, options equipmentRoller, graph rewardResolver, initial map[uint64]uint64) (*Economy, error) { + if store == nil || items == nil || wallet == nil || collection == nil || equipment == nil || costumes == nil || options == nil || graph == nil { + return nil, fmt.Errorf("events: invalid economy configuration") + } + e := &Economy{store: store, items: items, wallet: wallet, collection: collection, equipment: equipment, costumes: costumes, options: options, graph: graph, initial: map[uint64]uint64{}} + for t, n := range initial { + if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 { + return nil, fmt.Errorf("events: invalid initial currency %d", t) + } + e.initial[t] = n + } + _, err := e.load() + return e, err +} + +// AdditionalCurrencyFields maps EElementType to its UserDBInfo field. These +// balances use a gameplay entry, leaving the frozen wallet schema unchanged. +var AdditionalCurrencyFields = map[uint64]int{15: 16, 16: 17, 18: 18, 24: 22, 30: 34, 31: 35, 32: 36, 33: 37, 43: 48, 44: 54, 60: 60} + +func (e *Economy) AttachHuntingAP(h HuntingAP) { e.hunting = h } +func (e *Economy) AdditionalCurrencies() (map[int]uint64, error) { + e.mu.Lock() + defer e.mu.Unlock() + s, err := e.load() + if err != nil { + return nil, err + } + if err = e.refreshAP(&s); err != nil { + return nil, err + } + out := map[int]uint64{} + for t, f := range AdditionalCurrencyFields { + out[f] = s.Balances[t] + } + return out, nil +} +func (e *Economy) load() (economySnapshot, error) { + s := economySnapshot{Balances: map[uint64]uint64{}, Receipts: map[string]economyReceipt{}} + for t, n := range e.initial { + s.Balances[t] = n + } + b, err := e.store.Load("event_economy") + if err != nil || b == nil { + return s, err + } + if err = stateio.RequireExactJSONObject(b, "balances", "receipts"); err != nil { + return s, err + } + if err = json.Unmarshal(b, &s); err != nil { + return s, err + } + if s.Balances == nil || s.Receipts == nil { + return s, fmt.Errorf("events: invalid saved economy") + } + for t, n := range s.Balances { + if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 { + return s, fmt.Errorf("events: invalid saved currency %d", t) + } + } + for id, r := range s.Receipts { + if id == "" || len(r.Digest) != sha256.Size { + return s, fmt.Errorf("events: invalid economy receipt") + } + } + return s, nil +} + +func walletCurrency(t uint64) bool { + switch t { + case 2, 3, 4, 12, 20, 22, 68: + return true + } + return false +} +func extraCurrency(t uint64) bool { _, ok := AdditionalCurrencyFields[t]; return ok } +func apCurrency(t uint64) bool { return t == 21 || t == 23 } +func inventoryType(t uint64) bool { + switch t { + case 5, 7, 8, 9, 13, 14, 17, 19, 25, 26, 27, 29, 34, 45, 46, 47, 49: + return true + } + return false +} + +func (e *Economy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) + d := sha256.Sum256(definition) + e.mu.Lock() + s, err := e.load() + if err != nil { + e.mu.Unlock() + return nil, err + } + if r, ok := s.Receipts[identity]; ok { + e.mu.Unlock() + if !bytes.Equal(r.Definition, d[:]) { + return nil, fmt.Errorf("events: reward identity reused with different definition") + } + return append([]byte(nil), r.Bundle...), nil + } + e.mu.Unlock() + var selected []player.Item + // Merge repeated definitions before selecting stacks so the same quantity + // cannot be selected twice from a single inventory entry. + totals := map[[2]uint64]uint64{} + for _, r := range costs { + k := [2]uint64{r.Type, r.ID} + if r.Count == 0 || r.Count > math.MaxInt64 || totals[k] > math.MaxInt64-r.Count { + return nil, fmt.Errorf("events: invalid cost") + } + totals[k] += r.Count + } + keys := make([][2]uint64, 0, len(totals)) + for k := range totals { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i][0] != keys[j][0] { + return keys[i][0] < keys[j][0] + } + return keys[i][1] < keys[j][1] + }) + for _, k := range keys { + n := totals[k] + if walletCurrency(k[0]) || extraCurrency(k[0]) || apCurrency(k[0]) { + if k[1] != 0 { + return nil, fmt.Errorf("events: currency has item id") + } + selected = append(selected, player.Item{Type: k[0], Count: n}) + continue + } + items, err := e.items.SelectMutable(k[0], k[1], n) + if err != nil { + return nil, err + } + selected = append(selected, items...) + } + var rs []gamedata.BattleReward + for _, r := range rewards { + rs = append(rs, gamedata.BattleReward(r)) + } + return e.consumeAndGrant(identity, selected, rs, d[:], false) +} + +func (e *Economy) ConsumeAndGrant(identity string, consumed []player.Item, rewards []gamedata.BattleReward) ([]byte, error) { + return e.consumeAndGrant(identity, consumed, rewards, nil, false) +} + +func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewards []gamedata.BattleReward, definition []byte, openBox bool) ([]byte, error) { + e.mu.Lock() + defer e.mu.Unlock() + if identity == "" { + return nil, fmt.Errorf("events: empty reward identity") + } + s, err := e.load() + if err != nil { + return nil, err + } + raw, err := json.Marshal(struct { + Costs []player.Item + Rewards []gamedata.BattleReward + }{consumed, rewards}) + if err != nil { + return nil, err + } + digest := sha256.Sum256(raw) + if receipt, ok := s.Receipts[identity]; ok { + if !bytes.Equal(receipt.Digest, digest[:]) { + return nil, fmt.Errorf("events: reward identity reused with different contents") + } + return append([]byte(nil), receipt.Bundle...), nil + } + if err = e.refreshAP(&s); err != nil { + return nil, err + } + var itemCosts []player.Item + var walletCosts, apCosts []gamedata.Reward + for _, c := range consumed { + if c.Count == 0 || c.Count > math.MaxInt32 { + return nil, fmt.Errorf("events: invalid consumption count") + } + r := gamedata.Reward{Type: c.Type, ID: c.ID, Count: c.Count} + switch { + case walletCurrency(c.Type): + if c.ID != 0 || c.InvenIndex != 0 { + return nil, fmt.Errorf("events: invalid currency cost") + } + walletCosts = append(walletCosts, r) + case apCurrency(c.Type): + if c.ID != 0 || c.InvenIndex != 0 { + return nil, fmt.Errorf("events: invalid hunting AP cost") + } + apCosts = append(apCosts, r) + case extraCurrency(c.Type): + if c.ID != 0 || c.InvenIndex != 0 || s.Balances[c.Type] < c.Count { + return nil, fmt.Errorf("events: insufficient currency %d", c.Type) + } + s.Balances[c.Type] -= c.Count + default: + if !inventoryType(c.Type) { + return nil, fmt.Errorf("events: unsupported consumed item type %d", c.Type) + } + itemCosts = append(itemCosts, c) + } + } + if len(itemCosts) > 0 { + if err = e.items.CanConsume(itemCosts); err != nil { + return nil, err + } + } + var expanded []gamedata.BattleReward + if openBox { + expanded, err = e.graph.Resolve(rewards) + } else { + expanded, err = e.resolveGranted(rewards) + } + if err != nil { + return nil, err + } + var walletRewards, apRewards []gamedata.Reward + var itemRewards []gamedata.BattleReward + var costumes []uint64 + var equips []player.Equipment + var bundle []byte + for _, r := range expanded { + if r.Count == 0 || r.Count > math.MaxInt32 { + return nil, fmt.Errorf("events: invalid reward count") + } + switch { + case walletCurrency(r.Type): + if r.ID != 0 { + return nil, fmt.Errorf("events: currency reward has item id") + } + walletRewards = append(walletRewards, gamedata.Reward(r)) + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count})) + case apCurrency(r.Type): + if r.ID != 0 { + return nil, fmt.Errorf("events: invalid AP reward") + } + apRewards = append(apRewards, gamedata.Reward(r)) + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count})) + case extraCurrency(r.Type): + if r.ID != 0 || s.Balances[r.Type] > math.MaxInt32-r.Count { + return nil, fmt.Errorf("events: currency overflow") + } + s.Balances[r.Type] += r.Count + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count})) + case r.Type == 11: + if _, ok := e.costumes.Character(r.ID); !ok || r.Count > 1000 { + return nil, fmt.Errorf("events: unknown or excessive costume reward %d", r.ID) + } + for n := uint64(0); n < r.Count; n++ { + costumes = append(costumes, r.ID) + } + case r.Type == 10: + if r.Count > 1000 { + return nil, fmt.Errorf("events: excessive equipment reward") + } + for n := uint64(0); n < r.Count; n++ { + main, sub, private, rollErr := e.options.RollOptions(r.ID) + if rollErr != nil { + return nil, rollErr + } + entry := player.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}} + for _, o := range main { + entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) + } + for _, o := range sub { + entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) + } + if private != nil { + entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID} + } + equips = append(equips, entry) + } + case inventoryType(r.Type): + if r.Type == 47 || r.Type == 49 { + if !e.ownedDesign[r.Type][r.ID] { + return nil, fmt.Errorf("events: unknown owned item design %d:%d", r.Type, r.ID) + } + } + if r.Type == 45 { + if _, ok := e.prestige[r.ID]; !ok { + return nil, fmt.Errorf("events: unknown prestige skin %d", r.ID) + } + } + if r.ID == 0 { + return nil, fmt.Errorf("events: zero inventory reward id") + } + itemRewards = append(itemRewards, r) + default: + return nil, fmt.Errorf("events: unsupported reward type %d", r.Type) + } + } + if err = e.wallet.CanExchange(walletCosts, walletRewards); err != nil { + return nil, err + } + if len(apCosts)+len(apRewards) > 0 { + if e.hunting == nil { + return nil, fmt.Errorf("events: hunting AP runtime unavailable") + } + if err = e.hunting.CanExchangeAP(apCosts, apRewards); err != nil { + return nil, err + } + } + // Validation and random generation finish before the first account write. + if len(itemCosts) > 0 { + if err = e.items.Consume(itemCosts); err != nil { + return nil, err + } + } + if err = e.wallet.ExchangeOnce(identity+":wallet", walletCosts, walletRewards); err != nil { + return nil, err + } + if len(apCosts)+len(apRewards) > 0 { + if err = e.hunting.ExchangeAPOnce(identity+":ap", apCosts, apRewards); err != nil { + return nil, err + } + } + items, err := e.items.GrantOnce(identity+":items", itemRewards) + if err != nil { + return nil, err + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: item.Type, ID: item.ID, Count: item.Count})) + } + if len(costumes) > 0 { + grant, err := e.collection.GrantCostumes(identity+":costumes", costumes, e.costumes) + if err != nil { + return nil, err + } + var overflow uint64 + for _, x := range grant.Exchanges { + if x.ExchangeItemType != 20 || x.ExchangeCount > math.MaxInt64-overflow { + return nil, fmt.Errorf("events: invalid costume exchange") + } + overflow += x.ExchangeCount + } + if overflow > 0 { + if _, err = e.wallet.GrantMileageOnce(identity+":overflow", overflow); err != nil { + return nil, err + } + } + bundle = append(bundle, player.CollectionRewardBundle(e.collection, grant)...) + } + for i, entry := range equips { + saved, err := e.equipment.GrantGeneratedOnce(identity+":equipment:"+strconv.Itoa(i), entry) + if err != nil { + return nil, err + } + bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(saved)) + bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: 10, ID: saved.ID, Count: 1})) + } + s.Receipts[identity] = economyReceipt{Digest: digest[:], Definition: definition, Bundle: bundle} + data, err := json.Marshal(s) + if err != nil { + return nil, err + } + if err = e.store.Save("event_economy", data); err != nil { + return nil, err + } + return bundle, nil +} + +func (e *Economy) resolveGranted(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) { + if g, ok := e.graph.(interface { + ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error) + }); ok { + return g.ResolveGranted(rewards) + } + return e.graph.Resolve(rewards) +} + +func (e *Economy) OpenBox(identity string, box player.Item) ([]byte, error) { + return e.consumeAndGrant(identity, []player.Item{box}, []gamedata.BattleReward{{Type: 9, ID: box.ID, Count: box.Count}}, nil, true) +} diff --git a/go/internal/server/events/economy_test.go b/go/internal/server/events/economy_test.go new file mode 100644 index 0000000..2695c3f --- /dev/null +++ b/go/internal/server/events/economy_test.go @@ -0,0 +1,270 @@ +package events + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "fmt" + "path/filepath" + "testing" + "time" +) + +type economyCostumes struct{} + +func (economyCostumes) Character(id uint64) (gamedata.CharacterDesign, bool) { + return gamedata.CharacterDesign{ID: 6060, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 200}, id == 60601 +} + +type economyOptions struct{} + +func (economyOptions) RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) { + return nil, nil, nil, nil +} + +type economyGraph struct{ calls int } + +func (g *economyGraph) Resolve(rs []gamedata.BattleReward) ([]gamedata.BattleReward, error) { + g.calls++ + var out []gamedata.BattleReward + for _, r := range rs { + if r.Type == 9 { + out = append(out, gamedata.BattleReward{Type: 4, Count: r.Count * 10}) + } else { + out = append(out, r) + } + } + return out, nil +} +func economyFixture(t *testing.T, store stateio.Store, g *economyGraph) (*Economy, *player.Inventory, *player.Wallet) { + t.Helper() + items, e := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + if e != nil { + t.Fatal(e) + } + wallet, e := player.OpenWallet(store, player.Currency{Gold: 5}) + if e != nil { + t.Fatal(e) + } + collection, e := player.OpenCollectionStore(store, nil) + if e != nil { + t.Fatal(e) + } + equips, e := player.OpenEquipmentInventory(store) + if e != nil { + t.Fatal(e) + } + for _, f := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, equips.EnsurePersisted} { + if e = f(); e != nil { + t.Fatal(e) + } + } + eco, e := NewEconomy(store, items, wallet, collection, equips, economyCostumes{}, economyOptions{}, g, map[uint64]uint64{15: 3}) + if e != nil { + t.Fatal(e) + } + return eco, items, wallet +} +func TestEconomyReplayBeforeCostSelectionAndRandom(t *testing.T) { + g := &economyGraph{} + eco, _, wallet := economyFixture(t, stateio.NewMemory(), g) + cost := []gamedata.Reward{{Type: 4, Count: 5}} + rewards := []gamedata.Reward{{Type: 8, ID: 1000, Count: 2}} + a, e := eco.Apply("request", cost, rewards) + if e != nil { + t.Fatal(e) + } + b, e := eco.Apply("request", cost, rewards) + if e != nil || !bytes.Equal(a, b) || g.calls != 1 || wallet.Snapshot().Gold != 0 { + t.Fatalf("retry cost/RNG %v calls %d", e, g.calls) + } + if _, e = eco.Apply("request", cost, nil); e == nil { + t.Fatal("identity definition conflict accepted") + } +} +func TestEconomyRejectsUnknownCostAndCurrencyID(t *testing.T) { + eco, _, wallet := economyFixture(t, stateio.NewMemory(), &economyGraph{}) + if _, e := eco.ConsumeAndGrant("unknown", []player.Item{{Type: 999, ID: 1, InvenIndex: 1, Count: 1}}, nil); e == nil { + t.Fatal("unknown cost accepted") + } + if _, e := eco.Apply("badcurrency", nil, []gamedata.Reward{{Type: 4, ID: 3, Count: 1}}); e == nil { + t.Fatal("currency ID accepted") + } + if wallet.Snapshot().Gold != 5 { + t.Fatal("validation mutated wallet") + } +} +func TestEconomySQLiteAtomicRollbackAndRetry(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, e := accountstate.Open(path) + if e != nil { + t.Fatal(e) + } + g := &economyGraph{} + eco, _, wallet := economyFixture(t, repo, g) + op, e := repo.BeginOperation() + if e != nil { + t.Fatal(e) + } + cost := []gamedata.Reward{{Type: 4, Count: 5}} + rewards := []gamedata.Reward{{Type: 8, ID: 1000, Count: 2}, {Type: 15, Count: 2}} + if _, e = eco.Apply("atomic", cost, rewards); e != nil { + t.Fatal(e) + } + if wallet.Snapshot().Gold != 0 { + t.Fatal("operation not applied") + } + _ = op.Rollback() + repo.Close() + repo, e = accountstate.Open(path) + if e != nil { + t.Fatal(e) + } + defer repo.Close() + eco, items, wallet := economyFixture(t, repo, &economyGraph{}) + if wallet.Snapshot().Gold != 5 || len(items.All()) != 0 { + t.Fatal("rollback kept rewards/cost") + } + currencies, e := eco.AdditionalCurrencies() + if e != nil || currencies[16] != 3 { + t.Fatalf("extra currency survived rollback %v %v", currencies, e) + } + op, e = repo.BeginOperation() + if e != nil { + t.Fatal(e) + } + if _, e = eco.Apply("atomic", cost, rewards); e != nil { + t.Fatal(e) + } + if e = op.Commit(); e != nil { + t.Fatal(e) + } + if wallet.Snapshot().Gold != 0 || len(items.All()) != 1 { + t.Fatal("retry failed") + } +} +func TestEconomyOptionalCurrencyOverflow(t *testing.T) { + eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) + if _, e := eco.Apply("overflow", nil, []gamedata.Reward{{Type: 15, Count: 2147483647}}); e == nil { + t.Fatal("optional currency overflow accepted") + } + currencies, e := eco.AdditionalCurrencies() + if e != nil || currencies[16] != 3 { + t.Fatal(fmt.Sprint(currencies, e)) + } +} + +func TestEconomyDailyFreeAPOnly(t *testing.T) { + eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) + if e := eco.AttachAPRefresh(map[uint64]uint64{30: 5, 32: 60}, gamedata.HuntingAPDesign{ResetSeconds: 32400}); e != nil { + t.Fatal(e) + } + now := time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC) + eco.now = func() time.Time { return now } + if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 5}}); e != nil { + t.Fatal(e) + } + if _, e := eco.Apply("day1", []gamedata.Reward{{Type: 30, Count: 5}}, nil); e != nil { + t.Fatal(e) + } + if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 1}}); e == nil { + t.Fatal("CanApply ignored spent AP") + } + now = now.Add(2 * time.Minute) + if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 5}}); e != nil { + t.Fatal(e) + } + currencies, e := eco.AdditionalCurrencies() + if e != nil || currencies[16] != 3 || currencies[34] != 5 || currencies[36] != 60 { + t.Fatalf("refresh changed wrong currency %+v %v", currencies, e) + } + if _, e = eco.ChargeInfo(); e != nil { + t.Fatal(e) + } +} + +func (g *economyGraph) ResolveGranted(rs []gamedata.BattleReward) ([]gamedata.BattleReward, error) { + g.calls++ + return rs, nil +} +func TestEconomyBoxPreservedUntilExplicitUse(t *testing.T) { + store := stateio.NewMemory() + g := &economyGraph{} + eco, items, wallet := economyFixture(t, store, g) + if _, e := eco.Apply("grantbox", nil, []gamedata.Reward{{Type: 9, ID: 100, Count: 2}}); e != nil { + t.Fatal(e) + } + owned := items.All() + if len(owned) != 1 || owned[0].Type != 9 || wallet.Snapshot().Gold != 5 { + t.Fatal("reward auto-opened manual box") + } + boxes, e := OpenBoxes(store, items, eco) + if e != nil { + t.Fatal(e) + } + req := wire.AppendVarint(nil, 1, 1) + req = wire.AppendVarint(req, 2, owned[0].InvenIndex) + req = wire.AppendVarint(req, 3, 2) + code, a, _, e := boxes.HandleSession("/UseRandomBox", req, "session") + if e != nil || code != 143 || wallet.Snapshot().Gold != 25 || len(items.All()) != 0 { + t.Fatalf("openbox %d %v gold %d", code, e, wallet.Snapshot().Gold) + } + _, b, _, e := boxes.HandleSession("/UseRandomBox", req, "session") + if e != nil || !bytes.Equal(a, b) || g.calls != 2 { + t.Fatalf("box retry rerolled %v calls%d", e, g.calls) + } +} + +func TestPrestigeSkinRewardAndSpecialQuery(t *testing.T) { + eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) + eco.AttachPrestigeSkins(map[uint64]uint64{9901: 60601}) + if _, e := eco.Apply("skin", nil, []gamedata.Reward{{Type: 45, ID: 9901, Count: 1}}); e != nil { + t.Fatal(e) + } + code, out, ok, e := eco.PrestigeSkinInfo("/PrestigeSkinInfo", wire.AppendVarint(nil, 1, 1)) + entry, found, _ := wire.Bytes(out, 1) + costume, _, _ := wire.Varint(entry, 1) + design, _, _ := wire.Varint(entry, 2) + if e != nil || !ok || code != 425 || !found || costume != 60601 || design != 9901 { + t.Fatalf("skin wire %x %v", out, e) + } +} + +func TestCurrencyEnumProjectionMiniGameAndDeco(t *testing.T) { + eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) + if _, e := eco.Apply("coins", nil, []gamedata.Reward{{Type: 43, Count: 7}, {Type: 44, Count: 11}}); e != nil { + t.Fatal(e) + } + fields, e := eco.AdditionalCurrencies() + if e != nil || fields[48] != 7 || fields[54] != 11 { + t.Fatalf("enum projection %+v %v", fields, e) + } +} + +func TestOwnedEventItemsAndColosseumCoin(t *testing.T) { + eco, items, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) + eco.AttachOwnedItemDesign(map[uint64]map[uint64]bool{47: {100: true}, 49: {200: true}}) + if _, e := eco.Apply("eventowned", nil, []gamedata.Reward{{Type: 47, ID: 100, Count: 1}, {Type: 49, ID: 200, Count: 1}, {Type: 60, Count: 10}}); e != nil { + t.Fatal(e) + } + if len(items.All()) != 2 { + t.Fatal("owned event rewards absent") + } + currencies, e := eco.AdditionalCurrencies() + if e != nil || currencies[60] != 10 { + t.Fatal("colosseum coin missing") + } + code, out, _, e := eco.OwnedItemInfo("/AvatarInfo", wire.AppendVarint(nil, 1, 1)) + item, found, _ := wire.Bytes(out, 2) + typ, _, _ := wire.Varint(item, 3) + id, _, _ := wire.Varint(item, 2) + if e != nil || code != 467 || !found || typ != 49 || id != 200 { + t.Fatalf("avatar query%x %v", out, e) + } + if _, e = eco.Apply("badavatar", nil, []gamedata.Reward{{Type: 49, ID: 201, Count: 1}}); e == nil { + t.Fatal("unknown avatar accepted") + } +} diff --git a/go/internal/server/events/owned_items.go b/go/internal/server/events/owned_items.go new file mode 100644 index 0000000..9498164 --- /dev/null +++ b/go/internal/server/events/owned_items.go @@ -0,0 +1,28 @@ +package events + +import ( + "bd2server/internal/server/player" + "bd2server/internal/server/wire" + "fmt" +) + +func (e *Economy) AttachOwnedItemDesign(d map[uint64]map[uint64]bool) { e.ownedDesign = d } + +// ID card items are loaded through ItemInfo; there is no IdCardInfo ownership +// endpoint. AvatarInfo separately includes its owned ItemDBInfo list. +func (e *Economy) OwnedItemInfo(path string, req []byte) (int, []byte, bool, error) { + if path != "/AvatarInfo" { + return 0, nil, false, nil + } + seq, _, err := wire.Varint(req, 1) + if err != nil || seq == 0 { + return 467, nil, true, fmt.Errorf("events: missing sequence") + } + var out []byte + for _, item := range e.items.All() { + if item.Type == 49 { + out = wire.AppendBytes(out, 2, player.ItemWire(item)) + } + } + return 467, out, true, nil +} diff --git a/go/internal/server/events/registry.go b/go/internal/server/events/registry.go new file mode 100644 index 0000000..bfc2aef --- /dev/null +++ b/go/internal/server/events/registry.go @@ -0,0 +1,100 @@ +// Package events holds the public event calendar and shared account rewards. +package events + +import ( + "fmt" + "sync" + "time" + + "bd2server/internal/server/wire" +) + +type Schedule struct { + UID, Type, ID, SubID uint64 + Start, End int64 +} + +// Resolver separates gameplay from the project's published calendar. +type Resolver interface { + Resolve(uid uint64) (Schedule, error) + List() []Schedule +} + +type Registry struct { + mu sync.RWMutex + rows []Schedule + now func() time.Time +} + +func NewRegistry() *Registry { return &Registry{now: time.Now} } + +// Replace atomically installs an explicitly configured calendar. An empty +// calendar is valid; gameplay code does not invent official event dates. +func (r *Registry) Replace(rows []Schedule) error { + seen := map[string]bool{} + for _, s := range rows { + identity := fmt.Sprintf("uid:%d", s.UID) + if s.UID == 0 { + identity = fmt.Sprintf("public:%d:%d:%d", s.Type, s.ID, s.SubID) + } + if s.ID == 0 || s.Type > 25 || s.Type == 2 || s.End <= s.Start || seen[identity] { + return fmt.Errorf("events: invalid or duplicate schedule %d", s.UID) + } + seen[identity] = true + } + r.mu.Lock() + defer r.mu.Unlock() + r.rows = append([]Schedule(nil), rows...) + return nil +} + +func (r *Registry) List() []Schedule { + r.mu.RLock() + defer r.mu.RUnlock() + return append([]Schedule(nil), r.rows...) +} + +func (r *Registry) Resolve(uid uint64) (Schedule, error) { + r.mu.RLock() + defer r.mu.RUnlock() + var result Schedule + found := false + for _, s := range r.rows { + if s.UID == uid { + if found { + return Schedule{}, fmt.Errorf("events: ambiguous public schedule %d requires design identity", uid) + } + result, found = s, true + } + } + if found { + return result, nil + } + return Schedule{}, fmt.Errorf("events: unknown schedule %d", uid) +} + +func (r *Registry) Handle(path string, req []byte) (int, []byte, bool, error) { + if path != "/EventScheduleInfo" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(req, 1) + if err != nil || !found || seq == 0 { + return 163, nil, true, fmt.Errorf("events: invalid sequence") + } + var out []byte + for _, s := range r.List() { + b := wire.AppendVarint(nil, 1, s.UID) + b = wire.AppendVarint(b, 2, s.Type) + b = wire.AppendVarint(b, 3, s.ID) + if s.SubID != 0 { + b = wire.AppendVarint(b, 4, s.SubID) + } + b = wire.AppendVarint(b, 5, uint64(s.Start)) + b = wire.AppendVarint(b, 6, uint64(s.End)) + if now := r.now().UnixMilli(); now >= s.Start && now < s.End { + b = wire.AppendVarint(b, 7, 1) + } + out = wire.AppendBytes(out, 1, b) + } + return 163, out, true, nil +} diff --git a/go/internal/server/events/registry_test.go b/go/internal/server/events/registry_test.go new file mode 100644 index 0000000..046f702 --- /dev/null +++ b/go/internal/server/events/registry_test.go @@ -0,0 +1,103 @@ +package events + +import ( + "testing" + "time" + + "bd2server/internal/server/wire" +) + +func TestRegistryExplicitCalendarAndIsolation(t *testing.T) { + r := NewRegistry() + r.now = func() time.Time { return time.UnixMilli(100) } + if len(r.List()) != 0 { + t.Fatal("empty calendar activated events") + } + rows := []Schedule{{UID: 77, Type: 12, ID: 3, Start: 100, End: 200}} + if err := r.Replace(rows); err != nil { + t.Fatal(err) + } + rows[0].ID = 99 + got := r.List() + got[0].ID = 88 + s, err := r.Resolve(77) + if err != nil || s.ID != 3 { + t.Fatalf("calendar aliased caller: %+v %v", s, err) + } + code, out, ok, err := r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil || !ok || code != 163 { + t.Fatalf("schedule: %d %t %v", code, ok, err) + } + b, _, err := wire.Bytes(out, 1) + if err != nil { + t.Fatal(err) + } + if active, _, _ := wire.Varint(b, 7); active != 1 { + t.Fatal("start boundary inactive") + } + r.now = func() time.Time { return time.UnixMilli(200) } + _, out, _, err = r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 2)) + if err != nil { + t.Fatal(err) + } + b, _, _ = wire.Bytes(out, 1) + if active, _, _ := wire.Varint(b, 7); active != 0 { + t.Fatal("end boundary active") + } + if _, err = r.Resolve(78); err == nil { + t.Fatal("unknown uid resolved") + } + if err = r.Replace([]Schedule{{UID: 77, Type: 2, ID: 3, Start: 100, End: 200}}); err == nil { + t.Fatal("unknown type accepted") + } + if s, _ = r.Resolve(77); s.ID != 3 { + t.Fatal("failed replacement changed calendar") + } +} + +func TestPublicZeroUIDRowsRetainSignedWindowsAndRejectAmbiguousLookup(t *testing.T) { + r := NewRegistry() + r.now = func() time.Time { return time.UnixMilli(100) } + rows := []Schedule{ + {Type: 4, ID: 1, Start: -32400000, End: 200}, + {Type: 4, ID: 2, Start: -32400000, End: 200}, + {UID: 99, Type: 4, ID: 3, Start: 300, End: 400}, + } + if err := r.Replace(rows); err != nil { + t.Fatal(err) + } + if _, err := r.Resolve(0); err == nil { + t.Fatal("ambiguous UID 0 lookup succeeded") + } + _, body, _, err := r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil { + t.Fatal(err) + } + count := 0 + if err := wire.Walk(body, func(f wire.Field) error { + if f.Number != 1 { + return nil + } + count++ + start, _, _ := wire.Varint(f.Value, 5) + active, _, _ := wire.Varint(f.Value, 7) + if count <= 2 && (int64(start) != -32400000 || active != 1) { + t.Fatalf("public start=%d active=%d", int64(start), active) + } + if count == 3 && active != 0 { + t.Fatal("future row activated") + } + return nil + }); err != nil { + t.Fatal(err) + } + if count != 3 { + t.Fatalf("rows=%d", count) + } + if err := r.Replace(append(rows, rows[0])); err == nil { + t.Fatal("duplicate semantic public event accepted") + } + if len(r.List()) != 3 { + t.Fatal("failed replacement changed calendar") + } +} diff --git a/go/internal/server/events/skin_handler.go b/go/internal/server/events/skin_handler.go new file mode 100644 index 0000000..71d3043 --- /dev/null +++ b/go/internal/server/events/skin_handler.go @@ -0,0 +1,11 @@ +package events + +// SkinHandler restores skin ownership through the client's dedicated query. +type SkinHandler struct{ Economy *Economy } + +func (h SkinHandler) Handle(path string, request []byte) (int, []byte, bool, error) { + if code, response, handled, err := h.Economy.OwnedItemInfo(path, request); handled || err != nil { + return code, response, handled, err + } + return h.Economy.PrestigeSkinInfo(path, request) +} diff --git a/go/internal/server/events/skins.go b/go/internal/server/events/skins.go new file mode 100644 index 0000000..d622827 --- /dev/null +++ b/go/internal/server/events/skins.go @@ -0,0 +1,34 @@ +package events + +import ( + "bd2server/internal/server/wire" + "fmt" +) + +func (e *Economy) AttachPrestigeSkins(design map[uint64]uint64) { e.prestige = design } +func (e *Economy) PrestigeSkinInfo(path string, req []byte) (int, []byte, bool, error) { + if path != "/PrestigeSkinInfo" { + return 0, nil, false, nil + } + seq, _, err := wire.Varint(req, 1) + if err != nil || seq == 0 { + return 425, nil, true, fmt.Errorf("events: missing sequence") + } + var out []byte + seen := map[uint64]bool{} + for _, item := range e.items.All() { + if item.Type != 45 || seen[item.ID] { + continue + } + costume, ok := e.prestige[item.ID] + if !ok { + return 425, nil, true, fmt.Errorf("events: owned skin missing design") + } + seen[item.ID] = true + b := wire.AppendVarint(nil, 1, costume) + b = wire.AppendVarint(b, 2, item.ID) + b = wire.AppendVarint(b, 4, item.TimeValue) + out = wire.AppendBytes(out, 1, b) + } + return 425, out, true, nil +} diff --git a/go/internal/server/eventtasks/calendar_identity_test.go b/go/internal/server/eventtasks/calendar_identity_test.go new file mode 100644 index 0000000..b0162a9 --- /dev/null +++ b/go/internal/server/eventtasks/calendar_identity_test.go @@ -0,0 +1,81 @@ +package eventtasks + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "testing" +) + +func TestZeroUIDMissionGroupsKeepIndependentClaimsAfterRestart(t *testing.T) { + s, economy, store := setup(t) + now := s.now().UnixMilli() + rows := []events.Schedule{{Type: 4, ID: 7, Start: now - 1, End: now + 10000}, {Type: 4, ID: 8, Start: now - 1, End: now + 10000}} + if err := s.registry.(*events.Registry).Replace(rows); err != nil { + t.Fatal(err) + } + s.design.MissionGroups[8] = gamedata.EventMissionGroup{ID: 8, Groups: []uint64{9}} + if err := s.RecordEvent(2, 0, 2, nil); err != nil { + t.Fatal(err) + } + claim := func(seq, group uint64) []byte { + b := wire.AppendVarint(req(seq), 3, 2) + b = wire.AppendVarint(b, 4, 9) + b = wire.AppendVarint(b, 5, 10) + return wire.AppendVarint(b, 6, group) + } + if _, _, _, err := s.Handle("/MissionClear", claim(1, 7)); err != nil { + t.Fatal(err) + } + if !s.mission(rows[0], 10).Claimed || s.mission(rows[1], 10).Claimed { + t.Fatal("claim leaked across zero-UID groups") + } + restarted, err := Open(store, s.design, s.registry, economy) + if err != nil { + t.Fatal(err) + } + restarted.now = s.now + if !restarted.mission(rows[0], 10).Claimed || restarted.mission(rows[1], 10).Claimed { + t.Fatal("independent claims lost after restart") + } + if _, _, _, err := restarted.Handle("/MissionClear", claim(2, 8)); err != nil { + t.Fatal(err) + } + if economy.calls != 2 { + t.Fatalf("reward grants=%d", economy.calls) + } +} + +func TestInactiveCalendarDoesNotHideActivePassMissions(t *testing.T) { + s, _, _ := setup(t) + now := s.now().UnixMilli() + if err := s.registry.(*events.Registry).Replace([]events.Schedule{ + {UID: 2, Type: 4, ID: 7, Start: now - 20, End: now - 10}, + {UID: 3, Type: 5, ID: 8, Start: now - 1, End: now + 10000}, + {UID: 4, Type: 4, ID: 9, Start: now + 10000, End: now + 20000}, + }); err != nil { + t.Fatal(err) + } + if err := s.RecordEvent(2, 0, 1, nil); err != nil { + t.Fatal(err) + } + _, out, _, err := s.Handle("/EventMissionInfo", req(1)) + if err != nil { + t.Fatal(err) + } + count := 0 + if err := wire.Walk(out, func(f wire.Field) error { + if f.Number == 1 { + count++ + if scalar(f.Value, 1) != 7 || scalar(f.Value, 4) != 1 { + t.Fatalf("unexpected mission entry %x", f.Value) + } + } + return nil + }); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("active mission count=%d", count) + } +} diff --git a/go/internal/server/eventtasks/observer.go b/go/internal/server/eventtasks/observer.go new file mode 100644 index 0000000..7b47ba0 --- /dev/null +++ b/go/internal/server/eventtasks/observer.go @@ -0,0 +1,101 @@ +package eventtasks + +import ( + "bd2server/internal/server/world" + "encoding/json" + "fmt" +) + +func (s *Service) BeginSession(id string) { s.SetSession(id) } +func (s *Service) BeforeDispatch(string, []byte) error { + if s.provider == nil { + return nil + } + var e error + s.before, e = s.provider.Snapshot() + return e +} +func (s *Service) AttachGameplayProvider(p world.GameplayAchievementProvider) { s.provider = p } +func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte, error) { + if s.provider != nil { + after, e := s.provider.Snapshot() + if e != nil { + return nil, e + } + rk := fmt.Sprintf("observer:%s:%s:%d", s.session, path, scalar(request, 1)) + s.mu.Lock() + _, seen := s.state.Receipts[rk] + s.mu.Unlock() + if !seen { + for kind, old := range s.before.Items { + current := after.Items[kind] + if current < old { + condition := uint64(12) + if kind[1] == 0 { + condition = 11 + } + if e = s.RecordEvent(condition, kind[0], old-current, s.unlocked); e != nil { + return nil, e + } + } + } + for kind, current := range after.Items { + old := s.before.Items[kind] + if current > old { + if e = s.RecordEvent(32, kind[0], current-old, s.unlocked); e != nil { + return nil, e + } + } + } + for idx, current := range after.Equipment { + old, ok := s.before.Equipment[idx] + if ok && current.Level > old.Level { + if e = s.RecordEvent(14, 0, current.Level-old.Level, s.unlocked); e != nil { + return nil, e + } + } + } + for idx, current := range after.Costumes { + old, ok := s.before.Costumes[idx] + if ok && current.Level > old.Level { + if e = s.RecordEvent(104, current.ID, current.Level-old.Level, s.unlocked); e != nil { + return nil, e + } + } + } + s.mu.Lock() + s.state.Receipts[rk] = receipt{Digest: "observer"} + e = s.save() + s.mu.Unlock() + if e != nil { + return nil, e + } + } + } + return s.Notify() +} + +func (s *Service) CompleteSingleTargetEvent(condition uint64, unlocked func(uint64, uint64) bool) error { + s.mu.Lock() + defer s.mu.Unlock() + before, _ := json.Marshal(s.state) + for _, v := range s.taskSchedules() { + if !s.active(v) { + continue + } + for _, t := range s.design.Missions { + if t.Type != condition || t.SubType != 0 || t.Target != 1 || len(t.Params) != 0 || !s.availableTask(v, t) { + continue + } + if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) { + continue + } + s.mission(v, t.ID).Value = 1 + } + } + if e := s.save(); e != nil { + _ = json.Unmarshal(before, &s.state) + return e + } + return nil +} diff --git a/go/internal/server/eventtasks/paid_attendance_test.go b/go/internal/server/eventtasks/paid_attendance_test.go new file mode 100644 index 0000000..9903528 --- /dev/null +++ b/go/internal/server/eventtasks/paid_attendance_test.go @@ -0,0 +1,55 @@ +package eventtasks + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "bytes" + "testing" +) + +func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) { + for _, paid := range []bool{false, true} { + s, eco, store := setup(t) + s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 1, Ticket: 77} + s.design.AttendanceRewards[1] = []gamedata.EventAttendanceReward{{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}, Premium: gamedata.Reward{Type: 4, Count: 100}}} + s.AttachAttendancePremium(func(ticket uint64) bool { return paid && ticket == 77 }) + if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil { + t.Fatal(err) + } + claim := wire.AppendVarint(req(2), 2, 1) + claim = wire.AppendVarint(claim, 3, 1) + claim = wire.AppendVarint(claim, 4, 1) + code, response, handled, err := s.Handle("/EventReward", claim) + if err != nil || code != 151 || !handled { + t.Fatal(err) + } + bundle, ok, err := wire.Bytes(response, 1001) + if err != nil || !ok || !bytes.Equal(bundle, []byte{10, 0}) { + t.Fatal("actual bundle absent", bundle, err) + } + receipt, ok, err := wire.Bytes(response, 1002) + if err != nil || !ok || string(receipt) != "test:/EventReward:2" { + t.Fatal("durable receipt absent", string(receipt), err) + } + want := 1 + if paid { + want = 2 + } + if len(eco.rewards) != want { + t.Fatal("premium ticket ignored", paid, eco.rewards) + } + if _, replay, _, err := s.Handle("/EventReward", claim); err != nil || eco.calls != 1 || !bytes.Equal(replay, response) { + t.Fatal("claim replay duplicated", err) + } + reopened, err := Open(store, s.design, s.registry, eco) + if err != nil { + t.Fatal(err) + } + reopened.now = s.now + reopened.SetSession("test") + _, replay, _, err := reopened.Handle("/EventReward", claim) + if err != nil || eco.calls != 1 || !bytes.Equal(replay, response) { + t.Fatal("restart lost reward envelope", err) + } + } +} diff --git a/go/internal/server/eventtasks/service.go b/go/internal/server/eventtasks/service.go new file mode 100644 index 0000000..7cecdc4 --- /dev/null +++ b/go/internal/server/eventtasks/service.go @@ -0,0 +1,905 @@ +// Package eventtasks owns attendance, scheduled missions and pass progress. +// Calendar policy is supplied by events.Resolver; requests never create dates +// or manufacture progress. The parent request transaction includes all saves. +package eventtasks + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "bd2server/internal/server/world" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "sync" + "time" +) + +type Economy interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) +} +type attendance struct { + Group, Count uint64 + LastDay string + Obtained map[string]bool + History map[string]bool +} +type mission struct { + Value uint64 + Claimed bool + Period string +} +type pass struct { + Exp uint64 + Premium bool + Claimed map[string]bool +} +type receipt struct { + Digest string + Code int + Reply []byte +} +type snapshot struct { + Version string + NewbieStep uint64 + Attendance map[string]*attendance + Missions map[string]*mission + Passes map[string]*pass + LoginDays map[string]int64 + Receipts map[string]receipt +} +type Service struct { + provider world.GameplayAchievementProvider + before world.GameplayAchievementSnapshot + mu sync.Mutex + store stateio.Store + design *gamedata.EventTasksDesign + registry events.Resolver + economy Economy + state snapshot + now func() time.Time + session string + unlocked func(uint64, uint64) bool + authorizeCash func(uint64, uint64) bool + attendancePremium func(uint64) bool + associated func(events.Schedule) uint64 +} + +func Open(store stateio.Store, d *gamedata.EventTasksDesign, r events.Resolver, e Economy) (*Service, error) { + if store == nil || d == nil || r == nil || e == nil { + return nil, errors.New("eventtasks: missing dependency") + } + s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now} + b, err := store.Load("eventtasks") + if err != nil { + return nil, err + } + if b != nil { + if err = stateio.RequireExactJSONObject(b, "Version", "NewbieStep", "Attendance", "Missions", "Passes", "LoginDays", "Receipts"); err != nil { + return nil, err + } + if err = json.Unmarshal(b, &s.state); err != nil { + return nil, err + } + if s.state.Version != versionconfig.State() || s.state.Attendance == nil || s.state.Missions == nil || s.state.Passes == nil || s.state.LoginDays == nil || s.state.Receipts == nil { + return nil, errors.New("eventtasks: invalid state layout") + } + for _, a := range s.state.Attendance { + if a == nil || a.Obtained == nil || a.History == nil { + return nil, errors.New("eventtasks: invalid attendance") + } + } + for _, m := range s.state.Missions { + if m == nil { + return nil, errors.New("eventtasks: invalid mission") + } + } + for _, p := range s.state.Passes { + if p == nil || p.Claimed == nil { + return nil, errors.New("eventtasks: invalid pass") + } + } + } else { + s.state.Version = versionconfig.State() + } + s.init() + return s, nil +} +func (s *Service) init() { + if s.state.Attendance == nil { + s.state.Attendance = map[string]*attendance{} + } + if s.state.Missions == nil { + s.state.Missions = map[string]*mission{} + } + if s.state.Passes == nil { + s.state.Passes = map[string]*pass{} + } + if s.state.LoginDays == nil { + s.state.LoginDays = map[string]int64{} + } + if s.state.Receipts == nil { + s.state.Receipts = map[string]receipt{} + } +} +func (s *Service) SetSession(v string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = v } +func (s *Service) AttachUnlockResolver(f func(uint64, uint64) bool) { s.unlocked = f } +func (s *Service) AttachCashAuthorization(f func(uint64, uint64) bool) { s.authorizeCash = f } +func (s *Service) AttachAttendancePremium(f func(uint64) bool) { s.attendancePremium = f } +func (s *Service) AttachAssociatedMissionGroup(f func(events.Schedule) uint64) { s.associated = f } + +func (s *Service) SetNewbieStep(step uint64) error { + s.mu.Lock() + defer s.mu.Unlock() + if step > s.state.NewbieStep { + s.state.NewbieStep = step + } + return s.save() +} +func (s *Service) NewbieStep() uint64 { s.mu.Lock(); defer s.mu.Unlock(); return s.state.NewbieStep } +func (s *Service) save() error { + b, e := json.Marshal(s.state) + if e != nil { + return e + } + return s.store.Save("eventtasks", b) +} +func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } +func key(v ...uint64) string { + var k string + for i, n := range v { + if i > 0 { + k += "/" + } + k += strconv.FormatUint(n, 10) + } + return k +} + +// scheduleKey preserves the public UID where it is present. UID zero is a +// protocol default shared by permanent groups, so its local identity also +// includes the event type and design group. +func scheduleKey(v events.Schedule) string { + if v.UID != 0 { + return key(v.UID) + } + return key(0, v.Type, v.ID, v.SubID) +} +func (s *Service) active(v events.Schedule) bool { + n := s.now().UnixMilli() + return n >= v.Start && n < v.End +} +func (s *Service) find(t, id uint64) (events.Schedule, error) { + rows := s.registry.List() + if t == 4 { + rows = s.taskSchedules() + } + for _, v := range rows { + if v.Type == t && v.ID == id && s.active(v) { + return v, nil + } + } + return events.Schedule{}, fmt.Errorf("eventtasks: event %d/%d inactive", t, id) +} + +func (s *Service) taskSchedules() []events.Schedule { + rows := s.registry.List() + var out []events.Schedule + seen := map[uint64]bool{} + for _, v := range rows { + if v.Type == 4 { + out = append(out, v) + if s.active(v) { + seen[v.ID] = true + } + } + } + for _, v := range rows { + if v.Type == 5 && s.active(v) { + g := s.design.Passes[v.ID].MissionGroup + if g > 0 && !seen[g] { + v.Type = 4 + v.ID = g + out = append(out, v) + seen[g] = true + } + } + } + if s.associated != nil { + for _, v := range rows { + if !s.active(v) { + continue + } + g := s.associated(v) + if g > 0 && !seen[g] { + v.Type = 4 + v.ID = g + out = append(out, v) + seen[g] = true + } + } + } + return out +} +func (s *Service) resolve(uid uint64) (events.Schedule, error) { + v, e := s.registry.Resolve(uid) + if e != nil { + return v, e + } + if !s.active(v) { + return v, errors.New("eventtasks: event expired") + } + return v, nil +} +func (s *Service) day() string { return s.now().UTC().Format("2006-01-02") } +func (s *Service) attendance(v events.Schedule) *attendance { + k := scheduleKey(v) + a := s.state.Attendance[k] + if a == nil { + g := s.design.Attendance[v.ID].Group + var first uint64 + for _, x := range s.design.AttendanceGroups { + if x.Group == g && (first == 0 || x.ID < first) { + first = x.ID + } + } + if first > 0 { + g = first + } + a = &attendance{Group: g, Obtained: map[string]bool{}, History: map[string]bool{}} + s.state.Attendance[k] = a + } + return a +} +func (s *Service) pass(v events.Schedule) *pass { + k := scheduleKey(v) + p := s.state.Passes[k] + if p == nil { + p = &pass{Claimed: map[string]bool{}} + s.state.Passes[k] = p + } + return p +} +func (s *Service) mission(v events.Schedule, id uint64) *mission { + k := scheduleKey(v) + "/" + key(id) + m := s.state.Missions[k] + if m == nil { + m = &mission{} + s.state.Missions[k] = m + } + g := s.design.MissionGroups[v.ID] + period := "" + if g.Type == 1 { + period = s.day() + } + if g.Type == 2 { + y, w := s.now().UTC().ISOWeek() + period = fmt.Sprintf("%d-%d", y, w) + } + if m.Period != period { + *m = mission{Period: period} + } + return m +} +func (s *Service) availableTask(v events.Schedule, t gamedata.EventTask) bool { + g := s.design.MissionGroups[v.ID] + for i, n := range g.Groups { + if n == t.Group { + if g.Type == 3 && int64(i) > (s.now().UnixMilli()-v.Start)/86400000 { + return false + } + return t.UnlockPack == 0 && t.UnlockQuest == 0 || s.unlocked != nil && s.unlocked(t.UnlockPack, t.UnlockQuest) + } + } + return false +} + +func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { + codes := map[string]int{"/Attendance": 0, "/AttendanceInfo": 0, "/EventRewardHistory": 0, "/EventReward": 151, "/EventMissionInfo": 127, "/MissionClear": 120, "/MissionUpdate": 119, "/PassInfo": 124, "/PassReward": 126, "/PassBuy": 129, "/LoginEvent": 0} + code, found := codes[path] + if !found { + return 0, nil, false, nil + } + if path == "/MissionClear" && scalar(req, 3) != 2 { + return 0, nil, false, nil + } + if path == "/MissionUpdate" { + handled := false + _ = wire.Walk(req, func(f wire.Field) error { + if f.Number == 2 && f.Type == 2 && scalar(f.Value, 4) > 0 { + handled = true + } + return nil + }) + if !handled { + return 0, nil, false, nil + } + } + s.mu.Lock() + defer s.mu.Unlock() + seq, present, err := wire.Varint(req, 1) + if err != nil || !present || seq == 0 { + return code, nil, true, errors.New("eventtasks: invalid sequence") + } + if err = wire.Walk(req, func(wire.Field) error { return nil }); err != nil { + return code, nil, true, err + } + digest := sha256.Sum256(append([]byte(path), req...)) + ds := hex.EncodeToString(digest[:]) + rk := s.session + ":" + path + ":" + key(seq) + if r, ok := s.state.Receipts[rk]; ok { + if r.Digest != ds { + return code, nil, true, errors.New("eventtasks: changed replay request") + } + return r.Code, append([]byte(nil), r.Reply...), true, nil + } + before, _ := json.Marshal(s.state) + out, err := s.handle(path, req, rk) + if err != nil { + _ = json.Unmarshal(before, &s.state) + return code, nil, true, err + } + s.state.Receipts[rk] = receipt{ds, code, out} + if err = s.save(); err != nil { + _ = json.Unmarshal(before, &s.state) + return code, nil, true, err + } + return code, out, true, nil +} + +func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) { + switch path { + case "/Attendance", "/LoginEvent": + today := s.day() + if _, ok := s.state.LoginDays[today]; !ok { + s.state.LoginDays[today] = s.now().UnixMilli() + } + var out []byte + for _, v := range s.registry.List() { + if !s.active(v) { + continue + } + if v.Type > 1 { + continue + } + a := s.attendance(v) + if a.LastDay != today { + if v.Type == 0 { + complete, maxDay := true, uint64(0) + for _, r := range s.design.AttendanceRewards[a.Group] { + if r.Day > maxDay { + maxDay = r.Day + } + if !a.Obtained[key(a.Group, r.ID)] { + complete = false + } + } + if complete && maxDay > 0 && a.Count >= maxDay { + group := s.design.Attendance[v.ID].Group + if next := s.design.AttendanceGroups[[2]uint64{group, a.Group}].Next; next > 0 { + a.Group, a.Count = next, 0 + for _, r := range s.design.AttendanceRewards[next] { + delete(a.Obtained, key(next, r.ID)) + } + } + } + } + a.LastDay = today + a.Count++ + if v.Type == 0 { + maxDay := uint64(0) + for _, r := range s.design.AttendanceRewards[a.Group] { + if r.Day > maxDay { + maxDay = r.Day + } + } + if maxDay > 0 && a.Count > maxDay { + a.Count = maxDay + } + } + } + if path == "/LoginEvent" { + x := wire.AppendVarint(nil, 1, v.UID) + x = wire.AppendVarint(x, 2, uint64(s.now().UTC().Truncate(24*time.Hour).Add(24*time.Hour).UnixMilli())) + out = wire.AppendBytes(out, 1, x) + continue + } + if v.Type == 0 { + x := wire.AppendVarint(nil, 1, v.UID) + x = wire.AppendVarint(x, 2, a.Group) + x = wire.AppendVarint(x, 3, a.Count) + out = wire.AppendBytes(out, 1, x) + } else { + x := wire.AppendVarint(nil, 1, v.UID) + for k, box := range s.design.LimitRewards { + if k[0] != v.ID { + continue + } + entry := wire.AppendVarint(nil, 1, k[1]) + entry = wire.AppendVarint(entry, 2, box) + date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(k[1])-1).Format("2006-01-02") + entry = wire.AppendBytes(entry, 3, []byte(date)) + x = wire.AppendBytes(x, 2, entry) + } + out = wire.AppendBytes(out, 2, x) + } + for claim := range a.Obtained { + var group, id uint64 + _, _ = fmt.Sscanf(claim, "%d/%d", &group, &id) + x := wire.AppendVarint(nil, 1, v.UID) + x = wire.AppendVarint(x, 2, group) + x = wire.AppendVarint(x, 3, id) + out = wire.AppendBytes(out, 5, x) + } + } + return out, nil + case "/AttendanceInfo": + var out []byte + start, end := scalar(b, 2), scalar(b, 3) + days := make([]string, 0, len(s.state.LoginDays)) + for d := range s.state.LoginDays { + days = append(days, d) + } + sort.Strings(days) + for _, d := range days { + if len(d) != 10 { + continue + } + n, _ := strconv.ParseUint(d[0:4]+d[5:7]+d[8:10], 10, 64) + if (start == 0 || n >= start) && (end == 0 || n <= end) { + out = wire.AppendVarint(out, 1, uint64(s.state.LoginDays[d])) + } + } + return out, nil + case "/EventRewardHistory": + var out []byte + for uid, a := range s.state.Attendance { + groups := map[uint64][]uint64{} + for k := range a.History { + var g, id uint64 + _, _ = fmt.Sscanf(k, "%d/%d", &g, &id) + groups[g] = append(groups[g], id) + } + var u uint64 + _, _ = fmt.Sscanf(uid, "%d", &u) + for g, ids := range groups { + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + x := wire.AppendVarint(nil, 1, u) + x = wire.AppendVarint(x, 2, g) + for _, id := range ids { + x = wire.AppendVarint(x, 3, id) + } + out = wire.AppendBytes(out, 1, x) + } + } + return out, nil + case "/EventReward": + v, e := s.resolve(scalar(b, 2)) + if e != nil { + return nil, e + } + if v.Type > 1 { + return nil, errors.New("eventtasks: reward is not attendance") + } + a := s.attendance(v) + g, id := scalar(b, 3), scalar(b, 4) + ck := key(g, id) + if a.Obtained[ck] { + return nil, errors.New("eventtasks: attendance already received") + } + var rewards []gamedata.Reward + if v.Type == 0 { + if g != a.Group { + return nil, errors.New("eventtasks: wrong attendance group") + } + for _, r := range s.design.AttendanceRewards[g] { + if r.ID == id && r.Day <= a.Count { + rewards = append(rewards, r.Basic) + if ticket := s.design.Attendance[v.ID].Ticket; ticket != 0 && s.attendancePremium != nil && s.attendancePremium(ticket) && r.Premium.Count > 0 { + rewards = append(rewards, r.Premium) + } + } + } + } else { + box, ok := s.design.LimitRewards[[2]uint64{g, id}] + date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(id)-1).Format("2006-01-02") + _, logged := s.state.LoginDays[date] + if ok && g == v.ID && logged && date == s.day() { + rewards = []gamedata.Reward{{Type: 9, ID: box, Count: 1}} + } + } + if len(rewards) == 0 { + return nil, errors.New("eventtasks: attendance day unavailable") + } + bundle, e := s.economy.Apply(identity, nil, rewards) + if e != nil { + return nil, e + } + a.Obtained[ck] = true + a.History[ck] = true + // Frozen EventRewardResponse has no known fields. The owned-client plugin + // applies this actual grant once using the durable request receipt; Handle + // persists the complete response for replay before the account commits. + out := wire.AppendBytes(nil, 1001, bundle) + out = wire.AppendString(out, 1002, identity) + return out, nil + case "/EventMissionInfo": + var out []byte + for _, v := range s.taskSchedules() { + if v.Type != 4 || !s.active(v) { + continue + } + for _, t := range s.design.Missions { + if !s.availableTask(v, t) { + continue + } + m := s.mission(v, t.ID) + x := wire.AppendVarint(nil, 1, v.ID) + x = wire.AppendVarint(x, 2, t.Group) + x = wire.AppendVarint(x, 3, t.ID) + x = wire.AppendVarint(x, 4, m.Value) + if m.Claimed { + x = wire.AppendVarint(x, 5, 1) + } + out = wire.AppendBytes(out, 1, x) + } + } + return out, nil + case "/MissionUpdate": + err := wire.Walk(b, func(f wire.Field) error { + if f.Number != 2 || f.Type != 2 { + return nil + } + id, value, event := scalar(f.Value, 2), scalar(f.Value, 3), scalar(f.Value, 4) + v, e := s.find(4, event) + if e != nil { + return e + } + t, ok := s.design.Missions[id] + if !ok || t.Group != scalar(f.Value, 1) || !s.availableTask(v, t) { + return errors.New("eventtasks: invalid mission update") + } + if value > s.mission(v, id).Value { + return errors.New("eventtasks: client progress exceeds authoritative gameplay") + } + return nil + }) + return nil, err + case "/MissionClear": + v, e := s.find(4, scalar(b, 6)) + if e != nil { + return nil, e + } + all := scalar(b, 2) != 0 + g, id := scalar(b, 4), scalar(b, 5) + var rewards []gamedata.Reward + var done []uint64 + var exp uint64 + for _, t := range s.design.Missions { + if t.Group != g || !s.availableTask(v, t) || !all && t.ID != id { + continue + } + m := s.mission(v, t.ID) + if !m.Claimed && m.Value >= t.Target { + rewards = append(rewards, t.Rewards...) + done = append(done, t.ID) + exp += t.PassExp + } + } + if len(done) == 0 { + return nil, errors.New("eventtasks: no completed unclaimed mission") + } + bundle, e := s.economy.Apply(identity, nil, rewards) + if e != nil { + return nil, e + } + for _, id := range done { + s.mission(v, id).Claimed = true + } + // Completion-of-mission conditions are derived from durable claims, + // rather than a client-supplied total. + for _, t := range s.design.Missions { + if !s.availableTask(v, t) || (t.Type != 1001 && t.Type != 1002) { + continue + } + var completed uint64 + for _, other := range s.design.Missions { + if !s.availableTask(v, other) || !s.mission(v, other.ID).Claimed { + continue + } + match := other.Group == t.Group + if len(t.Params) > 0 { + match = false + for _, n := range t.Params { + if n == other.Group || n == other.ID { + match = true + } + } + } + if match { + completed++ + } + } + m := s.mission(v, t.ID) + if completed > t.Target { + completed = t.Target + } + if completed > m.Value { + m.Value = completed + } + } + for _, pv := range s.registry.List() { + if pv.Type == 5 && s.active(pv) && s.design.Passes[pv.ID].MissionGroup == v.ID { + s.pass(pv).Exp += exp + } + } + return wire.AppendBytes(nil, 1, bundle), nil + case "/PassInfo": + var out []byte + for _, v := range s.registry.List() { + if v.Type != 5 || !s.active(v) { + continue + } + p := s.pass(v) + x := wire.AppendVarint(nil, 1, v.ID) + x = wire.AppendVarint(x, 2, p.Exp) + if p.Premium { + x = wire.AppendVarint(x, 3, 1) + } + out = wire.AppendBytes(out, 1, x) + d := s.design.Passes[v.ID] + for _, lv := range s.design.PassLevels[d.LevelGroup] { + basic, premium := p.Claimed[key(lv.ID, 0)], p.Claimed[key(lv.ID, 1)] + if !basic && !premium { + continue + } + x = wire.AppendVarint(nil, 1, v.ID) + x = wire.AppendVarint(x, 2, lv.ID) + if basic { + x = wire.AppendVarint(x, 3, 1) + } + if premium { + x = wire.AppendVarint(x, 4, 1) + } + out = wire.AppendBytes(out, 2, x) + } + } + return out, nil + case "/PassReward": + v, e := s.find(5, scalar(b, 3)) + if e != nil { + return nil, e + } + d, ok := s.design.Passes[v.ID] + if !ok { + return nil, errors.New("eventtasks: unknown pass") + } + p := s.pass(v) + if d.NewbieStep > s.state.NewbieStep { + return nil, errors.New("eventtasks: guide pass step locked") + } + all, id, rt := scalar(b, 2) != 0, scalar(b, 4), scalar(b, 5) + if rt > 1 { + return nil, errors.New("eventtasks: invalid pass reward kind") + } + var rewards []gamedata.Reward + var claims []string + var threshold uint64 + for _, lv := range s.design.PassLevels[d.LevelGroup] { + threshold += lv.NeedExp + if p.Exp < threshold || !all && lv.ID != id { + continue + } + for typ := uint64(0); typ <= 1; typ++ { + if !all && typ != rt || typ == 1 && !p.Premium { + continue + } + ck := key(lv.ID, typ) + if p.Claimed[ck] { + continue + } + r := lv.Basic + if typ == 1 { + r = lv.Premium + } + if r.Type > 0 && r.Count > 0 { + rewards = append(rewards, r) + } + claims = append(claims, ck) + } + } + if len(claims) == 0 { + return nil, errors.New("eventtasks: pass reward not eligible") + } + bundle, e := s.economy.Apply(identity, nil, rewards) + if e != nil { + return nil, e + } + for _, k := range claims { + p.Claimed[k] = true + } + out := wire.AppendBytes(nil, 1, bundle) + if d.NewbieStep > 0 { + complete := true + for _, lv := range s.design.PassLevels[d.LevelGroup] { + if !p.Claimed[key(lv.ID, 0)] { + complete = false + } + } + mv, err := s.find(4, d.MissionGroup) + if err != nil { + complete = false + } else { + for _, t := range s.design.Missions { + if s.availableTask(mv, t) && !s.mission(mv, t.ID).Claimed { + complete = false + } + } + } + if complete && s.state.NewbieStep == d.NewbieStep { + s.state.NewbieStep++ + out = wire.AppendVarint(out, 2, s.state.NewbieStep) + } + } + return out, nil + case "/PassBuy": + v, e := s.find(5, scalar(b, 2)) + if e != nil { + return nil, e + } + p := s.pass(v) + typ := scalar(b, 3) + var chosen *gamedata.EventPassBuy + for _, b := range s.design.PassBuys[v.ID] { + if b.Type == typ { + copy := b + chosen = © + break + } + } + if chosen == nil { + return nil, errors.New("eventtasks: unknown pass purchase") + } + var level, threshold uint64 + for _, lv := range s.design.PassLevels[s.design.Passes[v.ID].LevelGroup] { + threshold += lv.NeedExp + if p.Exp >= threshold { + level = lv.ID + } + } + if level < chosen.UnlockLevel { + return nil, errors.New("eventtasks: pass purchase level locked") + } + // Private-server cash products follow the existing CashShopBuy policy: + // no external payment is charged. Premium activation is limited once per + // scheduled pass by durable Premium state. A supplied entitlement checker + // can restrict this policy for installations that integrate payments. + if chosen.CashID != 0 && s.authorizeCash != nil && !s.authorizeCash(v.ID, typ) { + return nil, errors.New("eventtasks: cash entitlement required") + } + if p.Premium && (typ == 1 || typ == 3) { + return nil, errors.New("eventtasks: premium already active") + } + var costs []gamedata.Reward + if chosen.Cost.Count > 0 { + costs = append(costs, chosen.Cost) + } + if _, e = s.economy.Apply(identity, costs, chosen.Rewards); e != nil { + return nil, e + } + if typ == 0 { + levels := s.design.PassLevels[s.design.Passes[v.ID].LevelGroup] + var threshold uint64 + for _, lv := range levels { + threshold += lv.NeedExp + if threshold > p.Exp { + p.Exp = threshold + break + } + } + } else if typ == 1 || typ == 3 { + p.Premium = true + } + return wire.AppendVarint(nil, 1, p.Exp), nil + } + return nil, errors.New("eventtasks: unsupported operation") +} + +// RecordEvent is called by authoritative gameplay. It returns no rewards and +// caps every matching task at its design threshold. +func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64, uint64) bool) error { + s.mu.Lock() + defer s.mu.Unlock() + if count == 0 { + return nil + } + for _, v := range s.taskSchedules() { + if v.Type != 4 || !s.active(v) { + continue + } + for _, t := range s.design.Missions { + if t.Type != condition || t.SubType != 0 && t.SubType != sub || !s.availableTask(v, t) { + continue + } + if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) { + continue + } + match := len(t.Params) == 0 + for _, n := range t.Params { + if n == sub { + match = true + } + } + if !match { + continue + } + m := s.mission(v, t.ID) + if m.Claimed { + continue + } + if m.Value >= t.Target { + continue + } + if count >= t.Target-m.Value { + m.Value = t.Target + } else { + m.Value += count + } + } + } + return s.save() +} +func (s *Service) Notify() ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []byte + for _, v := range s.taskSchedules() { + if v.Type != 4 || !s.active(v) { + continue + } + groups := map[uint64][]byte{} + for _, t := range s.design.Missions { + if !s.availableTask(v, t) { + continue + } + m := s.mission(v, t.ID) + if m.Value == 0 { + continue + } + entry := wire.AppendVarint(nil, 1, t.ID) + entry = wire.AppendVarint(entry, 2, m.Value) + groups[t.Group] = wire.AppendBytes(groups[t.Group], 1, entry) + } + var groupMap []byte + for g, rows := range groups { + entry := wire.AppendVarint(nil, 1, g) + entry = wire.AppendBytes(entry, 2, rows) + groupMap = wire.AppendBytes(groupMap, 1, entry) + } + entry := wire.AppendVarint(nil, 1, v.ID) + entry = wire.AppendBytes(entry, 2, groupMap) + out = wire.AppendBytes(out, 4, entry) + } + return out, nil +} + +// RecordLogin shares the same daily marker as Attendance, preventing repeated +// reconnects from inflating event login tasks. Main should invoke this once +// through the regular mission observer before serving attendance queries. +func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error { + s.mu.Lock() + marker := "mission-login:" + s.day() + if _, ok := s.state.LoginDays[marker]; ok { + s.mu.Unlock() + return nil + } + s.state.LoginDays[marker] = s.now().UnixMilli() + s.mu.Unlock() + return s.RecordEvent(0, 0, 1, unlocked) +} diff --git a/go/internal/server/eventtasks/service_test.go b/go/internal/server/eventtasks/service_test.go new file mode 100644 index 0000000..f5222fb --- /dev/null +++ b/go/internal/server/eventtasks/service_test.go @@ -0,0 +1,181 @@ +package eventtasks + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "testing" + "time" +) + +type economyStub struct { + calls int + rewards []gamedata.Reward +} + +func (e *economyStub) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { + e.calls++ + e.rewards = append(e.rewards, r...) + return []byte{10, 0}, nil +} +func setup(t *testing.T) (*Service, *economyStub, stateio.Store) { + t.Helper() + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + registry := events.NewRegistry() + if err := registry.Replace([]events.Schedule{{UID: 1, Type: 0, ID: 1, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}, {UID: 2, Type: 4, ID: 7, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}, {UID: 3, Type: 5, ID: 8, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}}); err != nil { + t.Fatal(err) + } + d := &gamedata.EventTasksDesign{Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 1}}, AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{1: {{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}}}}, MissionGroups: map[uint64]gamedata.EventMissionGroup{7: {ID: 7, Groups: []uint64{9}}}, Missions: map[uint64]gamedata.EventTask{10: {ID: 10, Group: 9, Type: 2, Target: 2, PassExp: 20, Rewards: []gamedata.Reward{{Type: 4, Count: 5}}}}, Passes: map[uint64]gamedata.EventPass{8: {ID: 8, MissionGroup: 7, LevelGroup: 8}}, PassLevels: map[uint64][]gamedata.EventPassLevel{8: {{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 8, ID: 1000, Count: 1}}}}} + e := &economyStub{} + store := stateio.NewMemory() + s, err := Open(store, d, registry, e) + if err != nil { + t.Fatal(err) + } + s.now = func() time.Time { return now } + s.SetSession("test") + return s, e, store +} +func req(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) } +func TestMissionRejectsFabricatedProgressAndReplaySurvivesRestart(t *testing.T) { + s, e, store := setup(t) + update := req(1) + x := wire.AppendVarint(nil, 1, 9) + x = wire.AppendVarint(x, 2, 10) + x = wire.AppendVarint(x, 3, 2) + x = wire.AppendVarint(x, 4, 7) + update = wire.AppendBytes(update, 2, x) + if _, _, _, err := s.Handle("/MissionUpdate", update); err == nil { + t.Fatal("client manufactured completion") + } + if err := s.RecordEvent(2, 0, 2, nil); err != nil { + t.Fatal(err) + } + clear := req(2) + clear = wire.AppendVarint(clear, 2, 1) + clear = wire.AppendVarint(clear, 3, 2) + clear = wire.AppendVarint(clear, 4, 9) + clear = wire.AppendVarint(clear, 6, 7) + _, reply, _, err := s.Handle("/MissionClear", clear) + if err != nil { + t.Fatal(err) + } + if e.calls != 1 || s.pass(s.registry.List()[2]).Exp != 20 { + t.Fatal("settlement or pass exp missing") + } + next, err := Open(store, s.design, s.registry, e) + if err != nil { + t.Fatal(err) + } + next.now = s.now + next.SetSession("test") + _, replay, _, err := next.Handle("/MissionClear", clear) + if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 { + t.Fatal("restart replay duplicated reward") + } + mutated := wire.AppendVarint(clear, 5, 10) + if _, _, _, err = next.Handle("/MissionClear", mutated); err == nil { + t.Fatal("changed replay accepted") + } + passReq := wire.AppendVarint(req(3), 2, 1) + passReq = wire.AppendVarint(passReq, 3, 8) + if _, _, _, err = next.Handle("/PassReward", passReq); err != nil { + t.Fatal(err) + } + if e.calls != 2 { + t.Fatal("pass reward not granted") + } +} +func TestAttendanceDailyCounterAndClaimEligibility(t *testing.T) { + s, e, _ := setup(t) + if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.Handle("/Attendance", req(2)); err != nil { + t.Fatal(err) + } + if s.state.Attendance["1"].Count != 1 { + t.Fatal("reconnect counted attendance twice") + } + claim := req(3) + claim = wire.AppendVarint(claim, 2, 1) + claim = wire.AppendVarint(claim, 3, 1) + claim = wire.AppendVarint(claim, 4, 1) + if _, _, _, err := s.Handle("/EventReward", claim); err != nil { + t.Fatal(err) + } + if e.calls != 1 { + t.Fatal("attendance reward missing") + } + again := wire.AppendVarint(nil, 1, 4) + again = append(again, claim[2:]...) + if _, _, _, err := s.Handle("/EventReward", again); err == nil { + t.Fatal("claimed attendance awarded again") + } +} + +func TestAttendanceNextGroupAndRepeatedChainReward(t *testing.T) { + s, e, _ := setup(t) + s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 20} + s.design.AttendanceGroups = map[[2]uint64]gamedata.EventAttendanceGroup{{20, 5}: {Group: 20, ID: 5, Next: 6}, {20, 6}: {Group: 20, ID: 6, Next: 5}} + s.design.AttendanceRewards = map[uint64][]gamedata.EventAttendanceReward{5: {{Group: 5, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 5}}}, 6: {{Group: 6, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 6}}}} + today := s.now() + s.now = func() time.Time { return today } + registry := events.NewRegistry() + if err := registry.Replace([]events.Schedule{{UID: 1, Type: 0, ID: 1, Start: today.Add(-24 * time.Hour).UnixMilli(), End: today.Add(7 * 24 * time.Hour).UnixMilli()}}); err != nil { + t.Fatal(err) + } + s.registry = registry + seq := uint64(1) + for _, group := range []uint64{5, 6, 5} { + if _, _, _, err := s.Handle("/Attendance", req(seq)); err != nil { + t.Fatal(err) + } + seq++ + if s.state.Attendance["1"].Group != group || s.state.Attendance["1"].Count != 1 { + t.Fatalf("wrong chain state %+v", s.state.Attendance["1"]) + } + claim := wire.AppendVarint(req(seq), 2, 1) + claim = wire.AppendVarint(claim, 3, group) + claim = wire.AppendVarint(claim, 4, 1) + if _, _, _, err := s.Handle("/EventReward", claim); err != nil { + t.Fatal(err) + } + seq++ + today = today.Add(24 * time.Hour) + } + if e.calls != 3 || len(s.state.Attendance["1"].History) != 2 { + t.Fatal("repeated cycle reward or retained history missing") + } +} + +func TestPrivateServerPremiumCashPassOnceAndRestartReplay(t *testing.T) { + s, e, store := setup(t) + s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, CashID: 42, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}}}} + request := wire.AppendVarint(req(1), 2, 8) + request = wire.AppendVarint(request, 3, 1) + _, reply, _, err := s.Handle("/PassBuy", request) + if err != nil { + t.Fatal(err) + } + if e.calls != 1 || !s.state.Passes["3"].Premium { + t.Fatal("premium cash pass not activated") + } + reopened, err := Open(store, s.design, s.registry, e) + if err != nil { + t.Fatal(err) + } + reopened.now = s.now + reopened.SetSession("test") + _, again, _, err := reopened.Handle("/PassBuy", request) + if err != nil || !bytes.Equal(reply, again) || e.calls != 1 { + t.Fatal("cash pass replay duplicated reward") + } + duplicate := wire.AppendVarint(req(2), 2, 8) + duplicate = wire.AppendVarint(duplicate, 3, 1) + if _, _, _, err = reopened.Handle("/PassBuy", duplicate); err == nil || e.calls != 1 { + t.Fatal("premium cash purchase granted twice") + } +} diff --git a/go/internal/server/feature/feature.go b/go/internal/server/feature/feature.go index 8de889c..85a485f 100644 --- a/go/internal/server/feature/feature.go +++ b/go/internal/server/feature/feature.go @@ -32,19 +32,15 @@ var emptyResponses = map[string]int{ "/EvilCastleDailyRewardState": 441, "/EvilCastleTowerInfo": 200, "/FieldTrapInfo": 171, - "/FireWorksInfo": 554, "/FishingCollectionInfo": 465, "/FishingTrapInfo": 457, "/FriendInfoList": 204, - "/FriendshipSpecialEpisodeInfo": 623, "/GuildInitInfo": 332, "/IdCardPresetInfo": 450, "/LifeUserInfo": 594, - "/MiniEventHubInfo": 534, "/MyLikeInfo": 218, "/MyRoomItemInfo": 232, "/PersonalInfo": 199, - "/PrestigeSkinInfo": 425, "/QuestMaxClearInfo": 137, "/SpineInteractionAchievementInfo": 532, "/TotalWarRewardState": 252, diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go index 964f610..c2d011a 100644 --- a/go/internal/server/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -8,9 +8,6 @@ import ( ) func TestHandleAuditedEmptyResponses(t *testing.T) { - if got := len(EmptyPacketCodes()); got != 29 { - t.Fatalf("audited empty-response registry has %d paths, want 29", got) - } for path, wantCode := range EmptyPacketCodes() { t.Run(path, func(t *testing.T) { gotCode, gotProto, ok, err := Handle(path, wire.AppendVarint(nil, 1, 42)) @@ -22,6 +19,11 @@ func TestHandleAuditedEmptyResponses(t *testing.T) { } func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) { + for _, path := range []string{"/EventScheduleInfo", "/LoginEvent", "/ChargeCostInfo", "/EventMissionInfo", "/EventRewardHistory", "/PackEventStoryInfo", "/PackEventBattleInfo", "/Attendance", "/AttendanceInfo", "/FireWorksInfo", "/FriendshipSpecialEpisodeInfo", "/MiniEventHubInfo", "/PrestigeSkinInfo"} { + if _, _, ok, err := Handle(path, wire.AppendVarint(nil, 1, 1)); ok || err != nil { + t.Fatalf("stateful event route %s has stateless fallback", path) + } + } for _, path := range []string{"/HuntDispatchInfo", "/HuntingGroundInfo", "/HuntingGroundInfoList", "/HuntingGroundEnter", "/UserLevelReward", "/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate", "/RecipeInfo", "/PackInfo", "/DeckInfo", "/RootSortIdInfo"} { if _, _, handled, err := Handle(path, wire.AppendVarint(nil, 1, 1)); handled || err != nil { t.Fatalf("stateful route %s still has a stateless success fallback", path) @@ -63,7 +65,6 @@ func TestEmptyPacketCodesReturnsCopy(t *testing.T) { func TestStandaloneNativeDefaults(t *testing.T) { for path, want := range map[string]int{ - "/EventScheduleInfo": 163, "/EquipInfo": 34, "/TodayQuestInfo": 64, "/UpdateAgeGate": 0, diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/feature/standalone.go index 3f3324f..beb0df8 100644 --- a/go/internal/server/feature/standalone.go +++ b/go/internal/server/feature/standalone.go @@ -4,19 +4,10 @@ package feature // features. An empty protobuf means the local account currently has no rows or // no active server schedule; it is not copied from a captured response. var standaloneDefaults = map[string]int{ - "/EventScheduleInfo": 163, - "/LoginEvent": 0, "/BalanceVersionCheck": 186, - "/ChargeCostInfo": 123, "/EquipInfo": 34, - "/EventMissionInfo": 127, "/MissionInfo": 118, - "/EventRewardHistory": 0, - "/PackEventStoryInfo": 220, - "/PackEventBattleInfo": 214, "/FishingItemInfo": 459, - "/Attendance": 0, - "/AttendanceInfo": 0, // No PvP decks, battle history or one-time reward claims have been saved. // Their repeated-only protobufs encode these account states as empty, and // the client's callbacks continue the arena-lobby entry sequence. diff --git a/go/internal/server/gacha/schedule.go b/go/internal/server/gacha/schedule.go index 6744d1e..d01c33f 100644 --- a/go/internal/server/gacha/schedule.go +++ b/go/internal/server/gacha/schedule.go @@ -1,12 +1,8 @@ package gacha import ( - "encoding/json" - "errors" "fmt" - "io" - "os" - "strings" + "math" "bd2server/internal/server/gamedata" ) @@ -65,64 +61,30 @@ type ScheduleWindow struct { CashCountBonus bool `json:"cash_count_bonus,omitempty"` } -func LoadScheduleSeed(path, clientVersion string) (*ScheduleSeed, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("gacha: read schedule seed: %w", err) - } - decoder := json.NewDecoder(strings.NewReader(string(data))) - decoder.DisallowUnknownFields() - var seed ScheduleSeed - if err := decoder.Decode(&seed); err != nil { - return nil, fmt.Errorf("gacha: decode schedule seed: %w", err) - } - var trailing any - if err := decoder.Decode(&trailing); err == nil { - return nil, errors.New("gacha: schedule seed has trailing JSON") - } else if !errors.Is(err, io.EOF) { - return nil, fmt.Errorf("gacha: decode schedule seed trailing data: %w", err) - } - if err := seed.Validate(clientVersion); err != nil { - return nil, err - } - return &seed, nil -} - func (s *ScheduleSeed) Validate(clientVersion string) error { if s == nil || clientVersion == "" || s.ClientVersion != clientVersion { return fmt.Errorf("gacha: schedule seed client version %q does not match %q", seedVersion(s), clientVersion) } - if len(s.Schedules) == 0 || len(s.StepUps) == 0 { - return errors.New("gacha: schedule seed requires regular and step-up windows") - } - seen := make(map[uint64]bool, len(s.Schedules)) - for _, window := range s.Schedules { - if err := validateWindow(window); err != nil { - return err + for n, rows := range [][]ScheduleWindow{s.Schedules, s.StepUps} { + for i, window := range rows { + if err := validateWindow(window); err != nil { + return err + } + if n == 1 && (window.FreeCountBonus || window.CashCountBonus) { + return fmt.Errorf("gacha: step-up group %d has unsupported bonus flags", window.GroupID) + } + for _, previous := range rows[:i] { + if previous.GroupID == window.GroupID && previous.StartTime < window.EndTime && window.StartTime < previous.EndTime { + return fmt.Errorf("gacha: overlapping schedule group %d", window.GroupID) + } + } } - if seen[window.GroupID] { - return fmt.Errorf("gacha: duplicate schedule group %d", window.GroupID) - } - seen[window.GroupID] = true - } - seen = make(map[uint64]bool, len(s.StepUps)) - for _, window := range s.StepUps { - if window.FreeCountBonus || window.CashCountBonus { - return fmt.Errorf("gacha: step-up group %d has unsupported bonus flags", window.GroupID) - } - if err := validateWindow(window); err != nil { - return err - } - if seen[window.GroupID] { - return fmt.Errorf("gacha: duplicate step-up group %d", window.GroupID) - } - seen[window.GroupID] = true } return nil } func validateWindow(window ScheduleWindow) error { - if window.GroupID == 0 || window.StartTime == 0 || window.EndTime <= window.StartTime { + if window.GroupID == 0 || window.GroupID > math.MaxInt32 || window.StartTime == 0 || window.EndTime > math.MaxInt64 || window.EndTime <= window.StartTime { return fmt.Errorf("gacha: invalid schedule window for group %d", window.GroupID) } return nil diff --git a/go/internal/server/gacha/schedule_test.go b/go/internal/server/gacha/schedule_test.go index b67be34..e306ab2 100644 --- a/go/internal/server/gacha/schedule_test.go +++ b/go/internal/server/gacha/schedule_test.go @@ -2,8 +2,6 @@ package gacha import ( "encoding/base64" - "os" - "path/filepath" "testing" "bd2server/internal/server/gamedata" @@ -12,50 +10,12 @@ import ( "bd2server/internal/server/wire" ) -func TestVersionedScheduleMatchesOfficial23510GachaInfo(t *testing.T) { - seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10") - if err != nil { - t.Fatal(err) - } - // Official 2.35.10 GachaInfo, captured 2026-09-23. It is evidence used by - // this test only; production loads the semantic JSON seed above. +func fixtureSchedule(t *testing.T) (*ScheduleSeed, error) { golden, err := base64.StdEncoding.DecodeString("ChEIpgEQgMi/3Iw0GJiIvcaRNAoSCLrqARCA8PTEiDQYmIi9xpE0ChIIu+oBEIDw9MSINBiYiL3GkTQKEQjOARCA8PTEiDQYmIi9xpE0ChEIzQEQgPD0xIg0GJiIvcaRNAoRCNABEIDIv9yMNBiYiL3GkTQKEQiZARCAyL/cjDQYmIi9xpE0ChAISBCAyL/cjDQYmIi9xpE0ChAIRxCAyL/cjDQYmIi9xpE0ChEIzwEQgMi/3Iw0GJiIvcaRNAoRCPEHEIDo2beLNBiA+L34jzQSBAgCGAo6EAgdEIDw9MSINBiYiL3GkTQ6EAgeEIDIv9yMNBiYiL3GkTRKUAiTCUJLGgMQvR4aBRCFBzABGgUQ0ggwAhoGEOn7AzADGgYQzeMDMAQaBhDF7gMwBRoGEJnsAzAGGgYQvfkDMAcaBhDu6QMwCBoGEKnvAzAJ") if err != nil { t.Fatal(err) } - regular := collectScheduleWindows(t, golden, 1) - steps := collectScheduleWindows(t, golden, 7) - if len(regular) != len(seed.Schedules) || len(steps) != len(seed.StepUps) { - t.Fatalf("golden schedules=%d/%d seed=%d/%d", len(regular), len(steps), len(seed.Schedules), len(seed.StepUps)) - } - for i := range seed.Schedules { - if regular[i] != seed.Schedules[i] { - t.Fatalf("schedule %d golden=%+v seed=%+v", i, regular[i], seed.Schedules[i]) - } - } - for i := range seed.StepUps { - if steps[i] != seed.StepUps[i] { - t.Fatalf("step-up %d golden=%+v seed=%+v", i, steps[i], seed.StepUps[i]) - } - } - if countFields(golden, 9) != 1 { - t.Fatal("official golden must retain the player preview used to verify field 9 separately") - } -} - -func TestScheduleSeedStrictValidation(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "gacha.json") - bad := `{"client_version":"2.35.10","schedules":[{"group_id":1,"start_time":1,"end_time":2}],"step_ups":[{"group_id":2,"start_time":1,"end_time":2}],"unknown":true}` - if err := os.WriteFile(path, []byte(bad), 0o600); err != nil { - t.Fatal(err) - } - if _, err := LoadScheduleSeed(path, "2.35.10"); err == nil { - t.Fatal("unknown schedule field was accepted") - } - if _, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "0.0.0"); err == nil { - t.Fatal("wrong client version was accepted") - } + return &ScheduleSeed{ClientVersion: "2.35.10", Schedules: collectScheduleWindows(t, golden, 1), StepUps: collectScheduleWindows(t, golden, 7)}, nil } func TestActivePickupCostumesUsesHalfOpenScheduleWindows(t *testing.T) { @@ -88,7 +48,7 @@ func TestActivePickupCostumesUsesHalfOpenScheduleWindows(t *testing.T) { } func TestGachaInfoUsesInjectedScheduleAndEmptyAccountHasNoPreview(t *testing.T) { - seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10") + seed, err := fixtureSchedule(t) if err != nil { t.Fatal(err) } diff --git a/go/internal/server/gameconfig/config.go b/go/internal/server/gameconfig/config.go index a7d9e0f..777f938 100644 --- a/go/internal/server/gameconfig/config.go +++ b/go/internal/server/gameconfig/config.go @@ -14,9 +14,22 @@ import ( const FileName = "game.json" type Config struct { - SchemaVersion int `json:"schema_version"` - Gacha GachaConfig `json:"gacha"` - Story StoryConfig `json:"story"` + SchemaVersion int `json:"schema_version"` + Gacha GachaConfig `json:"gacha"` + Story StoryConfig `json:"story"` + Purchases PurchasesConfig `json:"purchases"` +} + +type PurchasesConfig struct { + DiamondRecharge DiamondRechargeConfig `json:"diamond_recharge"` +} + +// Only paid-diamond recharge has a configurable price. All other cash goods +// use their GameData-derived paid-diamond price. +type DiamondRechargeConfig struct { + Currency string `json:"currency"` + GoldPerPaidDiamond int64 `json:"gold_per_paid_diamond"` + DiamondsPerPaidDiamond int64 `json:"diamonds_per_paid_diamond"` } // StartPackID is fixed for the server by its first successful initialization. @@ -31,7 +44,9 @@ type GachaConfig struct { IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"` } -func Default() Config { return Config{SchemaVersion: 1, Story: StoryConfig{StartPackID: 21}} } +func Default() Config { + return Config{SchemaVersion: 1, Story: StoryConfig{StartPackID: 21}, Purchases: PurchasesConfig{DiamondRecharge: DiamondRechargeConfig{Currency: "free", GoldPerPaidDiamond: 1000, DiamondsPerPaidDiamond: 1}}} +} // Load reads one explicit path. Older installations without the file retain // the default rules; an existing invalid file must never silently fall back. @@ -97,6 +112,15 @@ func (c Config) Validate() error { if c.Story.StartPackID != 1 && c.Story.StartPackID != 21 { return fmt.Errorf("story.start_pack_id must be 1 or 21, got %d", c.Story.StartPackID) } + r := c.Purchases.DiamondRecharge + switch r.Currency { + case "free", "gold", "diamonds", "ban", "": + default: + return fmt.Errorf("purchases.diamond_recharge.currency must be free, gold, diamonds, ban, or an empty string") + } + if r.GoldPerPaidDiamond < 1 || r.GoldPerPaidDiamond > 1_000_000_000 || r.DiamondsPerPaidDiamond < 1 || r.DiamondsPerPaidDiamond > 1_000_000_000 { + return fmt.Errorf("purchases.diamond_recharge conversion rates must be integers between 1 and 1000000000") + } return nil } diff --git a/go/internal/server/gameconfig/purchases_test.go b/go/internal/server/gameconfig/purchases_test.go new file mode 100644 index 0000000..ed1d4fe --- /dev/null +++ b/go/internal/server/gameconfig/purchases_test.go @@ -0,0 +1,39 @@ +package gameconfig + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiamondRechargeConfiguration(t *testing.T) { + for _, tc := range []struct { + text, currency string + invalid bool + }{ + {`{"schema_version":1}`, "free", false}, + {`{"schema_version":1,"purchases":{}}`, "free", false}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":""}}}`, "", false}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"ban"}}}`, "ban", false}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"gold","gold_per_paid_diamond":10}}}`, "gold", false}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"diamonds","diamonds_per_paid_diamond":2}}}`, "diamonds", false}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"paid_diamonds"}}}`, "", true}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":null}}}`, "", true}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"gold_per_paid_diamond":0}}}`, "", true}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"gold_per_paid_diamond":1000000001}}}`, "", true}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"diamonds_per_paid_diamond":1.1}}}`, "", true}, + {`{"schema_version":1,"purchases":{"diamond_recharge":{"typo":1}}}`, "", true}, + } { + path := filepath.Join(t.TempDir(), FileName) + if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if (err != nil) != tc.invalid { + t.Fatalf("%s: error=%v", tc.text, err) + } + if err == nil && cfg.Purchases.DiamondRecharge.Currency != tc.currency { + t.Fatalf("%s: %+v", tc.text, cfg.Purchases) + } + } +} diff --git a/go/internal/server/gamedata/battle.go b/go/internal/server/gamedata/battle.go index c29db66..b519764 100644 --- a/go/internal/server/gamedata/battle.go +++ b/go/internal/server/gamedata/battle.go @@ -1,11 +1,8 @@ package gamedata import ( - "database/sql" "encoding/binary" "fmt" - "os" - "path/filepath" "bd2server/internal/server/wire" _ "modernc.org/sqlite" @@ -38,24 +35,11 @@ func BattleRewards(root, version string, packID, monsterID int) ([]BattleReward, } func readBattleRewards(root, version string, packID, deckID, monsterID int) ([]BattleReward, error) { - plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID)) + db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID)) if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-battle-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "pack.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() if monsterID != 0 { var monster []byte if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&monster); err != nil { diff --git a/go/internal/server/gamedata/battle_difficulty.go b/go/internal/server/gamedata/battle_difficulty.go index 1e3c027..a28ab1d 100644 --- a/go/internal/server/gamedata/battle_difficulty.go +++ b/go/internal/server/gamedata/battle_difficulty.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" ) // BattleDeckForDifficulty mirrors QuicklyQuestHelper.GetBattleDeckIdByDifficulty @@ -23,25 +21,7 @@ func openPackDatabase(root, version string, packID int) (*sql.DB, func(), error) if packID <= 0 { return nil, nil, fmt.Errorf("gamedata: invalid battle pack %d", packID) } - plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID)) - if err != nil { - return nil, nil, err - } - dir, err := os.MkdirTemp("", "bd2-difficulty-") - if err != nil { - return nil, nil, err - } - path := filepath.Join(dir, "pack.db") - if err := os.WriteFile(path, plain, 0600); err != nil { - os.RemoveAll(dir) - return nil, nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - os.RemoveAll(dir) - return nil, nil, err - } - return db, func() { db.Close(); os.RemoveAll(dir) }, nil + return OpenDatabase(root, version, fmt.Sprintf("pack%d", packID)) } func battleDeckForDifficultyFromDB(db *sql.DB, deckID, difficulty uint64) (uint64, error) { diff --git a/go/internal/server/gamedata/battle_phase.go b/go/internal/server/gamedata/battle_phase.go index 1e6441c..ac4ec2c 100644 --- a/go/internal/server/gamedata/battle_phase.go +++ b/go/internal/server/gamedata/battle_phase.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" "bd2server/internal/server/wire" ) @@ -19,24 +17,11 @@ func BattleDeckPhases(root, version string, packID int, monsterID, deckID uint64 if packID <= 0 || monsterID == 0 || deckID == 0 || monsterID > 2147483647 || deckID > 2147483647 { return nil, fmt.Errorf("gamedata: invalid phase battle pack/monster/deck %d/%d/%d", packID, monsterID, deckID) } - plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID)) + db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID)) if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-phase-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "pack.db") - if err := os.WriteFile(path, plain, 0600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return battleDeckPhasesFromDB(db, monsterID, deckID) } diff --git a/go/internal/server/gamedata/calendar_ids.go b/go/internal/server/gamedata/calendar_ids.go new file mode 100644 index 0000000..9a6972d --- /dev/null +++ b/go/internal/server/gamedata/calendar_ids.go @@ -0,0 +1,39 @@ +package gamedata + +import "fmt" + +// LoadMonsterHuntIDs reads only supported hunt identities. Historical calendar +// entries remain display metadata even after their gameplay design is removed. +func LoadMonsterHuntIDs(root, version string) (map[uint64]bool, error) { + return loadCalendarIDs(root, version, "MonsterHuntTable") +} + +// LoadCalendarPackIDs includes story and event packs, rather than only the +// arena subset returned by LoadFieldPacks. +func LoadCalendarPackIDs(root, version string) (map[uint64]bool, error) { + return loadCalendarIDs(root, version, "PackTable") +} +func loadCalendarIDs(root, version, table string) (map[uint64]bool, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + rows, e := db.Query("SELECT id FROM " + table) + if e != nil { + return nil, e + } + defer rows.Close() + ids := map[uint64]bool{} + for rows.Next() { + var id uint64 + if e = rows.Scan(&id); e != nil { + return nil, e + } + if id == 0 { + return nil, fmt.Errorf("gamedata: invalid monster hunt identity") + } + ids[id] = true + } + return ids, rows.Err() +} diff --git a/go/internal/server/gamedata/cash_entitlements.go b/go/internal/server/gamedata/cash_entitlements.go new file mode 100644 index 0000000..e254920 --- /dev/null +++ b/go/internal/server/gamedata/cash_entitlements.go @@ -0,0 +1,91 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "sort" +) + +type CashAttendanceReward struct { + ID uint64 + Reward BattleReward +} +type CashEntitlementDesign struct { + AvatarSets map[uint64]bool + TicketTypes map[uint64]uint64 + Attendance map[uint64][]CashAttendanceReward + AttendanceTypes map[uint64]uint64 +} + +func LoadCashEntitlementDesign(root, version string) (*CashEntitlementDesign, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + return loadCashEntitlementDesign(db) +} +func loadCashEntitlementDesign(db *sql.DB) (*CashEntitlementDesign, error) { + d := &CashEntitlementDesign{AvatarSets: map[uint64]bool{}, TicketTypes: map[uint64]uint64{}, Attendance: map[uint64][]CashAttendanceReward{}, AttendanceTypes: map[uint64]uint64{}} + if e := readCashMetadata(db, "AvatarSetTable", func(raw []byte) error { + v, e := cashScalars(raw, 6) + if e != nil { + return e + } + if v[0] == 0 { + return fmt.Errorf("gamedata: invalid avatar set") + } + d.AvatarSets[v[0]] = true + return nil + }); e != nil { + return nil, e + } + if e := readCashMetadata(db, "ContentTicketTable", func(raw []byte) error { + v, e := cashScalars(raw, 4, 8) + if e != nil { + return e + } + d.TicketTypes[v[0]] = v[1] + return nil + }); e != nil { + return nil, e + } + if e := readCashMetadata(db, "ContentOpenTable", func(raw []byte) error { + v, e := cashScalars(raw, 6, 2) + if e != nil { + return e + } + if d.TicketTypes[v[0]] == 2 { + d.AttendanceTypes[v[0]] = v[1] + } + return nil + }); e != nil { + return nil, e + } + if e := readCashMetadata(db, "AttendanceRewardTable", func(raw []byte) error { + v, e := cashScalars(raw, 1, 2, 5, 4, 3) + if e != nil { + return e + } + if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[4] == 0 { + return fmt.Errorf("gamedata: invalid cash attendance reward") + } + d.Attendance[v[0]] = append(d.Attendance[v[0]], CashAttendanceReward{v[1], BattleReward{Type: v[2], ID: v[3], Count: v[4]}}) + return nil + }); e != nil { + return nil, e + } + for ticket, rows := range d.Attendance { + sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) + for i, r := range rows { + if r.ID != uint64(i+1) { + return nil, fmt.Errorf("gamedata: non-contiguous cash attendance %d", ticket) + } + } + if d.TicketTypes[ticket] != 2 && d.TicketTypes[ticket] != 3 { + return nil, fmt.Errorf("gamedata: missing cash attendance ticket %d", ticket) + } + d.Attendance[ticket] = rows + } + return d, nil +} diff --git a/go/internal/server/gamedata/cash_entitlements_test.go b/go/internal/server/gamedata/cash_entitlements_test.go new file mode 100644 index 0000000..069e773 --- /dev/null +++ b/go/internal/server/gamedata/cash_entitlements_test.go @@ -0,0 +1,38 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "testing" +) + +func TestCashEntitlementDesignUsesTicketTypesAndAttendanceMapping(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, table := range []string{"AvatarSetTable", "ContentTicketTable", "ContentOpenTable", "AttendanceRewardTable"} { + if _, err = db.Exec("CREATE TABLE " + table + "(ProtoBuf BLOB)"); err != nil { + t.Fatal(err) + } + } + db.Exec("INSERT INTO AvatarSetTable VALUES(?)", wire.AppendVarint(nil, 6, 10)) + raw := wire.AppendVarint(nil, 4, 38) + raw = wire.AppendVarint(raw, 8, 2) + db.Exec("INSERT INTO ContentTicketTable VALUES(?)", raw) + raw = wire.AppendVarint(nil, 6, 38) + raw = wire.AppendVarint(raw, 2, 1) + db.Exec("INSERT INTO ContentOpenTable VALUES(?)", raw) + for _, id := range []uint64{2, 1} { + raw = wire.AppendVarint(nil, 1, 38) + raw = wire.AppendVarint(raw, 2, id) + raw = wire.AppendVarint(raw, 3, 60) + raw = wire.AppendVarint(raw, 5, 3) + db.Exec("INSERT INTO AttendanceRewardTable VALUES(?)", raw) + } + d, err := loadCashEntitlementDesign(db) + if err != nil || !d.AvatarSets[10] || d.TicketTypes[38] != 2 || d.AttendanceTypes[38] != 1 || len(d.Attendance[38]) != 2 || d.Attendance[38][0].ID != 1 { + t.Fatalf("%+v %v", d, err) + } +} diff --git a/go/internal/server/gamedata/cash_products.go b/go/internal/server/gamedata/cash_products.go new file mode 100644 index 0000000..66a767c --- /dev/null +++ b/go/internal/server/gamedata/cash_products.go @@ -0,0 +1,217 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" + + "bd2server/internal/server/wire" +) + +// CashProductKey is the official three-part identity; an SDK SKU can be shared +// by several product variants and must not replace this key. +type CashProductKey struct{ GroupID, ProductID, SaleGroup uint64 } +type CashProductDesign struct { + Key CashProductKey + GoogleSKU, AppleSKU string + PriceType, PriceID, PriceCount uint64 + RandomBoxID, BonusRandomBoxID uint64 + PurchaseLimitType, PurchaseLimitCount, TimeLimitType, BulkOrderAvailability uint64 + LocalTextID uint64 + Recharge bool + NominalPaidDiamonds uint64 +} +type CashShopDesign struct{ GroupID, ID, ProductGroupID, BulkOrderShow uint64 } +type CashPackageDesign struct{ GroupID, ID, SaleGroup, PackageType, PaidShopGroupID, PaidShopID, ContentsGroupID, ContentsSortID uint64 } +type CashCatalog struct { + Products []CashProductDesign + Shops []CashShopDesign + Packages []CashPackageDesign +} + +func LoadCashCatalog(root, version string) (*CashCatalog, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + return loadCashCatalog(db) +} +func loadCashCatalog(db *sql.DB) (*CashCatalog, error) { + c := &CashCatalog{} + rows, err := db.Query("SELECT ProtoBuf FROM CashProductTable ORDER BY groupId,id,saleGroup") + if err != nil { + return nil, err + } + for rows.Next() { + var raw []byte + if err = rows.Scan(&raw); err != nil { + rows.Close() + return nil, err + } + p, e := decodeCashProduct(raw) + if e != nil { + rows.Close() + return nil, e + } + c.Products = append(c.Products, p) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + seen := map[CashProductKey]bool{} + // Direct single paid-diamond rewards identify recharge offers. The least + // grant among variants of the same group/sale tier is the regular amount, + // so first-purchase doubling never doubles the configurable price. + nominal := map[[2]uint64]uint64{} + for i := range c.Products { + p := &c.Products[i] + if seen[p.Key] { + return nil, fmt.Errorf("gamedata: duplicate cash product %+v", p.Key) + } + seen[p.Key] = true + if p.PriceType != 1 || p.RandomBoxID == 0 { + continue + } + n, e := cashPaidDiamondReward(db, p.RandomBoxID) + if e != nil { + return nil, fmt.Errorf("gamedata: cash product %+v: %w", p.Key, e) + } + if n == 0 { + continue + } + p.Recharge = true + k := [2]uint64{p.Key.GroupID, p.Key.SaleGroup} + if nominal[k] == 0 || n < nominal[k] { + nominal[k] = n + } + } + for i := range c.Products { + p := &c.Products[i] + if p.Recharge { + p.NominalPaidDiamonds = nominal[[2]uint64{p.Key.GroupID, p.Key.SaleGroup}] + } + } + if err = readCashMetadata(db, "CashShopTable", func(raw []byte) error { + v, e := cashScalars(raw, 4, 5, 10, 2) + if e != nil { + return e + } + c.Shops = append(c.Shops, CashShopDesign{v[0], v[1], v[2], v[3]}) + return nil + }); err != nil { + return nil, err + } + if err = readCashMetadata(db, "CashPackageTable", func(raw []byte) error { + v, e := cashScalars(raw, 8, 9, 17, 11, 12, 13, 4, 7) + if e != nil { + return e + } + c.Packages = append(c.Packages, CashPackageDesign{v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]}) + return nil + }); err != nil { + return nil, err + } + return c, nil +} +func readCashMetadata(db *sql.DB, table string, visit func([]byte) error) error { + rows, err := db.Query("SELECT ProtoBuf FROM " + table) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var raw []byte + if err = rows.Scan(&raw); err != nil { + return err + } + if err = visit(raw); err != nil { + return err + } + } + return rows.Err() +} +func cashScalars(raw []byte, fields ...int) ([]uint64, error) { + out := make([]uint64, len(fields)) + for i, f := range fields { + v, e := optionalScalar(raw, f) + if e != nil { + return nil, e + } + if v > math.MaxInt32 { + return nil, fmt.Errorf("gamedata: cash field %d outside int32", f) + } + out[i] = v + } + return out, nil +} +func decodeCashProduct(raw []byte) (CashProductDesign, error) { + v, e := cashScalars(raw, 5, 6, 15, 9, 8, 7, 14, 2, 13, 12, 16, 3, 11) + if e != nil { + return CashProductDesign{}, e + } + google, _, e := wire.Bytes(raw, 4) + if e != nil { + return CashProductDesign{}, e + } + apple, _, e := wire.Bytes(raw, 1) + if e != nil { + return CashProductDesign{}, e + } + p := CashProductDesign{Key: CashProductKey{v[0], v[1], v[2]}, GoogleSKU: string(google), AppleSKU: string(apple), PriceType: v[3], PriceID: v[4], PriceCount: v[5], RandomBoxID: v[6], BonusRandomBoxID: v[7], PurchaseLimitType: v[8], PurchaseLimitCount: v[9], TimeLimitType: v[10], BulkOrderAvailability: v[11], LocalTextID: v[12]} + if p.Key.GroupID == 0 || p.Key.ProductID == 0 { + return p, fmt.Errorf("gamedata: invalid cash product key %+v", p.Key) + } + if p.PriceType == 1 && (p.GoogleSKU == "" && p.AppleSKU == "" || p.PriceCount == 0) { + return p, fmt.Errorf("gamedata: cash product lacks SKU/price %+v", p.Key) + } + return p, nil +} + +// Mixed packages, random selection, nested boxes and non-diamond leaves are +// ordinary goods. Only the audited pure direct paid-diamond shape qualifies. +func cashPaidDiamondReward(db *sql.DB, box uint64) (uint64, error) { + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&raw); err != nil { + return 0, err + } + gid, e := optionalScalar(raw, 9) + if e != nil { + return 0, e + } + if gid == 0 { + return 0, nil + } + if e = db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", gid).Scan(&raw); e != nil { + return 0, e + } + drop, e := optionalScalar(raw, 2) + if e != nil { + return 0, e + } + types, e := packedInts(raw, 6) + if e != nil { + return 0, e + } + ids, e := packedInts(raw, 5) + if e != nil { + return 0, e + } + counts, e := packedInts(raw, 4) + if e != nil { + return 0, e + } + ratios, e := packedInts(raw, 8) + if e != nil { + return 0, e + } + if drop != 1 || len(types) != 1 || len(ids) != 1 || len(counts) != 1 || len(ratios) != 1 || types[0] != 2 || ids[0] != 0 || ratios[0] != 100 { + return 0, nil + } + if counts[0] == 0 || counts[0] > math.MaxInt32 { + return 0, fmt.Errorf("gamedata: invalid paid-diamond recharge amount") + } + return counts[0], nil +} diff --git a/go/internal/server/gamedata/cash_products_test.go b/go/internal/server/gamedata/cash_products_test.go new file mode 100644 index 0000000..e9af6ff --- /dev/null +++ b/go/internal/server/gamedata/cash_products_test.go @@ -0,0 +1,77 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "testing" +) + +func TestCashCatalogPreservesKeysAliasesAndLimits(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.SetMaxOpenConns(1) + for _, q := range []string{"CREATE TABLE CashProductTable(groupId INTEGER,id INTEGER,saleGroup INTEGER,ProtoBuf BLOB)", "CREATE TABLE RandomBoxTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CashShopTable(ProtoBuf BLOB)", "CREATE TABLE CashPackageTable(ProtoBuf BLOB)"} { + if _, err = db.Exec(q); err != nil { + t.Fatal(err) + } + } + for i, count := range []uint64{200, 100} { + raw := wire.AppendVarint(nil, 2, 1) + raw = wire.AppendVarint(raw, 6, 2) + raw = wire.AppendVarint(raw, 5, 0) + raw = wire.AppendVarint(raw, 4, count) + raw = wire.AppendVarint(raw, 8, 100) + if _, err = db.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", i+1, raw); err != nil { + t.Fatal(err) + } + if _, err = db.Exec("INSERT INTO RandomBoxTable VALUES(?,?)", i+1, wire.AppendVarint(nil, 9, uint64(i+1))); err != nil { + t.Fatal(err) + } + raw = wire.AppendString(nil, 4, "shared-sku") + raw = wire.AppendString(raw, 1, "shared-ios") + for _, f := range []struct { + n int + v uint64 + }{{5, 99}, {6, uint64(i + 1)}, {15, 7}, {9, 1}, {7, 2200}, {14, uint64(i + 1)}, {2, 333}, {12, 1}, {13, 3}, {16, 2}, {3, 1}} { + raw = wire.AppendVarint(raw, f.n, f.v) + } + if _, err = db.Exec("INSERT INTO CashProductTable VALUES(99,?,7,?)", i+1, raw); err != nil { + t.Fatal(err) + } + } + shop := wire.AppendVarint(nil, 4, 8) + shop = wire.AppendVarint(shop, 5, 1) + shop = wire.AppendVarint(shop, 10, 99) + if _, err = db.Exec("INSERT INTO CashShopTable VALUES(?)", shop); err != nil { + t.Fatal(err) + } + packageRaw := wire.AppendVarint(nil, 8, 99) + packageRaw = wire.AppendVarint(packageRaw, 9, 1) + packageRaw = wire.AppendVarint(packageRaw, 17, 7) + packageRaw = wire.AppendVarint(packageRaw, 11, 12) + if _, err = db.Exec("INSERT INTO CashPackageTable VALUES(?)", packageRaw); err != nil { + t.Fatal(err) + } + c, err := loadCashCatalog(db) + if err != nil { + t.Fatal(err) + } + if len(c.Products) != 2 || len(c.Shops) != 1 || len(c.Packages) != 1 { + t.Fatalf("incomplete %+v", c) + } + for _, p := range c.Products { + if !p.Recharge || p.NominalPaidDiamonds != 100 || p.GoogleSKU != "shared-sku" || p.AppleSKU != "shared-ios" || p.BonusRandomBoxID != 333 || p.PurchaseLimitType != 3 || p.TimeLimitType != 2 { + t.Fatalf("lost metadata %+v", p) + } + } +} +func TestDecodeCashProductRejectsMalformedMonetaryFields(t *testing.T) { + for _, raw := range [][]byte{{255}, wire.AppendVarint(nil, 5, 1), wire.AppendVarint(wire.AppendVarint(nil, 5, 1), 6, 1<<40)} { + if _, err := decodeCashProduct(raw); err == nil { + t.Fatalf("accepted %x", raw) + } + } +} diff --git a/go/internal/server/gamedata/cash_rewards.go b/go/internal/server/gamedata/cash_rewards.go new file mode 100644 index 0000000..73ff968 --- /dev/null +++ b/go/internal/server/gamedata/cash_rewards.go @@ -0,0 +1,103 @@ +package gamedata + +import ( + "fmt" + "math" +) + +// CashRewardResolver gives cash combination groups their storefront meaning: +// ratio 1 and ratio 100 both describe guaranteed components. Weighted groups +// keep the shared reward graph sampler, and manually opened boxes stay items. +type CashRewardResolver struct { + graph *RewardGraph + boxes map[uint64]uint64 + direct map[uint64]bool + groups map[uint64][]byte +} + +func LoadCashRewardResolver(root, version string, shared ...*RewardGraph) (*CashRewardResolver, error) { + var graph *RewardGraph + if len(shared) > 0 { + graph = shared[0] + } + if graph == nil { + var err error + graph, err = LoadRewardGraph(root, version) + if err != nil { + return nil, err + } + } + // The shared immutable design maps are initialized completely at startup. + return &CashRewardResolver{graph: graph, boxes: graph.boxes, direct: graph.direct, groups: graph.groups}, nil +} +func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) { + budget := uint64(100000) + visiting := map[uint64]bool{} + var out []BattleReward + var emit func(BattleReward, bool) error + emit = func(r BattleReward, force bool) error { + if budget == 0 || r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 0 { + return fmt.Errorf("gamedata: invalid cash reward/budget") + } + budget-- + if r.Type != 9 || !force && !c.direct[r.ID] { + out = append(out, r) + return nil + } + if visiting[r.ID] || len(visiting) >= 32 { + return fmt.Errorf("gamedata: cash reward cycle") + } + gid, ok := c.boxes[r.ID] + if !ok { + return fmt.Errorf("gamedata: unknown cash random box %d", r.ID) + } + raw, ok := c.groups[gid] + if !ok { + return fmt.Errorf("gamedata: unknown cash reward group %d", gid) + } + drop, err := optionalScalar(raw, 2) + if err != nil { + return err + } + if drop == 0 { + selected, err := c.graph.Resolve([]BattleReward{r}) + if err != nil { + return err + } + out = append(out, selected...) + return nil + } + if drop != 1 { + return fmt.Errorf("gamedata: unsupported cash reward mode %d", drop) + } + children, err := eventGameRewards(raw, 6, 5, 4) + if err != nil || len(children) == 0 { + return fmt.Errorf("gamedata: malformed cash combination") + } + weights, err := packedInts(raw, 8) + if err != nil || len(weights) != len(children) { + return fmt.Errorf("gamedata: malformed cash combination ratios") + } + visiting[r.ID] = true + defer delete(visiting, r.ID) + for i, child := range children { + if weights[i] != 1 && weights[i] != 100 { + return fmt.Errorf("gamedata: cash combination ratio %d is not guaranteed", weights[i]) + } + if child.Count > math.MaxInt32/r.Count { + return fmt.Errorf("gamedata: cash reward overflow") + } + child.Count *= r.Count + if err = emit(child, false); err != nil { + return err + } + } + return nil + } + for _, r := range rewards { + if err := emit(r, true); err != nil { + return nil, err + } + } + return out, nil +} diff --git a/go/internal/server/gamedata/cash_rewards_test.go b/go/internal/server/gamedata/cash_rewards_test.go new file mode 100644 index 0000000..c59f226 --- /dev/null +++ b/go/internal/server/gamedata/cash_rewards_test.go @@ -0,0 +1,41 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "testing" +) + +func TestCashCombinationsGuaranteeOneAndHundredPreserveManualBoxes(t *testing.T) { + raw := wire.AppendVarint(nil, 2, 1) + for _, f := range []struct { + n int + v uint64 + }{{6, 3}, {6, 8}, {5, 0}, {5, 1000}, {4, 60}, {4, 2}, {8, 1}, {8, 100}} { + raw = wire.AppendVarint(raw, f.n, f.v) + } + c := &CashRewardResolver{boxes: map[uint64]uint64{1: 10}, direct: map[uint64]bool{1: true}, groups: map[uint64][]byte{10: raw}} + got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 2}, {Type: 8, ID: 2, Count: 1}}) + if err != nil || len(got) != 3 || got[0].Type != 3 || got[0].Count != 120 || got[1].Count != 4 || got[2].ID != 2 || got[2].Type != 8 { + t.Fatalf("cash rewards %+v %v", got, err) + } + raw = wire.AppendVarint(raw, 8, 35) + c.groups[10] = raw + if _, err = c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 1}}); err == nil { + t.Fatal("malformed ratio accepted") + } +} + +func TestLoginPassTopLevelWrapperExpandsAndNestedManualGiftStaysOwned(t *testing.T) { + raw := wire.AppendVarint(nil, 2, 1) + for _, f := range []struct { + n int + v uint64 + }{{6, 3}, {6, 9}, {5, 0}, {5, 200}, {4, 125}, {4, 1}, {8, 1}, {8, 100}} { + raw = wire.AppendVarint(raw, f.n, f.v) + } + c := &CashRewardResolver{boxes: map[uint64]uint64{100: 1000, 200: 2000}, direct: map[uint64]bool{}, groups: map[uint64][]byte{1000: raw}} + got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 100, Count: 1}}) + if err != nil || len(got) != 2 || got[0].Type != 3 || got[0].Count != 125 || got[1].Type != 9 || got[1].ID != 200 { + t.Fatalf("login pass wrapper=%+v err=%v", got, err) + } +} diff --git a/go/internal/server/gamedata/char_awake.go b/go/internal/server/gamedata/char_awake.go index 4e6670c..f28632a 100644 --- a/go/internal/server/gamedata/char_awake.go +++ b/go/internal/server/gamedata/char_awake.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "math" - "os" - "path/filepath" ) // CharAwakeCost is one exact CharAwakeGrowthTable material row. Type 4 is @@ -49,24 +47,11 @@ type CharImprintTarget struct { } func LoadCharAwakeDesign(root, version string) (*CharAwakeDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-char-awake-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadCharAwakeDesign(db) } @@ -199,13 +184,28 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { if err != nil { return nil, err } + type stageRow struct { + id uint64 + proto []byte + } + var sourceRows []stageRow for rows.Next() { - var id uint64 - var proto []byte - if err := rows.Scan(&id, &proto); err != nil { + var row stageRow + if err := rows.Scan(&row.id, &row.proto); err != nil { rows.Close() return nil, err } + sourceRows = append(sourceRows, row) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for _, row := range sourceRows { + id, proto := row.id, row.proto growthID, _ := packedInts(proto, 1) grade, _ := packedInts(proto, 9) growthGrade, _ := packedInts(proto, 10) diff --git a/go/internal/server/gamedata/clear_packages.go b/go/internal/server/gamedata/clear_packages.go new file mode 100644 index 0000000..4694516 --- /dev/null +++ b/go/internal/server/gamedata/clear_packages.go @@ -0,0 +1,56 @@ +package gamedata + +import "fmt" + +// ClearPackageRewardDesign matches ClearPackTable and ClearEvilCastleTable. +// Kind is the wire enum: zero story pack, one evil-castle tower. +type ClearPackageRewardDesign struct{ Kind, GroupID, TicketID, TargetID, Level, RandomBoxID, Type uint64 } +type ClearPackageCatalog struct{ Rewards []ClearPackageRewardDesign } + +func LoadClearPackageCatalog(root, version string) (*ClearPackageCatalog, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + c := &ClearPackageCatalog{} + for kind, table := range []string{"ClearPackTable", "ClearEvilCastleTable"} { + rows, err := db.Query("SELECT ProtoBuf FROM " + table) + if err != nil { + return nil, err + } + for rows.Next() { + var raw []byte + if err = rows.Scan(&raw); err != nil { + rows.Close() + return nil, err + } + v, err := cashScalars(raw, 1, 2, 3, 4, 5, 6) + if err != nil { + rows.Close() + return nil, err + } + r := ClearPackageRewardDesign{Kind: uint64(kind), TicketID: v[0], GroupID: v[1], Type: v[5]} + if kind == 0 { + r.TargetID = v[2] + r.Level = v[3] + r.RandomBoxID = v[4] + } else { + r.RandomBoxID = v[2] + r.Level = v[3] + r.TargetID = v[4] + } + if r.GroupID == 0 || r.TicketID == 0 || r.TargetID == 0 || r.RandomBoxID == 0 || r.Type > 1 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid clear-package reward") + } + c.Rewards = append(c.Rewards, r) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + } + return c, nil +} diff --git a/go/internal/server/gamedata/costume_potential.go b/go/internal/server/gamedata/costume_potential.go index a61b4d8..f23949d 100644 --- a/go/internal/server/gamedata/costume_potential.go +++ b/go/internal/server/gamedata/costume_potential.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "math" - "os" - "path/filepath" ) type CostumePotentialCost struct{ Type, ID, Count uint64 } @@ -28,24 +26,11 @@ type CostumePotentialDesign struct { } func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-costume-potential-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadCostumePotentialDesign(db) } diff --git a/go/internal/server/gamedata/database_cache.go b/go/internal/server/gamedata/database_cache.go new file mode 100644 index 0000000..e9285cd --- /dev/null +++ b/go/internal/server/gamedata/database_cache.go @@ -0,0 +1,310 @@ +package gamedata + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + _ "modernc.org/sqlite" +) + +const DefaultDatabaseCacheBytes int64 = 512 << 20 + +type databaseCacheKey struct { + Path, Version, Logical string + Size, Modified int64 +} +type databaseCacheEntry struct { + db *sql.DB + size int64 + refs int + used uint64 + ready chan struct{} + err error + retired bool +} + +// DatabaseCache owns read-only SQLite connections. A lease must be released +// after closing all rows; callers must never Close the returned shared DB. +// The budget bounds retained SQLite images, excluding transient decrypt buffers +// and SQLite page-cache overhead. Active leases may temporarily exceed it. +type DatabaseCache struct { + mu sync.Mutex + loadMu sync.Mutex + entries map[databaseCacheKey]*databaseCacheEntry + budget, bytes int64 + clock uint64 + closed bool + loader func(string, string, string) ([]byte, error) +} + +func NewDatabaseCache(maxBytes int64) *DatabaseCache { + if maxBytes <= 0 { + maxBytes = DefaultDatabaseCacheBytes + } + return &DatabaseCache{budget: maxBytes, entries: map[databaseCacheKey]*databaseCacheEntry{}, loader: ReadDatabase} +} + +var sharedDatabaseCache = NewDatabaseCache(DefaultDatabaseCacheBytes) +var sharedDatabaseCacheMu sync.RWMutex +var databaseMemoryID atomic.Uint64 + +func databaseKey(root, version, logical string) (databaseCacheKey, error) { + if _, err := DatabaseName(logical); err != nil { + return databaseCacheKey{}, err + } + if version == "" || version == "." || version == ".." || strings.ContainsAny(version, `/\`) { + return databaseCacheKey{}, fmt.Errorf("gamedata: invalid database version") + } + abs, err := filepath.Abs(filepath.Clean(root)) + if err != nil { + return databaseCacheKey{}, err + } + abs, err = filepath.EvalSymlinks(abs) + if err != nil { + return databaseCacheKey{}, err + } + archive := filepath.Join(abs, version, "release", ArchiveName) + stat, err := os.Stat(archive) + if err != nil { + return databaseCacheKey{}, err + } + path := archive + if runtime.GOOS == "windows" { + path = strings.ToLower(path) + } + return databaseCacheKey{Path: path, Version: version, Logical: logical, Size: stat.Size(), Modified: stat.ModTime().UnixNano()}, nil +} + +func memoryDatabase(plain []byte) (*sql.DB, error) { + db, err := sql.Open("sqlite", fmt.Sprintf("file:bd2-gamedata-%d?mode=memory&cache=private", databaseMemoryID.Add(1))) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + conn, err := db.Conn(context.Background()) + if err != nil { + db.Close() + return nil, err + } + err = conn.Raw(func(driver any) error { + d, ok := driver.(interface{ Deserialize([]byte) error }) + if !ok { + return fmt.Errorf("gamedata: SQLite driver lacks Deserialize") + } + return d.Deserialize(plain) + }) + if err == nil { + _, err = conn.ExecContext(context.Background(), "PRAGMA query_only=ON") + } + if err == nil { + _, err = conn.ExecContext(context.Background(), "PRAGMA cache_size=-4096") + } + if err == nil { + var n int + err = conn.QueryRowContext(context.Background(), "SELECT count(*) FROM sqlite_schema").Scan(&n) + } + closeErr := conn.Close() + if err == nil { + err = closeErr + } + if err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func (c *DatabaseCache) Open(root, version, logical string) (*sql.DB, func(), error) { + key, err := databaseKey(root, version, logical) + if err != nil { + return nil, nil, err + } + for { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil, nil, fmt.Errorf("gamedata: database cache closed") + } + if e, ok := c.entries[key]; ok { + if e.ready != nil { + ready := e.ready + c.mu.Unlock() + <-ready + if e.err != nil { + return nil, nil, e.err + } + continue + } + e.refs++ + c.clock++ + e.used = c.clock + c.mu.Unlock() + return e.db, c.release(e), nil + } + e := &databaseCacheEntry{ready: make(chan struct{})} + c.entries[key] = e + c.mu.Unlock() + // Serial loading limits peak encrypted + plaintext + SQLite image memory. + c.loadMu.Lock() + loadStarted := time.Now() + plain, loadErr := c.loader(root, version, logical) + var db *sql.DB + size := int64(len(plain)) + if loadErr == nil { + current, e2 := databaseKey(root, version, logical) + if e2 != nil { + loadErr = e2 + } else if current != key { + loadErr = fmt.Errorf("gamedata: archive changed during database load") + } else { + db, loadErr = memoryDatabase(plain) + } + } + plain = nil + c.loadMu.Unlock() + if loadErr == nil { + slog.Debug("GameData database cached", "logical", logical, "version", version, "image_bytes", size, "load_ms", float64(time.Since(loadStarted).Microseconds())/1000) + } + c.mu.Lock() + e.err = loadErr + ready := e.ready + e.ready = nil + if loadErr != nil || c.closed { + delete(c.entries, key) + if db != nil { + _ = db.Close() + } + if loadErr == nil { + e.err = fmt.Errorf("gamedata: database cache closed") + } + close(ready) + c.mu.Unlock() + return nil, nil, e.err + } + e.db = db + e.size = size + e.refs = 1 + c.clock++ + e.used = c.clock + c.bytes += size + // Retire earlier versions of a replaced archive. Existing leases remain + // valid until released, but cannot become a new request's database. + for oldKey, old := range c.entries { + if oldKey != key && oldKey.Path == key.Path && oldKey.Logical == key.Logical && old.ready == nil { + delete(c.entries, oldKey) + old.retired = true + if old.refs == 0 { + c.bytes -= old.size + _ = old.db.Close() + } + } + } + c.evictLocked() + close(ready) + c.mu.Unlock() + return db, c.release(e), nil + } +} +func (c *DatabaseCache) release(e *databaseCacheEntry) func() { + var once sync.Once + return func() { + once.Do(func() { + c.mu.Lock() + defer c.mu.Unlock() + e.refs-- + if e.refs == 0 && e.retired { + c.bytes -= e.size + _ = e.db.Close() + } + c.evictLocked() + }) + } +} +func (c *DatabaseCache) evictLocked() { + for c.bytes > c.budget { + var key databaseCacheKey + var oldest *databaseCacheEntry + for k, e := range c.entries { + if e.ready == nil && e.refs == 0 && (oldest == nil || e.used < oldest.used) { + key = k + oldest = e + } + } + if oldest == nil { + return + } + delete(c.entries, key) + c.bytes -= oldest.size + oldest.retired = true + _ = oldest.db.Close() + } +} + +// Close retires all images; active queries finish before their final release. +func (c *DatabaseCache) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + c.closed = true + var first error + for key, e := range c.entries { + if e.ready != nil { + continue + } + delete(c.entries, key) + e.retired = true + if e.refs == 0 { + c.bytes -= e.size + if err := e.db.Close(); err != nil && first == nil { + first = err + } + } + } + return first +} +func (c *DatabaseCache) WithDatabase(root, version, logical string, query func(*sql.DB) error) error { + if query == nil { + return fmt.Errorf("gamedata: missing query callback") + } + db, release, err := c.Open(root, version, logical) + if err != nil { + return err + } + defer release() + return query(db) +} +func OpenDatabase(root, version, logical string) (*sql.DB, func(), error) { + sharedDatabaseCacheMu.RLock() + defer sharedDatabaseCacheMu.RUnlock() + return sharedDatabaseCache.Open(root, version, logical) +} +func WithDatabase(root, version, logical string, query func(*sql.DB) error) error { + if query == nil { + return fmt.Errorf("gamedata: missing query callback") + } + db, release, err := OpenDatabase(root, version, logical) + if err != nil { + return err + } + defer release() + return query(db) +} + +// CloseDatabaseCache retires the current generation. Active leases finish +// normally; the next OpenDatabase starts with a fresh empty cache. +func CloseDatabaseCache() error { + sharedDatabaseCacheMu.Lock() + old := sharedDatabaseCache + sharedDatabaseCache = NewDatabaseCache(DefaultDatabaseCacheBytes) + sharedDatabaseCacheMu.Unlock() + return old.Close() +} diff --git a/go/internal/server/gamedata/database_cache_test.go b/go/internal/server/gamedata/database_cache_test.go new file mode 100644 index 0000000..5f03b69 --- /dev/null +++ b/go/internal/server/gamedata/database_cache_test.go @@ -0,0 +1,322 @@ +package gamedata + +import ( + "archive/zip" + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" +) + +func cachePlain(t *testing.T, value int) []byte { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec(fmt.Sprintf("CREATE TABLE test(value INTEGER);INSERT INTO test VALUES(%d)", value)); err != nil { + t.Fatal(err) + } + conn, err := db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + var raw []byte + err = conn.Raw(func(v any) error { + var e error + raw, e = v.(interface{ Serialize() ([]byte, error) }).Serialize() + return e + }) + if err != nil { + t.Fatal(err) + } + return raw +} +func cacheArchive(t *testing.T, root string, marker byte) { + t.Helper() + dir := filepath.Join(root, "v1", "release") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, ArchiveName) + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + w := zip.NewWriter(f) + for _, logical := range []string{"common", "pack1", "pack2"} { + name, _ := DatabaseName(logical) + entry, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err = entry.Write([]byte{marker}); err != nil { + t.Fatal(err) + } + } + if err = w.Close(); err != nil { + t.Fatal(err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } +} +func TestDatabaseCacheSingleFlightReadOnlyAndLifetime(t *testing.T) { + root := t.TempDir() + cacheArchive(t, root, 1) + plain := cachePlain(t, 7) + c := NewDatabaseCache(1 << 20) + defer c.Close() + var loads atomic.Int32 + c.loader = func(string, string, string) ([]byte, error) { loads.Add(1); return plain, nil } + var wg sync.WaitGroup + for i := 0; i < 12; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := c.WithDatabase(root, "v1", "common", func(db *sql.DB) error { + var n int + if err := db.QueryRow("SELECT value FROM test").Scan(&n); err != nil { + return err + } + if n != 7 { + return fmt.Errorf("wrong value") + } + if _, err := db.Exec("INSERT INTO test VALUES(8)"); err == nil { + return fmt.Errorf("write accepted") + } + return nil + }) + if err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if loads.Load() != 1 { + t.Fatal("duplicate decrypt", loads.Load()) + } + db, release, err := c.Open(root, "v1", "common") + if err != nil { + t.Fatal(err) + } + if err = c.Close(); err != nil { + t.Fatal(err) + } + var n int + if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil { + t.Fatal("active lease closed", err) + } + release() + release() + if err = db.Ping(); err == nil { + t.Fatal("released retired database alive") + } +} +func TestDatabaseCacheFailureRetryReplacementAndEviction(t *testing.T) { + root := t.TempDir() + cacheArchive(t, root, 1) + plain := cachePlain(t, 1) + c := NewDatabaseCache(int64(len(plain))) + defer c.Close() + var loads int + c.loader = func(string, string, string) ([]byte, error) { + loads++ + if loads == 1 { + return nil, fmt.Errorf("failure") + } + return plain, nil + } + if _, _, err := c.Open(root, "v1", "common"); err == nil { + t.Fatal("failure missing") + } + first, release, err := c.Open(root, "v1", "common") + if err != nil { + t.Fatal(err) + } + release() + second, done, err := c.Open(filepath.Join(root, "."), "v1", "common") + if err != nil || first != second || loads != 2 { + t.Fatal("canonical hit failed", err, loads) + } + done() + _, done, err = c.Open(root, "v1", "pack1") + if err != nil { + t.Fatal(err) + } + done() + if err = first.Ping(); err == nil { + t.Fatal("LRU not evicted") + } + old, oldRelease, err := c.Open(root, "v1", "pack1") + if err != nil { + t.Fatal(err) + } + cacheArchive(t, root, 2) + plain = cachePlain(t, 9) + current, currentRelease, err := c.Open(root, "v1", "pack1") + if err != nil { + t.Fatal(err) + } + if old == current { + t.Fatal("replacement reused stale image") + } + var n int + if err = current.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 9 { + t.Fatal("replacement wrong", n, err) + } + oldRelease() + currentRelease() +} + +func TestDatabaseCacheMutationDuringLoadFailsAndDoesNotPoison(t *testing.T) { + root := t.TempDir() + cacheArchive(t, root, 1) + plain := cachePlain(t, 3) + c := NewDatabaseCache(1 << 20) + defer c.Close() + calls := 0 + c.loader = func(string, string, string) ([]byte, error) { + calls++ + if calls == 1 { + cacheArchive(t, root, 2) + } + return plain, nil + } + if _, _, err := c.Open(root, "v1", "common"); err == nil { + t.Fatal("replaced archive was cached") + } + db, release, err := c.Open(root, "v1", "common") + if err != nil { + t.Fatal(err) + } + defer release() + var n int + if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 3 || calls != 2 { + t.Fatal(n, calls, err) + } +} + +func TestSharedDatabaseCacheCloseStartsFreshGeneration(t *testing.T) { + root := t.TempDir() + cacheArchive(t, root, 1) + plain := cachePlain(t, 11) + sharedDatabaseCacheMu.Lock() + previous := sharedDatabaseCache + current := NewDatabaseCache(1 << 20) + current.loader = func(string, string, string) ([]byte, error) { return plain, nil } + sharedDatabaseCache = current + sharedDatabaseCacheMu.Unlock() + t.Cleanup(func() { + _ = CloseDatabaseCache() + sharedDatabaseCacheMu.Lock() + empty := sharedDatabaseCache + sharedDatabaseCache = previous + sharedDatabaseCacheMu.Unlock() + _ = empty.Close() + }) + old, releaseOld, err := OpenDatabase(root, "v1", "common") + if err != nil { + t.Fatal(err) + } + defer releaseOld() + if err = CloseDatabaseCache(); err != nil { + t.Fatal(err) + } + sharedDatabaseCacheMu.Lock() + fresh := sharedDatabaseCache + fresh.loader = func(string, string, string) ([]byte, error) { return plain, nil } + sharedDatabaseCacheMu.Unlock() + if fresh == current { + t.Fatal("global generation not replaced") + } + var n int + if err = old.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 { + t.Fatal("retired active lease unavailable", n, err) + } + newDB, releaseNew, err := OpenDatabase(root, "v1", "common") + if err != nil { + t.Fatal(err) + } + defer releaseNew() + if newDB == old { + t.Fatal("new generation reused old handle") + } + if err = newDB.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 { + t.Fatal(n, err) + } + releaseOld() + if err = old.Ping(); err == nil { + t.Fatal("retired released generation retained database") + } +} + +func TestDatabaseCacheRootAndVersionIsolation(t *testing.T) { + rootA, rootB := t.TempDir(), t.TempDir() + cacheArchive(t, rootA, 1) + cacheArchive(t, rootB, 1) + archive, err := os.ReadFile(filepath.Join(rootA, "v1", "release", ArchiveName)) + if err != nil { + t.Fatal(err) + } + v2dir := filepath.Join(rootA, "v2", "release") + if err = os.MkdirAll(v2dir, 0700); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(v2dir, ArchiveName), archive, 0600); err != nil { + t.Fatal(err) + } + plainA, plainB, plainV2 := cachePlain(t, 1), cachePlain(t, 2), cachePlain(t, 3) + c := NewDatabaseCache(1 << 20) + defer c.Close() + loads := 0 + c.loader = func(root, version, _ string) ([]byte, error) { + loads++ + if version == "v2" { + return plainV2, nil + } + if root == rootB { + return plainB, nil + } + return plainA, nil + } + type item struct { + root, version string + want int + } + handles := map[*sql.DB]bool{} + for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} { + db, release, err := c.Open(input.root, input.version, "common") + if err != nil { + t.Fatal(err) + } + var value int + if err = db.QueryRow("SELECT value FROM test").Scan(&value); err != nil || value != input.want { + release() + t.Fatal("root/version crossed", value, input.want, err) + } + if handles[db] { + release() + t.Fatal("isolated inputs shared handle") + } + handles[db] = true + release() + } + if loads != 3 { + t.Fatal("isolated inputs did not load separately", loads) + } + for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} { + if err = c.WithDatabase(input.root, input.version, "common", func(db *sql.DB) error { return db.Ping() }); err != nil { + t.Fatal(err) + } + } + if loads != 3 { + t.Fatal("isolated caches did not hit", loads) + } +} diff --git a/go/internal/server/gamedata/dispatch.go b/go/internal/server/gamedata/dispatch.go index 3623681..c33362a 100644 --- a/go/internal/server/gamedata/dispatch.go +++ b/go/internal/server/gamedata/dispatch.go @@ -187,6 +187,19 @@ func (d *DispatchDesign) Roll(count uint64, draw func(uint64) (uint64, error)) ( budget-- if g.Drop == 1 { for _, e := range g.Entries { + if e.Weight > 100 { + return fmt.Errorf("gamedata: direct dispatch percent exceeds 100") + } + v, err := draw(100) + if err != nil { + return err + } + if v >= 100 { + return fmt.Errorf("gamedata: sampler out of range") + } + if v >= e.Weight { + continue + } if err := emit(e); err != nil { return err } diff --git a/go/internal/server/gamedata/dispatch_test.go b/go/internal/server/gamedata/dispatch_test.go index 994436c..735c5be 100644 --- a/go/internal/server/gamedata/dispatch_test.go +++ b/go/internal/server/gamedata/dispatch_test.go @@ -6,7 +6,7 @@ import ( ) func TestDispatchWeightedNestedRewards(t *testing.T) { - leaf := &dispatchGroup{Drop: 1, Entries: []dispatchEntry{{Reward: BattleReward{Type: 8, ID: 44, Count: 3}}}} + leaf := &dispatchGroup{Drop: 1, Entries: []dispatchEntry{{Weight: 100, Reward: BattleReward{Type: 8, ID: 44, Count: 3}}}} weighted := &dispatchGroup{Drop: 0, Count: 2, Entries: []dispatchEntry{{Reward: BattleReward{Type: 9, Count: 1}, Weight: 90, Child: leaf}, {Reward: BattleReward{Type: 4, Count: 5}, Weight: 10}}} d := &DispatchDesign{Rewards: []BattleReward{{Type: 9, ID: 7, Count: 1}}, boxes: map[uint64]*dispatchGroup{7: weighted}} draws := 0 @@ -15,7 +15,7 @@ func TestDispatchWeightedNestedRewards(t *testing.T) { return 0, fmt.Errorf("limit %d", n) } draws++ - if draws%2 == 0 { + if draws%3 == 0 { return 95, nil } return 0, nil @@ -27,7 +27,7 @@ func TestDispatchWeightedNestedRewards(t *testing.T) { for _, v := range r { totals[v.Type] += v.Count } - if totals[8] != 6 || totals[4] != 10 || draws != 4 { + if totals[8] != 6 || totals[4] != 10 || draws != 6 { t.Fatalf("rewards %+v draws %d", r, draws) } if _, err = d.Roll(1, func(n uint64) (uint64, error) { return n, nil }); err == nil { diff --git a/go/internal/server/gamedata/equipment_collaboration.go b/go/internal/server/gamedata/equipment_collaboration.go index 340d91d..aad3e92 100644 --- a/go/internal/server/gamedata/equipment_collaboration.go +++ b/go/internal/server/gamedata/equipment_collaboration.go @@ -3,32 +3,17 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" "sort" ) // IncludeCollaborationURWeapons extends only the UR equipment ticket pool. // Limited costumes identify eligible owners; equipment IDs remain version data. func (c *EquipmentGachaCatalog) IncludeCollaborationURWeapons(root, version string) error { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return err } - dir, err := os.MkdirTemp("", "bd2-collaboration-equipment-") - if err != nil { - return err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return err - } - defer db.Close() + defer release() return c.includeCollaborationURWeapons(db) } diff --git a/go/internal/server/gamedata/equipment_craft.go b/go/internal/server/gamedata/equipment_craft.go index c6c754f..3d5fe03 100644 --- a/go/internal/server/gamedata/equipment_craft.go +++ b/go/internal/server/gamedata/equipment_craft.go @@ -61,13 +61,30 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { if err != nil { return nil, err } + type makingRow struct { + id uint64 + raw []byte + } + var makingRows []makingRow for rows.Next() { - var id uint64 - var raw []byte - if err := rows.Scan(&id, &raw); err != nil { + var row makingRow + if err := rows.Scan(&row.id, &row.raw); err != nil { rows.Close() return nil, err } + makingRows = append(makingRows, row) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + // Recipes perform nested random-box, reward and equipment queries only + // after the source result set releases the single database connection. + for _, row := range makingRows { + id, raw := row.id, row.raw counts, _ := packedInts(raw, 4) materialIDs, _ := packedInts(raw, 5) materialTypes, _ := packedInts(raw, 6) @@ -78,45 +95,32 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { if len(counts) == 0 || len(counts) != len(materialIDs) || len(counts) != len(materialTypes) || len(resultCounts) != 1 || resultCounts[0] != 1 || len(boxIDs) != 1 || len(resultTypes) != 1 || resultTypes[0] != 9 || len(talentLevels) != 1 || talentLevels[0] == 0 { - rows.Close() return nil, fmt.Errorf("gamedata: equipment making recipe %d malformed", id) } var box []byte if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", boxIDs[0]).Scan(&box); err != nil { - rows.Close() return nil, fmt.Errorf("gamedata: equipment making recipe %d random box: %w", id, err) } groups, _ := packedInts(box, 9) if len(groups) != 1 || groups[0] == 0 { - rows.Close() return nil, fmt.Errorf("gamedata: equipment making random box %d malformed", boxIDs[0]) } results, equipmentOnly, err := classifyEquipmentRewardPool(db, groups[0]) if err != nil || !equipmentOnly || len(results) == 0 { - rows.Close() return nil, fmt.Errorf("gamedata: equipment making recipe %d reward group: %w", id, err) } recipe := EquipmentCraftRecipe{ID: id, ResultCount: resultCounts[0], TalentLevel: talentLevels[0], Results: results} for i := range counts { if counts[i] == 0 || materialIDs[i] == 0 || materialTypes[i] == 0 { - rows.Close() return nil, fmt.Errorf("gamedata: equipment making recipe %d invalid material", id) } recipe.Costs = append(recipe.Costs, PromotionCost{Type: materialTypes[i], ID: materialIDs[i], Count: counts[i]}) } if err := design.loadEquipmentPool(db, results); err != nil { - rows.Close() return nil, fmt.Errorf("gamedata: equipment making recipe %d: %w", id, err) } design.Recipes[id] = recipe } - if err := rows.Err(); err != nil { - rows.Close() - return nil, err - } - if err := rows.Close(); err != nil { - return nil, err - } talents := make(map[uint64]equipmentMakingTalent) rows, err = db.Query("SELECT id,ProtoBuf FROM TalentTable") diff --git a/go/internal/server/gamedata/equipment_gacha.go b/go/internal/server/gamedata/equipment_gacha.go index 4cae8a2..a3de3bc 100644 --- a/go/internal/server/gamedata/equipment_gacha.go +++ b/go/internal/server/gamedata/equipment_gacha.go @@ -7,8 +7,6 @@ import ( "fmt" "math" "math/big" - "os" - "path/filepath" "sort" ) @@ -62,24 +60,11 @@ type EquipmentOptionChoice struct{ GroupID, ID uint64 } // LoadEquipmentGachaGroups loads only groups selected by a captured dynamic // schedule. Presence in GameData alone does not mean a banner is open. func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*EquipmentGachaCatalog, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-equipment-gacha-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{}, groups: map[uint64]EquipmentGachaGroup{}, byGacha: map[uint64]uint64{}, equipment: map[uint64]EquipmentDesign{}} for _, groupID := range groupIDs { var raw []byte @@ -146,6 +131,11 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme if err != nil { return nil, err } + type ticketCandidate struct { + id, count, reward uint64 + tickets []uint64 + } + var candidates []ticketCandidate for rows.Next() { var id uint64 var raw []byte @@ -164,7 +154,18 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme if len(count) != 1 || count[0] == 0 || len(reward) != 1 || len(tickets) == 0 || len(price) != 0 || len(kind) != 0 { continue } - pool, equipmentOnly, err := classifyEquipmentRewardPool(db, reward[0]) + candidates = append(candidates, ticketCandidate{id, count[0], reward[0], append([]uint64(nil), tickets...)}) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for _, candidate := range candidates { + id := candidate.id + pool, equipmentOnly, err := classifyEquipmentRewardPool(db, candidate.reward) if err != nil { rows.Close() return nil, fmt.Errorf("gamedata: ticket gacha %d: %w", id, err) @@ -172,7 +173,7 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme if !equipmentOnly { continue } - g := EquipmentGacha{ID: id, Count: int(count[0]), TicketIDs: append([]uint64(nil), tickets...), Pool: pool, TicketOnly: true} + g := EquipmentGacha{ID: id, Count: int(candidate.count), TicketIDs: candidate.tickets, Pool: pool, TicketOnly: true} c.Gachas[id] = g for _, item := range pool { if err := c.loadEquipmentTree(db, item); err != nil { diff --git a/go/internal/server/gamedata/equipment_option_reroll.go b/go/internal/server/gamedata/equipment_option_reroll.go index 9dfd028..2ec4932 100644 --- a/go/internal/server/gamedata/equipment_option_reroll.go +++ b/go/internal/server/gamedata/equipment_option_reroll.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "math" - "os" - "path/filepath" ) // EquipmentOptionRerollDesign is the static equipment-option refinement @@ -57,24 +55,11 @@ type EquipmentOptionRerollRoll struct { } func LoadEquipmentOptionRerollDesign(root, version string) (*EquipmentOptionRerollDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-equipment-option-reroll-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadEquipmentOptionRerollDesign(db) } diff --git a/go/internal/server/gamedata/equipment_slots.go b/go/internal/server/gamedata/equipment_slots.go index 4c92232..2408c78 100644 --- a/go/internal/server/gamedata/equipment_slots.go +++ b/go/internal/server/gamedata/equipment_slots.go @@ -3,31 +3,16 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" ) // LoadEquipmentSlots reads EquipmentTable.SlotType for every installed item. // SlotType is a proto3 enum, so an absent field is the valid first slot (0). func LoadEquipmentSlots(root, version string) (map[uint64]uint64, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-equipment-slots-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadEquipmentSlots(db) } diff --git a/go/internal/server/gamedata/equipment_smelting.go b/go/internal/server/gamedata/equipment_smelting.go index 21ace75..34194c2 100644 --- a/go/internal/server/gamedata/equipment_smelting.go +++ b/go/internal/server/gamedata/equipment_smelting.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "math" - "os" - "path/filepath" ) // EquipmentSmeltingDesign contains only static 2.35.10 GameData facts. It does @@ -37,24 +35,11 @@ type EquipmentSmeltingMileage struct { } func LoadEquipmentSmeltingDesign(root, version string) (*EquipmentSmeltingDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-equipment-smelting-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadEquipmentSmeltingDesign(db) } diff --git a/go/internal/server/gamedata/equipment_upgrade.go b/go/internal/server/gamedata/equipment_upgrade.go index b75d1ce..2940cd4 100644 --- a/go/internal/server/gamedata/equipment_upgrade.go +++ b/go/internal/server/gamedata/equipment_upgrade.go @@ -6,8 +6,6 @@ import ( "encoding/binary" "fmt" "math" - "os" - "path/filepath" ) type EquipmentUpgradeLevel struct { @@ -29,24 +27,11 @@ type EquipmentUpgradeDesign struct { } func LoadEquipmentUpgradeDesign(root, version string) (*EquipmentUpgradeDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-equipment-upgrade-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadEquipmentUpgradeDesign(db) } diff --git a/go/internal/server/gamedata/event_actions.go b/go/internal/server/gamedata/event_actions.go new file mode 100644 index 0000000..e2eca3e --- /dev/null +++ b/go/internal/server/gamedata/event_actions.go @@ -0,0 +1,150 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "fmt" + "sort" +) + +// EventActionRow preserves current-version design fields without introducing +// hard-coded event IDs. Runtime modules consume the named table and fields. +type EventActionRow struct { + Values map[int]uint64 + Rewards []Reward + EventRewards []Reward + Text map[int]string +} + +func (r EventActionRow) V(field int) uint64 { return r.Values[field] } + +type EventActionsDesign struct { + Tables map[string][]EventActionRow + SpawnRewards map[[2]uint64]Reward +} + +func (d *EventActionsDesign) Row(table string, idField int, id uint64) (EventActionRow, bool) { + for _, r := range d.Tables[table] { + if r.V(idField) == id { + return r, true + } + } + return EventActionRow{}, false +} +func LoadEventActionsDesign(root, version string) (*EventActionsDesign, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + d := &EventActionsDesign{Tables: map[string][]EventActionRow{}, SpawnRewards: map[[2]uint64]Reward{}} + for _, table := range []string{"FieldEventDefaultTable", "FieldSpawnEventTable", "FieldEventMonsterTable", "FireworksTable", "VotingEventTable", "VotingCandidateTable", "VotingRoundTable", "VotingCountRewardTable", "VotingSeasonTable", "FriendshipSpecialEpisodeTable", "NpcQuizTable", "TacticsBingoGroupTable", "TacticsBingoTable", "CafeteriaDefaultTable", "CafeteriaEventTable"} { + rows, e := db.Query("SELECT ProtoBuf FROM " + table) + if e != nil { + return nil, e + } + for rows.Next() { + var p []byte + if e = rows.Scan(&p); e != nil { + rows.Close() + return nil, e + } + r := EventActionRow{Values: map[int]uint64{}, Text: map[int]string{}} + if e = wire.Walk(p, func(f wire.Field) error { + if f.Type == 0 { + v, _, er := wire.Varint(p, f.Number) + if er != nil { + return er + } + r.Values[f.Number] = v + } else if f.Type == 2 { + r.Text[f.Number] = string(f.Value) + } + return nil + }); e != nil { + rows.Close() + return nil, e + } + var array [3]int + switch table { + case "FriendshipSpecialEpisodeTable": + array = [3]int{9, 8, 7} + case "NpcQuizTable": + array = [3]int{12, 11, 10} + } + if array[0] > 0 { + rs, e := monsterHuntRewardArrays(p, array[0], array[1], array[2]) + if e != nil { + rows.Close() + return nil, e + } + for _, x := range rs { + r.Rewards = append(r.Rewards, Reward{x.Type, x.ID, x.Count}) + } + } + if table == "FriendshipSpecialEpisodeTable" { + rs, e := monsterHuntRewardArrays(p, 4, 3, 2) + if e != nil { + rows.Close() + return nil, e + } + for _, x := range rs { + r.EventRewards = append(r.EventRewards, Reward{x.Type, x.ID, x.Count}) + } + } + if table == "FireworksTable" { + r.Rewards = []Reward{{r.V(9), r.V(8), r.V(7)}} + } + if table == "VotingCountRewardTable" { + r.Rewards = []Reward{{r.V(5), r.V(4), r.V(3)}} + } + d.Tables[table] = append(d.Tables[table], r) + } + e = rows.Err() + rows.Close() + if e != nil { + return nil, e + } + } + packs := map[uint64]bool{} + for _, r := range d.Tables["FieldEventMonsterTable"] { + packs[r.V(5)] = true + } + for pack := range packs { + pdb, closePack, e := openPackDatabase(root, version, int(pack)) + if e != nil { + return nil, e + } + for _, r := range d.Tables["FieldEventMonsterTable"] { + if r.V(5) != pack { + continue + } + var p []byte + if e = pdb.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", r.V(1)).Scan(&p); e != nil { + closePack() + return nil, fmt.Errorf("event spawn monster %d: %w", r.V(1), e) + } + t, e := optionalScalar(p, 29) + if e != nil { + closePack() + return nil, e + } + id, e := optionalScalar(p, 28) + if e != nil { + closePack() + return nil, e + } + count, e := optionalScalar(p, 27) + if e != nil { + closePack() + return nil, e + } + d.SpawnRewards[[2]uint64{pack, r.V(1)}] = Reward{t, id, count} + } + closePack() + } + for table, rows := range d.Tables { + sort.Slice(rows, func(i, j int) bool { return rows[i].V(2) < rows[j].V(2) }) + d.Tables[table] = rows + } + return d, nil +} diff --git a/go/internal/server/gamedata/event_actions_test.go b/go/internal/server/gamedata/event_actions_test.go new file mode 100644 index 0000000..dea60ea --- /dev/null +++ b/go/internal/server/gamedata/event_actions_test.go @@ -0,0 +1,24 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestInstalledEventActions23510(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + d, e := LoadEventActionsDesign(root, "20260923193640") + if e != nil { + t.Fatal(e) + } + if len(d.Tables["VotingEventTable"]) != 6 || len(d.Tables["FriendshipSpecialEpisodeTable"]) != 120 || len(d.Tables["NpcQuizTable"]) != 43 { + t.Fatal("event actions incomplete") + } + r := d.SpawnRewards[[2]uint64{3009, 3001}] + if r.Type != 4 || r.Count != 10000 { + t.Fatalf("spawn reward mismatch %+v", r) + } +} diff --git a/go/internal/server/gamedata/event_ap.go b/go/internal/server/gamedata/event_ap.go new file mode 100644 index 0000000..4c55599 --- /dev/null +++ b/go/internal/server/gamedata/event_ap.go @@ -0,0 +1,26 @@ +package gamedata + +func LoadEventAPDesign(root, version string) (map[uint64]uint64, HuntingAPDesign, error) { + d, e := LoadHuntingAPDesign(root, version) + if e != nil { + return nil, d, e + } + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, d, e + } + defer done() + var raw []byte + if e = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); e != nil { + return nil, d, e + } + caps := map[uint64]uint64{} + for typ, field := range map[uint64]int{30: 43, 32: 113} { + n, e := optionalScalar(raw, field) + if e != nil { + return nil, d, e + } + caps[typ] = n + } + return caps, d, nil +} diff --git a/go/internal/server/gamedata/event_battle_challenge.go b/go/internal/server/gamedata/event_battle_challenge.go new file mode 100644 index 0000000..4c46b06 --- /dev/null +++ b/go/internal/server/gamedata/event_battle_challenge.go @@ -0,0 +1,96 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "encoding/binary" + "fmt" +) + +type EventBattleChallenge struct { + Type, Value1, Value2 uint64 + Reward BattleReward +} +type EventBattleChallenges map[uint64][]EventBattleChallenge + +func LoadEventBattleChallenges(root, version string) (EventBattleChallenges, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + rows, e := db.Query("SELECT id,ProtoBuf FROM BattleDeckTable") + if e != nil { + return nil, e + } + defer rows.Close() + out := EventBattleChallenges{} + for rows.Next() { + var id uint64 + var raw []byte + if e = rows.Scan(&id, &raw); e != nil { + return nil, e + } + fields := map[int][]uint64{} + for _, n := range []int{6, 7, 8, 9, 10, 11} { + v, e := packedInts(raw, n) + if e != nil { + return nil, e + } + fields[n] = v + } + n := len(fields[6]) + if n == 0 { + continue + } + for _, f := range []int{7, 8, 9, 10, 11} { + if len(fields[f]) != n { + return nil, fmt.Errorf("gamedata: event challenge arrays mismatch deck%d", id) + } + } + for i, t := range fields[6] { + if t < 1 || t > 8 { + return nil, fmt.Errorf("gamedata: unsupported challenge type%d", t) + } + out[id] = append(out[id], EventBattleChallenge{Type: t, Value1: fields[7][i], Value2: fields[8][i], Reward: BattleReward{Type: fields[11][i], ID: fields[10][i], Count: fields[9][i]}}) + } + } + return out, rows.Err() +} + +// VerifySubmittedChallenges uses the client's simulation reports. Conditions +// absent from BattleEnd (turn count/used skill/team elements) are accepted as +// explicit local simulation attestations after static index/type validation. +func VerifySubmittedChallenges(req []byte, definitions []EventBattleChallenge) ([]uint64, error) { + var indexes []uint64 + seen := map[uint64]bool{} + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != 5 { + return nil + } + var v []uint64 + var e error + if f.Type == 0 { + n, k := binary.Uvarint(f.Value) + if k <= 0 { + return wire.ErrMalformed + } + v = []uint64{n} + } else if f.Type == 2 { + v, e = packedInts(wire.AppendBytes(nil, 5, f.Value), 5) + } else { + return wire.ErrMalformed + } + if e != nil { + return e + } + for _, i := range v { + if i >= uint64(len(definitions)) || seen[i] { + return fmt.Errorf("gamedata: invalid challenge index") + } + seen[i] = true + indexes = append(indexes, i) + } + return nil + }) + return indexes, e +} diff --git a/go/internal/server/gamedata/event_battle_challenge_test.go b/go/internal/server/gamedata/event_battle_challenge_test.go new file mode 100644 index 0000000..29ec770 --- /dev/null +++ b/go/internal/server/gamedata/event_battle_challenge_test.go @@ -0,0 +1,32 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "os" + "testing" +) + +func TestEventBattleChallengeInstalledSmoke(t *testing.T) { + root, version := os.Getenv("BD2_GAMEDATA_ROOT"), os.Getenv("BD2_GAMEDATA_VERSION") + if root == "" || version == "" { + t.Skip("installed design env required") + } + d, e := LoadEventBattleChallenges(root, version) + if e != nil { + t.Fatal(e) + } + if len(d) == 0 { + t.Fatal("empty challenges") + } +} +func TestChallengeIndexesPackedAndDuplicate(t *testing.T) { + definitions := []EventBattleChallenge{{Type: 8}, {Type: 2, Value1: 8}} + req := wire.AppendBytes(nil, 5, []byte{0, 1}) + out, e := VerifySubmittedChallenges(req, definitions) + if e != nil || len(out) != 2 { + t.Fatal(out, e) + } + if _, e = VerifySubmittedChallenges(wire.AppendBytes(nil, 5, []byte{0, 0}), definitions); e == nil { + t.Fatal("duplicate index") + } +} diff --git a/go/internal/server/gamedata/event_exchange.go b/go/internal/server/gamedata/event_exchange.go new file mode 100644 index 0000000..12cebac --- /dev/null +++ b/go/internal/server/gamedata/event_exchange.go @@ -0,0 +1,101 @@ +package gamedata + +import "fmt" + +type EventExchangeEntry struct { + ID, Page, KeyType, Ratio, LimitedRatio, SetCount uint64 + Reward Reward +} +type EventExchangeGroup struct { + ID, StartPage, EndPage, FreeCount, FreeType, UnlockRatio uint64 + Repeat bool + Cost Reward + Entries []EventExchangeEntry +} +type EventExchangeCatalog struct{ Groups map[uint64]EventExchangeGroup } + +func LoadEventExchangeCatalog(root, version string) (*EventExchangeCatalog, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + d := &EventExchangeCatalog{Groups: map[uint64]EventExchangeGroup{}} + rows, err := db.Query("SELECT id,ProtoBuf FROM EventCoinExchangeGroupTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + rows.Close() + return nil, err + } + g := EventExchangeGroup{ID: id} + for n, p := range map[int]*uint64{22: &g.StartPage, 4: &g.EndPage, 7: &g.FreeCount, 8: &g.FreeType, 25: &g.UnlockRatio, 14: &g.Cost.Count, 15: &g.Cost.ID, 16: &g.Cost.Type} { + *p, err = optionalScalar(raw, n) + if err != nil { + rows.Close() + return nil, err + } + } + repeat, e := optionalScalar(raw, 12) + if e != nil { + rows.Close() + return nil, e + } + g.Repeat = repeat != 0 + if g.StartPage == 0 || g.EndPage < g.StartPage || g.Cost.Count == 0 || g.Cost.Type == 0 || g.FreeType > 2 || g.ID > 2147483647 || g.EndPage > 2147483647 || g.Cost.Count > 2147483647 || g.FreeCount > 2147483647 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid exchange group %d", id) + } + d.Groups[id] = g + } + if err = rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + rows, err = db.Query("SELECT groupId,id,pageId,ProtoBuf FROM EventCoinExchangeTable ORDER BY groupId,id") + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var group, id, page uint64 + var raw []byte + if err = rows.Scan(&group, &id, &page, &raw); err != nil { + return nil, err + } + g, ok := d.Groups[group] + if !ok { + return nil, fmt.Errorf("gamedata: exchange group missing") + } + e := EventExchangeEntry{ID: id, Page: page} + for n, p := range map[int]*uint64{3: &e.KeyType, 4: &e.LimitedRatio, 6: &e.Ratio, 7: &e.Reward.Count, 8: &e.Reward.ID, 9: &e.Reward.Type, 10: &e.SetCount} { + *p, err = optionalScalar(raw, n) + if err != nil { + return nil, err + } + } + if e.ID == 0 || e.Page < g.StartPage || e.Page > g.EndPage || e.SetCount == 0 || e.Ratio == 0 || e.Reward.Type == 0 || e.Reward.Count == 0 || e.SetCount > 2147483647 || e.Reward.Count > 2147483647 || e.ID > 2147483647 { + return nil, fmt.Errorf("gamedata: invalid exchange entry %d:%d", group, id) + } + g.Entries = append(g.Entries, e) + d.Groups[group] = g + } + return d, rows.Err() +} +func (g EventExchangeGroup) Page(page uint64) []EventExchangeEntry { + if page > g.EndPage { + page = g.EndPage + } + var out []EventExchangeEntry + for _, e := range g.Entries { + if e.Page == page { + out = append(out, e) + } + } + return out +} diff --git a/go/internal/server/gamedata/event_games.go b/go/internal/server/gamedata/event_games.go new file mode 100644 index 0000000..80a0ec5 --- /dev/null +++ b/go/internal/server/gamedata/event_games.go @@ -0,0 +1,213 @@ +package gamedata + +import "fmt" + +type EventGameReward struct { + ID, Count, LineType, LineIndex, Slot, Weight, Weight2 uint64 + Members []uint64 + Rewards []BattleReward +} +type EventGame struct { + ID, Type, Columns, CostType, CostID, Cost, ScaffoldGroup, Controller, Free, Pity, MaxConsume uint64 + Cells, Lines, Complete []EventGameReward + Moves []struct{ ID, Min, Max uint64 } + RewardGroup uint64 +} + +func LoadEventGame(root, version string, kind, id uint64) (*EventGame, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + tables := map[uint64]string{12: "MiniGameBoardTable", 13: "MiniGameBingoTable", 17: "MiniGamePuzzleEventTable", 19: "MiniGameRouletteTable"} + table := tables[kind] + if table == "" { + return nil, fmt.Errorf("gamedata: unsupported event game") + } + var raw []byte + if e = db.QueryRow("SELECT ProtoBuf FROM "+table+" WHERE id=?", id).Scan(&raw); e != nil { + return nil, e + } + d := &EventGame{ID: id, Type: kind} + var cellGroup, lineGroup, completeGroup uint64 + fields := map[int]*uint64{} + switch kind { + case 12: + fields = map[int]*uint64{4: &d.Cost, 5: &d.CostID, 6: &d.CostType, 7: &completeGroup, 9: &d.Controller, 11: &d.ScaffoldGroup} + case 13: + fields = map[int]*uint64{1: &completeGroup, 2: &lineGroup, 3: &cellGroup, 5: &d.Columns, 7: &d.Cost, 8: &d.CostID, 9: &d.CostType} + case 17: + fields = map[int]*uint64{1: &d.Columns, 3: &d.Cost, 4: &d.CostID, 5: &d.CostType, 6: &completeGroup, 7: &cellGroup} + case 19: + fields = map[int]*uint64{1: &d.Free, 3: &d.Cost, 4: &d.CostID, 5: &d.MaxConsume, 6: &d.CostType, 7: &d.Pity, 8: &completeGroup, 9: &cellGroup} + } + for n, p := range fields { + *p, e = optionalScalar(raw, n) + if e != nil { + return nil, e + } + } + d.RewardGroup = cellGroup + load := func(table string, group uint64, mode string) ([]EventGameReward, error) { + rows, e := db.Query("SELECT ProtoBuf FROM "+table+" WHERE groupId=? ORDER BY id", group) + if e != nil { + return nil, e + } + defer rows.Close() + var out []EventGameReward + for rows.Next() { + var b []byte + if e = rows.Scan(&b); e != nil { + return nil, e + } + r := EventGameReward{} + idField, countField, typ, ids, counts := 3, 1, 6, 5, 4 + switch mode { + case "scaffold": + idField = 2 + countField = 0 + typ = 5 + ids = 4 + counts = 3 + case "line": + typ = 8 + ids = 7 + counts = 6 + r.LineType, e = optionalScalar(b, 5) + if e != nil { + return nil, e + } + r.LineIndex, e = optionalScalar(b, 4) + case "roulette": + idField = 2 + countField = 0 + typ = 7 + ids = 6 + counts = 5 + r.Slot, e = optionalScalar(b, 8) + if e != nil { + return nil, e + } + r.Weight, e = optionalScalar(b, 3) + if e != nil { + return nil, e + } + r.Weight2, e = optionalScalar(b, 4) + case "puzzlecomplete": + typ = 7 + ids = 6 + counts = 5 + r.Members, e = packedInts(b, 4) + case "puzzle": + r.Slot, e = optionalScalar(b, 7) + } + if e != nil { + return nil, e + } + r.ID, e = optionalScalar(b, idField) + if e != nil { + return nil, e + } + if countField > 0 { + r.Count, e = optionalScalar(b, countField) + if e != nil { + return nil, e + } + } + r.Rewards, e = eventGameRewards(b, typ, ids, counts) + if e != nil { + return nil, e + } + out = append(out, r) + } + return out, rows.Err() + } + switch kind { + case 12: + d.Cells, e = load("MiniGameScaffoldTable", d.ScaffoldGroup, "scaffold") + if e == nil { + d.Complete, e = load("MiniGameCompleteRewardTable", completeGroup, "default") + } + if e != nil { + return nil, e + } + rows, e := db.Query("SELECT ProtoBuf FROM MiniGameMoveControllerTable WHERE groupId=? ORDER BY id", d.Controller) + if e != nil { + return nil, e + } + for rows.Next() { + var b []byte + if e = rows.Scan(&b); e != nil { + rows.Close() + return nil, e + } + i, e := optionalScalar(b, 3) + if e != nil { + rows.Close() + return nil, e + } + min, _ := optionalScalar(b, 5) + max, _ := optionalScalar(b, 4) + d.Moves = append(d.Moves, struct{ ID, Min, Max uint64 }{i, min, max}) + } + e = rows.Err() + rows.Close() + case 13: + d.Cells, e = load("BingoRewardGroupTable", cellGroup, "default") + if e == nil { + d.Lines, e = load("BingoLineRewardGroupTable", lineGroup, "line") + } + if e == nil { + d.Complete, e = load("BingoCompleteRewardGroupTable", completeGroup, "default") + } + case 17: + d.Cells, e = load("PuzzleRewardGroupTable", cellGroup, "puzzle") + if e == nil { + d.Complete, e = load("PuzzleCompleteRewardGroupTable", completeGroup, "puzzlecomplete") + } + case 19: + d.Cells, e = load("RouletteRewardGroupTable", cellGroup, "roulette") + if e == nil { + d.Complete, e = load("RouletteAccumulatedRewardTable", completeGroup, "default") + } + } + if e != nil { + return nil, e + } + if d.Cost == 0 || len(d.Cells) == 0 { + return nil, fmt.Errorf("gamedata: empty event game rules") + } + return d, nil +} + +func eventGameRewards(raw []byte, typ, ids, counts int) ([]BattleReward, error) { + ts, e := packedInts(raw, typ) + if e != nil { + return nil, e + } + is, e := packedInts(raw, ids) + if e != nil { + return nil, e + } + cs, e := packedInts(raw, counts) + if e != nil { + return nil, e + } + if len(ts) == 0 && len(cs) == 0 { + return nil, nil + } + if len(is) == 0 { + is = make([]uint64, len(ts)) + } + if len(ts) != len(is) || len(ts) != len(cs) { + return nil, fmt.Errorf("gamedata: event game reward arrays differ") + } + var out []BattleReward + for i, t := range ts { + if t != 0 && cs[i] != 0 { + out = append(out, BattleReward{Type: t, ID: is[i], Count: cs[i]}) + } + } + return out, nil +} diff --git a/go/internal/server/gamedata/event_games_test.go b/go/internal/server/gamedata/event_games_test.go new file mode 100644 index 0000000..4adaff6 --- /dev/null +++ b/go/internal/server/gamedata/event_games_test.go @@ -0,0 +1,31 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestEventGameInstalledRules(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + for _, kind := range []uint64{12, 13, 17, 19} { + d, e := LoadEventGame(root, "20260923193640", kind, 1) + if e != nil { + t.Fatalf("kind=%d %v", kind, e) + } + if d.Type != kind || d.Cost == 0 || len(d.Cells) == 0 { + t.Fatalf("kind=%d invalid %+v", kind, d) + } + if kind == 12 && len(d.Moves) == 0 { + t.Fatal("no moves") + } + if kind == 13 && len(d.Lines) == 0 { + t.Fatal("no lines") + } + if kind == 17 && len(d.Complete) == 0 { + t.Fatal("no words") + } + } +} diff --git a/go/internal/server/gamedata/event_owned_item.go b/go/internal/server/gamedata/event_owned_item.go new file mode 100644 index 0000000..cccc177 --- /dev/null +++ b/go/internal/server/gamedata/event_owned_item.go @@ -0,0 +1,31 @@ +package gamedata + +func LoadOwnedEventItemDesign(root, version string) (map[uint64]map[uint64]bool, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + out := map[uint64]map[uint64]bool{} + for typ, table := range map[uint64]string{47: "IdCardItemTable", 49: "AvatarItemTable"} { + rows, e := db.Query("SELECT id FROM " + table) + if e != nil { + return nil, e + } + out[typ] = map[uint64]bool{} + for rows.Next() { + var id uint64 + if e = rows.Scan(&id); e != nil { + rows.Close() + return nil, e + } + out[typ][id] = true + } + if e = rows.Err(); e != nil { + rows.Close() + return nil, e + } + rows.Close() + } + return out, nil +} diff --git a/go/internal/server/gamedata/event_play.go b/go/internal/server/gamedata/event_play.go new file mode 100644 index 0000000..5f75b0c --- /dev/null +++ b/go/internal/server/gamedata/event_play.go @@ -0,0 +1,101 @@ +package gamedata + +import ( + "fmt" + "sort" +) + +// EventPlayCatalog keeps the installed static rows for event gameplay. +type EventPlayCatalog struct { + Tables map[string][][]byte + root, version string +} + +func LoadEventPlayCatalog(root, version string) (*EventPlayCatalog, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + c := &EventPlayCatalog{Tables: map[string][][]byte{}, root: root, version: version} + names := []string{"PackEventHubTable", "PackEventListTable", "PackEventStoryGroupTable", "PackEventStoryTable", "PackEventBattleGroupTable", "PackEventBattleTable", "PackEventMiniGameTable", "FieldMiniGameRewardTable", "FieldMiniGameSpeedTable", "RhythmGameMusicTable", "RhythmGameGradeTable", "SichuanStageTable", "SichuanEventTable", "SichuanRewardTable", "HopscotchStageTable", "HopscotchRewardTable", "HopscotchDefaultTable", "FieldMiniGameSurvivalTable", "ActionGameDefaultTable", "ActionGameMissionTable", "ActionGameStageTable", "FieldMiniGameCharTable", "FieldMiniGameMapTable", "FieldMiniGameUpgradeTable", "FieldMiniGameUpgradeGroupTable", "FieldMiniGameSkillGroupTable", "FieldMiniGameSkillTable", "FieldMiniGameCharLevelTable", "FieldMiniGameMonsterTable", "FieldMiniGameSurvivalItemTable", "FieldMiniGameSurvivalBoxTable", "MGDRewardTable", "MGDWaveTable", "MGDDefaultTable"} + for _, name := range names { + rows, e := db.Query("SELECT ProtoBuf FROM " + name) + if e != nil { + return nil, e + } + for rows.Next() { + var raw []byte + if e = rows.Scan(&raw); e != nil { + rows.Close() + return nil, e + } + c.Tables[name] = append(c.Tables[name], append([]byte(nil), raw...)) + } + e = rows.Err() + rows.Close() + if e != nil { + return nil, e + } + } + return c, nil +} +func (c *EventPlayCatalog) Row(table string, n int, id uint64) ([]byte, error) { + for _, r := range c.Tables[table] { + v, e := optionalScalar(r, n) + if e != nil { + return nil, e + } + if v == id { + return r, nil + } + } + return nil, fmt.Errorf("gamedata: %s row %d missing", table, id) +} +func (c *EventPlayCatalog) Rows(table string, n int, id uint64) [][]byte { + var out [][]byte + for _, r := range c.Tables[table] { + v, _ := optionalScalar(r, n) + if v == id { + out = append(out, r) + } + } + return out +} +func EventPlayRewards(raw []byte, typ, id, count int) ([]BattleReward, error) { + return eventGameRewards(raw, typ, id, count) +} +func (c *EventPlayCatalog) Story(group, id uint64) ([]byte, error) { + for _, r := range c.Rows("PackEventStoryTable", 1, group) { + v, _ := optionalScalar(r, 2) + if v == id { + return r, nil + } + } + return nil, fmt.Errorf("gamedata: missing event story") +} +func (c *EventPlayCatalog) ScoreRewards(group, score uint64) (uint64, []BattleReward, error) { + var picked []byte + point := uint64(0) + rows := c.Rows("FieldMiniGameRewardTable", 1, group) + sort.Slice(rows, func(i, j int) bool { + a, _ := optionalScalar(rows[i], 3) + b, _ := optionalScalar(rows[j], 3) + return a < b + }) + for _, r := range rows { + p, e := optionalScalar(r, 3) + if e != nil { + return 0, nil, e + } + if p <= score && p >= point { + picked = r + point = p + } + } + if picked == nil { + return 0, nil, nil + } + rewards, e := eventGameRewards(picked, 6, 5, 4) + return point, rewards, e +} diff --git a/go/internal/server/gamedata/event_play_field.go b/go/internal/server/gamedata/event_play_field.go new file mode 100644 index 0000000..771c0b3 --- /dev/null +++ b/go/internal/server/gamedata/event_play_field.go @@ -0,0 +1,48 @@ +package gamedata + +import "fmt" + +type EventField struct { + HP, Limit uint64 + Objects map[uint64]struct{ Point, Type uint64 } +} + +func (c *EventPlayCatalog) Field(game uint64) (*EventField, error) { + g, e := c.Row("PackEventMiniGameTable", 8, game) + if e != nil { + return nil, e + } + pack, _ := optionalScalar(g, 12) + target, _ := optionalScalar(g, 4) + db, done, e := openPackDatabase(c.root, c.version, int(pack)) + if e != nil { + return nil, e + } + defer done() + var raw []byte + if e = db.QueryRow("SELECT ProtoBuf FROM FieldMiniGameTable WHERE id=?", target).Scan(&raw); e != nil { + return nil, e + } + group, _ := optionalScalar(raw, 3) + d := &EventField{Objects: map[uint64]struct{ Point, Type uint64 }{}} + d.HP, _ = optionalScalar(raw, 6) + d.Limit, _ = optionalScalar(raw, 7) + rows, e := db.Query("SELECT ProtoBuf FROM FieldMiniGameObjectTable WHERE groupId=? ORDER BY id", group) + if e != nil { + return nil, e + } + defer rows.Close() + for rows.Next() { + if e = rows.Scan(&raw); e != nil { + return nil, e + } + id, _ := optionalScalar(raw, 2) + point, _ := optionalScalar(raw, 3) + typ, _ := optionalScalar(raw, 4) + d.Objects[id] = struct{ Point, Type uint64 }{point, typ} + } + if len(d.Objects) == 0 { + return nil, fmt.Errorf("gamedata: field game objects missing") + } + return d, rows.Err() +} diff --git a/go/internal/server/gamedata/event_play_test.go b/go/internal/server/gamedata/event_play_test.go new file mode 100644 index 0000000..a33bb08 --- /dev/null +++ b/go/internal/server/gamedata/event_play_test.go @@ -0,0 +1,22 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestEventPlayInstalledCatalog(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not set") + } + c, e := LoadEventPlayCatalog(root, "20260923193640") + if e != nil { + t.Fatal(e) + } + for _, n := range []string{"PackEventStoryTable", "FieldMiniGameSkillTable", "MGDRewardTable"} { + if len(c.Tables[n]) == 0 { + t.Fatalf("empty %s", n) + } + } +} diff --git a/go/internal/server/gamedata/event_tasks.go b/go/internal/server/gamedata/event_tasks.go new file mode 100644 index 0000000..e7ff25b --- /dev/null +++ b/go/internal/server/gamedata/event_tasks.go @@ -0,0 +1,233 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "sort" +) + +type EventAttendance struct{ ID, Group, Ticket uint64 } +type EventAttendanceGroup struct{ Group, ID, Next uint64 } +type EventAttendanceReward struct { + Group, ID, Day uint64 + Basic, Premium Reward +} +type EventMissionGroup struct { + ID, Type, ScheduleType, UsePass, CompleteHide uint64 + Groups []uint64 +} +type EventTask struct { + ID, Group, Type, SubType, Target, PassExp, UnlockPack, UnlockQuest uint64 + Params []uint64 + Rewards []Reward +} +type EventPass struct { + ID, MissionGroup, LevelGroup, Type, ScheduleType, NewbieGroup, NewbieStep uint64 + Core Reward +} +type EventPassLevel struct { + ID, NeedExp, PremiumType uint64 + Basic, Premium Reward +} +type EventPassBuy struct { + ID, Type, UnlockLevel, CashGroup, CashID, CashSales uint64 + Cost Reward + Rewards []Reward +} +type EventTasksDesign struct { + Attendance map[uint64]EventAttendance + AttendanceGroups map[[2]uint64]EventAttendanceGroup + AttendanceRewards map[uint64][]EventAttendanceReward + LimitRewards map[[2]uint64]uint64 + MissionGroups map[uint64]EventMissionGroup + Missions map[uint64]EventTask + Passes map[uint64]EventPass + PassLevels map[uint64][]EventPassLevel + PassBuys map[uint64][]EventPassBuy +} + +func LoadEventTasksDesign(root, version string) (*EventTasksDesign, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + return loadEventTasksDesign(db) +} +func loadEventTasksDesign(db *sql.DB) (*EventTasksDesign, error) { + d := &EventTasksDesign{Attendance: map[uint64]EventAttendance{}, AttendanceGroups: map[[2]uint64]EventAttendanceGroup{}, AttendanceRewards: map[uint64][]EventAttendanceReward{}, LimitRewards: map[[2]uint64]uint64{}, MissionGroups: map[uint64]EventMissionGroup{}, Missions: map[uint64]EventTask{}, Passes: map[uint64]EventPass{}, PassLevels: map[uint64][]EventPassLevel{}, PassBuys: map[uint64][]EventPassBuy{}} + each := func(table string, fn func([]byte) error) error { + rows, e := db.Query("SELECT ProtoBuf FROM " + table) + if e != nil { + return e + } + defer rows.Close() + for rows.Next() { + var p []byte + if e = rows.Scan(&p); e != nil { + return e + } + if e = fn(p); e != nil { + return fmt.Errorf("gamedata: %s: %w", table, e) + } + } + return rows.Err() + } + scalar := func(p []byte, n int) (uint64, error) { return optionalScalar(p, n) } + read := func(p []byte, m map[int]*uint64) error { + for n, target := range m { + v, e := scalar(p, n) + if e != nil { + return e + } + *target = v + } + return nil + } + reward := func(p []byte, t, i, c int) (Reward, error) { + r := Reward{} + e := read(p, map[int]*uint64{t: &r.Type, i: &r.ID, c: &r.Count}) + return r, e + } + if e := each("AttendanceAlwaysTable", func(p []byte) error { + r := EventAttendance{} + e := read(p, map[int]*uint64{3: &r.ID, 1: &r.Group, 2: &r.Ticket}) + d.Attendance[r.ID] = r + return e + }); e != nil { + return nil, e + } + if e := each("AttendanceAlwaysGroupTable", func(p []byte) error { + r := EventAttendanceGroup{} + e := read(p, map[int]*uint64{1: &r.Group, 2: &r.ID, 3: &r.Next}) + d.AttendanceGroups[[2]uint64{r.Group, r.ID}] = r + return e + }); e != nil { + return nil, e + } + if e := each("AttendanceAlwaysRewardTable", func(p []byte) error { + r := EventAttendanceReward{} + if e := read(p, map[int]*uint64{2: &r.Group, 3: &r.ID, 1: &r.Day}); e != nil { + return e + } + var e error + r.Basic, e = reward(p, 9, 8, 7) + if e != nil { + return e + } + r.Premium, e = reward(p, 6, 5, 4) + d.AttendanceRewards[r.Group] = append(d.AttendanceRewards[r.Group], r) + return e + }); e != nil { + return nil, e + } + if e := each("AttendanceLimitRewardTable", func(p []byte) error { + var g, i, b uint64 + e := read(p, map[int]*uint64{1: &g, 2: &i, 3: &b}) + d.LimitRewards[[2]uint64{g, i}] = b + return e + }); e != nil { + return nil, e + } + if e := each("EventMissionGroupTable", func(p []byte) error { + r := EventMissionGroup{} + if e := read(p, map[int]*uint64{5: &r.ID, 1: &r.Type, 8: &r.ScheduleType, 10: &r.UsePass, 6: &r.CompleteHide}); e != nil { + return e + } + var e error + r.Groups, e = packedInts(p, 7) + d.MissionGroups[r.ID] = r + return e + }); e != nil { + return nil, e + } + if e := each("MissionTable", func(p []byte) error { + g, e := scalar(p, 7) + if e != nil { + return e + } + if g != 2 { + return nil + } + r := EventTask{} + if e = read(p, map[int]*uint64{8: &r.ID, 6: &r.Group, 3: &r.Type, 1: &r.SubType, 4: &r.Target, 12: &r.PassExp, 19: &r.UnlockPack, 20: &r.UnlockQuest}); e != nil { + return e + } + r.Params, e = packedInts(p, 2) + if e != nil { + return e + } + v, e := reward(p, 15, 14, 13) + if e != nil { + return e + } + if v.Type > 0 && v.Count > 0 { + r.Rewards = []Reward{v} + } + d.Missions[r.ID] = r + return nil + }); e != nil { + return nil, e + } + if e := each("PassTable", func(p []byte) error { + r := EventPass{} + if e := read(p, map[int]*uint64{6: &r.ID, 5: &r.MissionGroup, 11: &r.LevelGroup, 13: &r.Type, 15: &r.ScheduleType, 9: &r.NewbieGroup, 10: &r.NewbieStep}); e != nil { + return e + } + var e error + r.Core, e = reward(p, 4, 3, 2) + d.Passes[r.ID] = r + return e + }); e != nil { + return nil, e + } + if e := each("PassLevelTable", func(p []byte) error { + r := EventPassLevel{} + var g uint64 + if e := read(p, map[int]*uint64{4: &g, 5: &r.ID, 6: &r.NeedExp, 10: &r.PremiumType}); e != nil { + return e + } + var e error + r.Basic, e = reward(p, 3, 2, 1) + if e != nil { + return e + } + r.Premium, e = reward(p, 9, 8, 7) + d.PassLevels[g] = append(d.PassLevels[g], r) + return e + }); e != nil { + return nil, e + } + if e := each("PassBuyTable", func(p []byte) error { + r := EventPassBuy{} + var g uint64 + if e := read(p, map[int]*uint64{7: &g, 8: &r.ID, 12: &r.Type, 13: &r.UnlockLevel, 4: &r.CashGroup, 5: &r.CashID, 6: &r.CashSales}); e != nil { + return e + } + var e error + r.Cost, e = reward(p, 3, 2, 1) + if e != nil { + return e + } + rs, e := monsterHuntRewardArrays(p, 11, 10, 9) + if e != nil { + return e + } + for _, v := range rs { + r.Rewards = append(r.Rewards, Reward{v.Type, v.ID, v.Count}) + } + d.PassBuys[g] = append(d.PassBuys[g], r) + return nil + }); e != nil { + return nil, e + } + for g, rs := range d.AttendanceRewards { + sort.Slice(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID }) + d.AttendanceRewards[g] = rs + } + for g, rs := range d.PassLevels { + sort.Slice(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID }) + d.PassLevels[g] = rs + } + return d, nil +} diff --git a/go/internal/server/gamedata/event_tasks_test.go b/go/internal/server/gamedata/event_tasks_test.go new file mode 100644 index 0000000..8a2da86 --- /dev/null +++ b/go/internal/server/gamedata/event_tasks_test.go @@ -0,0 +1,27 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestInstalledEventTasks23510(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + d, e := LoadEventTasksDesign(root, "20260923193640") + if e != nil { + t.Fatal(e) + } + if len(d.Attendance) != 109 || len(d.MissionGroups) != 519 || len(d.Passes) != 141 { + t.Fatal("event design tables not fully loaded") + } + p := d.Passes[141] + if p.MissionGroup != 51 || p.LevelGroup != 141 || d.PassLevels[141][0].NeedExp != 20 || d.PassLevels[141][0].Basic.Type != 9 { + t.Fatalf("pass 141 mismatch %+v", p) + } + if d.MissionGroups[51].Type != 3 || len(d.MissionGroups[51].Groups) != 7 { + t.Fatal("daily-open groups lost") + } +} diff --git a/go/internal/server/gamedata/field_objects.go b/go/internal/server/gamedata/field_objects.go index 9166bf7..536cc59 100644 --- a/go/internal/server/gamedata/field_objects.go +++ b/go/internal/server/gamedata/field_objects.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" ) // FieldObjectDesign retains the client table identities, including unsupported @@ -21,24 +19,11 @@ type FieldRewardObject struct { } func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error) { - plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", pack)) + db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", pack)) if err != nil { return FieldObjectDesign{}, err } - dir, err := os.MkdirTemp("", "bd2-field-objects-") - if err != nil { - return FieldObjectDesign{}, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "pack.db") - if err = os.WriteFile(path, plain, 0600); err != nil { - return FieldObjectDesign{}, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return FieldObjectDesign{}, err - } - defer db.Close() + defer release() common, closeDB, err := openStatDatabase(root, version) if err != nil { return FieldObjectDesign{}, err diff --git a/go/internal/server/gamedata/field_research.go b/go/internal/server/gamedata/field_research.go new file mode 100644 index 0000000..953d152 --- /dev/null +++ b/go/internal/server/gamedata/field_research.go @@ -0,0 +1,225 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "fmt" +) + +type FieldResearchObject struct { + ID, Type, CollectionID, HoldingCollectionID, TalentObjectID int + Maps []int + QuestRange []uint64 + InteractionQuests []int + Reward Reward +} +type FieldResearchDesign struct{ Objects map[int]FieldResearchObject } + +func openLogicalReadDB(root, version, name string) (*sql.DB, func(), error) { + return OpenDatabase(root, version, name) +} +func LoadFieldResearch(root, version string, pack int) (FieldResearchDesign, error) { + d := FieldResearchDesign{Objects: map[int]FieldResearchObject{}} + db, done, err := openLogicalReadDB(root, version, fmt.Sprintf("pack%d", pack)) + if err != nil { + return d, err + } + defer done() + rows, err := db.Query("SELECT ProtoBuf FROM FieldResearchObjectTable") + if err != nil { + return d, err + } + for rows.Next() { + var raw []byte + if err = rows.Scan(&raw); err != nil { + rows.Close() + return d, err + } + var o FieldResearchObject + values := []*int{&o.CollectionID, &o.HoldingCollectionID, &o.ID, &o.TalentObjectID, &o.Type} + for i, f := range []int{1, 3, 5, 17, 18} { + v, e := optionalScalar(raw, f) + if e != nil { + rows.Close() + return d, e + } + *values[i] = int(v) + } + o.QuestRange, err = packedInts(raw, 10) + if err != nil { + rows.Close() + return d, err + } + o.Reward.Type, err = optionalScalar(raw, 16) + if err != nil { + rows.Close() + return d, err + } + o.Reward.ID, err = optionalScalar(raw, 15) + if err != nil { + rows.Close() + return d, err + } + o.Reward.Count, err = optionalScalar(raw, 14) + if err != nil { + rows.Close() + return d, err + } + if o.ID <= 0 { + rows.Close() + return d, fmt.Errorf("gamedata: invalid research id") + } + d.Objects[o.ID] = o + } + err = rows.Err() + rows.Close() + if err != nil { + return d, err + } + common, closeCommon, err := openStatDatabase(root, version) + if err != nil { + return d, err + } + defer closeCommon() + maps := map[string]int{} + rows, err = common.Query("SELECT id,mapScenePath FROM MapTable WHERE packId=?", pack) + if err != nil { + return d, err + } + for rows.Next() { + var id int + var scene string + if err = rows.Scan(&id, &scene); err != nil { + rows.Close() + return d, err + } + maps[scene] = id + } + err = rows.Err() + rows.Close() + if err != nil { + return d, err + } + scenes, closeScenes, err := openLogicalReadDB(root, version, "FieldObjectSceneData") + if err != nil { + return d, err + } + defer closeScenes() + rows, err = scenes.Query(fmt.Sprintf("SELECT ProtoBuf FROM FieldObjectSceneData%d WHERE objectType=5", pack)) + if err != nil { + return d, err + } + defer rows.Close() + for rows.Next() { + var raw []byte + if err = rows.Scan(&raw); err != nil { + return d, err + } + id, e := optionalScalar(raw, 8) + if e != nil { + return d, e + } + var scene string + if e = wire.Walk(raw, func(f wire.Field) error { + if f.Number == 7 && f.Type == 2 { + scene = string(f.Value) + } + return nil + }); e != nil { + return d, e + } + o, ok := d.Objects[int(id)] + if !ok { + return d, fmt.Errorf("gamedata: scene references missing research %d", id) + } + mapID, ok := maps[scene] + if !ok { + return d, fmt.Errorf("gamedata: research scene %s missing map", scene) + } + o.Maps = append(o.Maps, mapID) + d.Objects[o.ID] = o + } + if err = rows.Err(); err != nil { + return d, err + } + rows.Close() + rows, err = common.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d", pack)) + if err != nil { + return d, err + } + defer rows.Close() + for rows.Next() { + var id int + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + return d, err + } + condition, e := optionalScalar(raw, 11) + if e != nil { + return d, e + } + if condition != 18 { + continue + } + objects, e := packedInts(raw, 33) + if e != nil { + return d, e + } + for _, object := range objects { + if o, ok := d.Objects[int(object)]; ok { + o.InteractionQuests = append(o.InteractionQuests, id) + d.Objects[o.ID] = o + } + } + } + return d, rows.Err() +} + +// Research characters are identified by the client's FieldRewardResearch class +// (6), joined through CharTable's talent ID rather than a fixed character list. +func LoadResearchCharacters(root, version string) (map[uint64]bool, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + talents := map[uint64]bool{} + rows, err := db.Query("SELECT id FROM TalentTable WHERE classType=6") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + if err = rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + talents[id] = true + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + chars := map[uint64]bool{} + rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable") + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id uint64 + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + return nil, err + } + talent, e := optionalScalar(raw, 18) + if e != nil { + return nil, e + } + if talents[talent] { + chars[id] = true + } + } + return chars, rows.Err() +} diff --git a/go/internal/server/gamedata/field_research_test.go b/go/internal/server/gamedata/field_research_test.go new file mode 100644 index 0000000..f3af459 --- /dev/null +++ b/go/internal/server/gamedata/field_research_test.go @@ -0,0 +1,28 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestInstalledFieldResearch23510(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + d, err := LoadFieldResearch(root, "20260923193640", 1) + if err != nil { + t.Fatal(err) + } + o := d.Objects[401] + if o.CollectionID != 103 || o.Type != 1 || o.Reward.Type != 4 || o.Reward.Count != 345 || len(o.Maps) != 1 || o.Maps[0] != 4 { + t.Fatalf("research table mismatch: %+v", o) + } + chars, err := LoadResearchCharacters(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + if len(chars) == 0 { + t.Fatal("research talents empty") + } +} diff --git a/go/internal/server/gamedata/food.go b/go/internal/server/gamedata/food.go index 599650a..b3a623f 100644 --- a/go/internal/server/gamedata/food.go +++ b/go/internal/server/gamedata/food.go @@ -5,8 +5,6 @@ import ( "errors" "fmt" "math" - "os" - "path/filepath" ) // Food is decoded from FoodTable, including materials so that the server can @@ -19,24 +17,11 @@ type Food struct { type FoodDesign struct{ Foods map[uint64]Food } func LoadFoodDesign(root, version string) (*FoodDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-food-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadFoodDesign(db) } diff --git a/go/internal/server/gamedata/gacha.go b/go/internal/server/gamedata/gacha.go index 70d5174..7f06de9 100644 --- a/go/internal/server/gamedata/gacha.go +++ b/go/internal/server/gamedata/gacha.go @@ -8,8 +8,6 @@ import ( "fmt" "math" "math/big" - "os" - "path/filepath" "sort" _ "modernc.org/sqlite" @@ -971,24 +969,11 @@ func isPermanentEquipmentGachaGroup(raw []byte) bool { // selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and // is intentionally omitted from both ordinary catalogs. func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume, equipment []uint64, err error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, nil, err } - dir, err := os.MkdirTemp("", "bd2-gacha-classify-") - if err != nil { - return nil, nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, nil, err - } - defer db.Close() + defer release() seen := map[uint64]bool{} for _, id := range groupIDs { if seen[id] { @@ -1026,24 +1011,11 @@ func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume } func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-regular-gacha-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() catalog := &RegularGachaCatalog{ Gachas: map[uint64]RegularGacha{}, characters: make(map[uint64]CharacterDesign), groups: map[uint64]GachaGroupDesign{}, byGacha: map[uint64]uint64{}, fixed: map[uint64]GachaFixedDesign{}, grades: map[uint64]uint64{}, @@ -1218,6 +1190,15 @@ func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) { if id != step.Step || step.Step != uint64(len(out.Steps)+1) || step.GroupID == 0 || step.GachaID == 0 { return GachaStepUpDesign{}, fmt.Errorf("gamedata: malformed step-up group %d row %d", groupID, id) } + out.Steps = append(out.Steps, step) + } + if err := rows.Err(); err != nil { + return GachaStepUpDesign{}, err + } + if err := rows.Close(); err != nil { + return GachaStepUpDesign{}, err + } + for _, step := range out.Steps { var child []byte if err := db.QueryRow("SELECT ProtoBuf FROM GachaGroupTable WHERE id=?", step.GroupID).Scan(&child); err != nil { return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up child group %d: %w", step.GroupID, err) @@ -1228,10 +1209,6 @@ func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) { if len(types) != 1 || types[0] != 1 || len(subTypes) != 1 || subTypes[0] != 4 || len(tenIDs) != 1 || tenIDs[0] != step.GachaID { return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up group %d child %d does not reference gacha %d", groupID, step.GroupID, step.GachaID) } - out.Steps = append(out.Steps, step) - } - if err := rows.Err(); err != nil { - return GachaStepUpDesign{}, err } if len(out.Steps) == 0 { return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up group %d is empty", groupID) @@ -1398,13 +1375,6 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error { if !oneLoaded && !tenLoaded { continue } - if group.CashProductGroupID != 0 && group.CashProductID != 0 { - var err error - group.CashRewards, err = loadGachaCashRewards(db, group) - if err != nil { - return err - } - } c.groups[id] = group if oneLoaded { c.byGacha[group.OneTimeGachaID] = id @@ -1416,7 +1386,17 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error { if err := rows.Err(); err != nil { return err } - for _, group := range c.groups { + if err := rows.Close(); err != nil { + return err + } + for id, group := range c.groups { + if group.CashProductGroupID != 0 && group.CashProductID != 0 { + group.CashRewards, err = loadGachaCashRewards(db, group) + if err != nil { + return err + } + c.groups[id] = group + } if group.FixedID == 0 { continue } @@ -1658,24 +1638,11 @@ func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeS } func LoadInfiniteGachaForSchedules(root, version string, groups []uint64) (*InfiniteGachaDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-gacha-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() infiniteGroupID, infiniteGachaID, err := loadInfiniteGachaIdentity(db, groups) if err != nil { diff --git a/go/internal/server/gamedata/gacha_infinite_metadata.go b/go/internal/server/gamedata/gacha_infinite_metadata.go index 786008d..ba5587d 100644 --- a/go/internal/server/gamedata/gacha_infinite_metadata.go +++ b/go/internal/server/gamedata/gacha_infinite_metadata.go @@ -58,11 +58,21 @@ func loadInfiniteCashIdentity(db *sql.DB, rewardID uint64) (groupID, productID, return 0, 0, 0, err } defer rows.Close() + var products [][]byte for rows.Next() { var raw []byte if err = rows.Scan(&raw); err != nil { return } + products = append(products, raw) + } + if err = rows.Err(); err != nil { + return + } + if err = rows.Close(); err != nil { + return + } + for _, raw := range products { box, e := optionalScalar(raw, 14) if e != nil { return 0, 0, 0, e diff --git a/go/internal/server/gamedata/growth.go b/go/internal/server/gamedata/growth.go index c46728e..4b05dc5 100644 --- a/go/internal/server/gamedata/growth.go +++ b/go/internal/server/gamedata/growth.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" "sort" _ "modernc.org/sqlite" @@ -19,24 +17,11 @@ func CharacterGrowth(root, version string, charID int, level, exp uint64, materi if charID <= 0 || level == 0 || len(materials) == 0 { return 0, 0, nil, fmt.Errorf("gamedata: invalid character growth input") } - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return 0, 0, nil, err } - dir, err := os.MkdirTemp("", "bd2-growth-") - if err != nil { - return 0, 0, nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return 0, 0, nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return 0, 0, nil, err - } - defer db.Close() + defer release() var charProto []byte if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil { diff --git a/go/internal/server/gamedata/hunting_ap.go b/go/internal/server/gamedata/hunting_ap.go new file mode 100644 index 0000000..c9024ca --- /dev/null +++ b/go/internal/server/gamedata/hunting_ap.go @@ -0,0 +1,50 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "fmt" + "strconv" + "strings" +) + +type HuntingAPDesign struct { + Max uint64 + ResetSeconds int64 +} + +func LoadHuntingAPDesign(root, version string) (HuntingAPDesign, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return HuntingAPDesign{}, e + } + defer done() + var raw []byte + if e = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); e != nil { + return HuntingAPDesign{}, e + } + max, e := optionalScalar(raw, 56) + if e != nil { + return HuntingAPDesign{}, e + } + text, _, e := wire.Bytes(raw, 23) + if e != nil { + return HuntingAPDesign{}, e + } + parts := strings.Split(string(text), ":") + if max == 0 || max > 2147483647 || len(parts) != 3 { + return HuntingAPDesign{}, fmt.Errorf("gamedata: invalid hunting AP defaults") + } + var secs int64 + for i, p := range parts { + n, e := strconv.ParseInt(p, 10, 64) + limit := int64(60) + if i == 0 { + limit = 24 + } + if e != nil || n < 0 || n >= limit { + return HuntingAPDesign{}, fmt.Errorf("gamedata: invalid daily reset time") + } + secs = secs*60 + n + } + return HuntingAPDesign{Max: max, ResetSeconds: secs}, nil +} diff --git a/go/internal/server/gamedata/inventory_slots.go b/go/internal/server/gamedata/inventory_slots.go index 953affc..09578d9 100644 --- a/go/internal/server/gamedata/inventory_slots.go +++ b/go/internal/server/gamedata/inventory_slots.go @@ -4,8 +4,6 @@ import ( "database/sql" "errors" "fmt" - "os" - "path/filepath" _ "modernc.org/sqlite" ) @@ -28,24 +26,11 @@ type InventorySlotDesign struct { } func LoadInventorySlotDesign(root, version string) (*InventorySlotDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-inventory-slots-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadInventorySlotDesign(db) } diff --git a/go/internal/server/gamedata/limited_costume.go b/go/internal/server/gamedata/limited_costume.go index 16aca00..be030b9 100644 --- a/go/internal/server/gamedata/limited_costume.go +++ b/go/internal/server/gamedata/limited_costume.go @@ -1,11 +1,8 @@ package gamedata import ( - "database/sql" "errors" "fmt" - "os" - "path/filepath" _ "modernc.org/sqlite" ) @@ -19,24 +16,11 @@ type LimitedCostumeCatalog struct { } func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-limited-costumes-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() rows, err := db.Query("SELECT id FROM LimitedCostumeTable ORDER BY id") if err != nil { return nil, err @@ -51,19 +35,25 @@ func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) { if costumeID == 0 { return nil, errors.New("gamedata: limited costume has zero id") } - design, err := loadGachaCharacterDesign(db, costumeID) - if err != nil { - return nil, fmt.Errorf("gamedata: limited costume %d: %w", costumeID, err) - } catalog.ids = append(catalog.ids, costumeID) - catalog.characters[costumeID] = design } if err := rows.Err(); err != nil { return nil, err } + // Release the single connection before character loading performs queries. + if err := rows.Close(); err != nil { + return nil, err + } if len(catalog.ids) == 0 { return nil, errors.New("gamedata: limited costume catalog is empty") } + for _, costumeID := range catalog.ids { + design, err := loadGachaCharacterDesign(db, costumeID) + if err != nil { + return nil, fmt.Errorf("gamedata: limited costume %d: %w", costumeID, err) + } + catalog.characters[costumeID] = design + } return catalog, nil } diff --git a/go/internal/server/gamedata/limited_costume_test.go b/go/internal/server/gamedata/limited_costume_test.go new file mode 100644 index 0000000..43eb469 --- /dev/null +++ b/go/internal/server/gamedata/limited_costume_test.go @@ -0,0 +1,41 @@ +package gamedata + +import ( + "os" + "testing" + "time" +) + +func TestRealLimitedCostumesReleasesRowsBeforeCharacterQueries(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + type result struct { + catalog *LimitedCostumeCatalog + err error + } + done := make(chan result, 1) + go func() { c, e := LoadLimitedCostumes(root, "20260923193640"); done <- result{c, e} }() + select { + case r := <-done: + if r.err != nil { + t.Fatal(r.err) + } + ids := r.catalog.IDs() + if len(ids) == 0 { + t.Fatal("empty limited costume catalog") + } + for i, id := range ids { + if i > 0 && ids[i-1] >= id { + t.Fatal("unstable/duplicate membership order") + } + design, ok := r.catalog.Character(id) + if !ok || design.ID == 0 { + t.Fatalf("limited costume %d missing character design", id) + } + } + case <-time.After(15 * time.Second): + t.Fatal("limited costume nested queries blocked on retained result rows") + } +} diff --git a/go/internal/server/gamedata/login_passes.go b/go/internal/server/gamedata/login_passes.go new file mode 100644 index 0000000..d7faa8b --- /dev/null +++ b/go/internal/server/gamedata/login_passes.go @@ -0,0 +1,45 @@ +package gamedata + +import ( + "fmt" + "sort" +) + +type LoginPassReward struct { + ID, TicketID uint64 + Free, Premium Reward +} +type LoginPassCatalog struct{ Groups map[uint64][]LoginPassReward } + +func LoadLoginPassCatalog(root, version string) (*LoginPassCatalog, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + d := &LoginPassCatalog{Groups: map[uint64][]LoginPassReward{}} + err = readCashMetadata(db, "LoginPassTable", func(raw []byte) error { + v, e := cashScalars(raw, 5, 6, 1, 4, 3, 2, 9, 8, 7) + if e != nil { + return e + } + if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[3] == 0 || v[5] == 0 || v[6] == 0 || v[8] == 0 { + return fmt.Errorf("gamedata: invalid login-pass reward") + } + d.Groups[v[0]] = append(d.Groups[v[0]], LoginPassReward{ID: v[1], TicketID: v[2], Free: Reward{Type: v[3], ID: v[4], Count: v[5]}, Premium: Reward{Type: v[6], ID: v[7], Count: v[8]}}) + return nil + }) + if err != nil { + return nil, err + } + for group, rows := range d.Groups { + sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) + for i, r := range rows { + if r.ID != uint64(i+1) || r.TicketID != rows[0].TicketID { + return nil, fmt.Errorf("gamedata: invalid login-pass sequence %d", group) + } + } + d.Groups[group] = rows + } + return d, nil +} diff --git a/go/internal/server/gamedata/missions.go b/go/internal/server/gamedata/missions.go index bf03405..3128394 100644 --- a/go/internal/server/gamedata/missions.go +++ b/go/internal/server/gamedata/missions.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "math" - "os" - "path/filepath" _ "modernc.org/sqlite" ) @@ -56,24 +54,11 @@ type AchievementDesign struct { // current database. Event missions intentionally remain outside this reader: // their eligibility depends on an active server schedule, not just GameData. func LoadMissionDesign(root, version string) (*MissionDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-mission-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadMissionDesignDB(db) } diff --git a/go/internal/server/gamedata/pack_detail.go b/go/internal/server/gamedata/pack_detail.go index 3b119ab..8209c13 100644 --- a/go/internal/server/gamedata/pack_detail.go +++ b/go/internal/server/gamedata/pack_detail.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" ) // PackDetailDesign identifies the monsters whose state is consumed by the @@ -15,24 +13,11 @@ func LoadPackDetailDesign(root, version string, packID int) (PackDetailDesign, e if packID <= 0 { return PackDetailDesign{}, fmt.Errorf("gamedata: invalid detail pack %d", packID) } - plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID)) + db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID)) if err != nil { return PackDetailDesign{}, err } - dir, err := os.MkdirTemp("", "bd2-pack-detail-") - if err != nil { - return PackDetailDesign{}, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "pack.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return PackDetailDesign{}, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return PackDetailDesign{}, err - } - defer db.Close() + defer release() return loadPackDetailDesign(db) } diff --git a/go/internal/server/gamedata/pack_transition.go b/go/internal/server/gamedata/pack_transition.go index a78e048..099a30f 100644 --- a/go/internal/server/gamedata/pack_transition.go +++ b/go/internal/server/gamedata/pack_transition.go @@ -1,10 +1,7 @@ package gamedata import ( - "database/sql" "fmt" - "os" - "path/filepath" ) // PackTransition is the authoritative story edge in PackTable. It is kept @@ -18,24 +15,11 @@ func LoadPackTransition(root, version string, packID int) (PackTransition, error if packID <= 0 { return PackTransition{}, fmt.Errorf("gamedata: invalid pack transition id %d", packID) } - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return PackTransition{}, err } - dir, err := os.MkdirTemp("", "bd2-pack-transition-") - if err != nil { - return PackTransition{}, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return PackTransition{}, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return PackTransition{}, err - } - defer db.Close() + defer release() var raw []byte if err := db.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", packID).Scan(&raw); err != nil { return PackTransition{}, fmt.Errorf("gamedata: pack %d: %w", packID, err) diff --git a/go/internal/server/gamedata/preset.go b/go/internal/server/gamedata/preset.go index 8711874..6b2397a 100644 --- a/go/internal/server/gamedata/preset.go +++ b/go/internal/server/gamedata/preset.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" ) // PresetDesign is the normal party preset configuration in GameDefaultTable. @@ -26,24 +24,11 @@ func (d PresetDesign) Validate() error { } func LoadPresetDesign(root, version string) (*PresetDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-presets-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadPresetDesign(db) } diff --git a/go/internal/server/gamedata/promotion.go b/go/internal/server/gamedata/promotion.go index f68772a..a1360fc 100644 --- a/go/internal/server/gamedata/promotion.go +++ b/go/internal/server/gamedata/promotion.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" ) // PromotionCost is one exact CharGrowthTable.ClassupItem{Type,Id,Count} row. @@ -26,24 +24,11 @@ type PromotionGrowthResult struct { // stage cap the client sends its class-up materials through CharGrowth, then // detects a promotion by comparing the response character ID to the old ID. func CharacterPromotion(root, version string, charID int, level, exp uint64) (uint64, []PromotionCost, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return 0, nil, err } - dir, err := os.MkdirTemp("", "bd2-promotion-") - if err != nil { - return 0, nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return 0, nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return 0, nil, err - } - defer db.Close() + defer release() return characterPromotion(db, charID, level, exp) } @@ -54,24 +39,11 @@ func CharacterPromotion(root, version string, charID int, level, exp uint64) (ui // CharTable.NextCharId chain; each prefix cost is then subtracted from the // submitted resources and only the remainder contributes EXP. func CharacterGrowthPromotions(root, version string, charID int, level, exp uint64, submitted []PromotionCost) (PromotionGrowthResult, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return PromotionGrowthResult{}, err } - dir, err := os.MkdirTemp("", "bd2-promotion-growth-") - if err != nil { - return PromotionGrowthResult{}, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return PromotionGrowthResult{}, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return PromotionGrowthResult{}, err - } - defer db.Close() + defer release() return characterGrowthPromotions(db, charID, level, exp, submitted) } diff --git a/go/internal/server/gamedata/quest_collections_test.go b/go/internal/server/gamedata/quest_collections_test.go index f69a8c0..80a7f96 100644 --- a/go/internal/server/gamedata/quest_collections_test.go +++ b/go/internal/server/gamedata/quest_collections_test.go @@ -13,6 +13,7 @@ func TestQuestCollectionsFollowChangedPackQuestAndItemIDs(t *testing.T) { t.Fatal(err) } defer db.Close() + db.SetMaxOpenConns(1) for _, sqlText := range []string{"CREATE TABLE QuestTable77(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CollectionTable(id INTEGER,ProtoBuf BLOB)"} { if _, err := db.Exec(sqlText); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/quest_difficulty.go b/go/internal/server/gamedata/quest_difficulty.go index d19931a..c82c763 100644 --- a/go/internal/server/gamedata/quest_difficulty.go +++ b/go/internal/server/gamedata/quest_difficulty.go @@ -3,35 +3,16 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" ) // LoadQuestDifficulties reads the pack-specific selectable difficulties; // normal (zero) is the base story chain and is absent from this table. func LoadQuestDifficulties(root, version string) (map[int]map[int]bool, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - file, err := os.CreateTemp("", "bd2-quest-difficulty-*.sqlite") - if err != nil { - return nil, err - } - name := file.Name() - defer os.Remove(name) - if _, err = file.Write(plain); err != nil { - file.Close() - return nil, err - } - if err = file.Close(); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(name)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadQuestDifficultiesDB(db) } func loadQuestDifficultiesDB(db *sql.DB) (map[int]map[int]bool, error) { diff --git a/go/internal/server/gamedata/quest_formation.go b/go/internal/server/gamedata/quest_formation.go index 1da60c9..4b4f3fa 100644 --- a/go/internal/server/gamedata/quest_formation.go +++ b/go/internal/server/gamedata/quest_formation.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" _ "modernc.org/sqlite" ) @@ -48,29 +46,11 @@ func LoadQuestFormations(root, version string, packID int) (map[int]QuestFormati if packID <= 0 { return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID) } - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - f, err := os.CreateTemp("", "bd2-quest-formation-*.sqlite") - if err != nil { - return nil, fmt.Errorf("gamedata: create quest database: %w", err) - } - name := f.Name() - defer os.Remove(name) - if _, err = f.Write(plain); err == nil { - err = f.Close() - } else { - _ = f.Close() - } - if err != nil { - return nil, fmt.Errorf("gamedata: write quest database: %w", err) - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(name)+"?mode=ro") - if err != nil { - return nil, fmt.Errorf("gamedata: open quest database: %w", err) - } - defer db.Close() + defer release() return loadQuestFormationsDB(db, packID) } @@ -87,12 +67,26 @@ func loadQuestFormationsDB(db *sql.DB, packID int) (map[int]QuestFormation, erro formations := make(map[int]QuestFormation) charGroups := make(map[uint64][]QuestCharacterDesign) storyGroups := make(map[uint64][]QuestCostumeDesign) + type formationRow struct { + id int + proto []byte + } + var sourceRows []formationRow for rows.Next() { - var questID int - var proto []byte - if err := rows.Scan(&questID, &proto); err != nil { - return nil, fmt.Errorf("gamedata: scan QuestTable%d formation: %w", packID, err) + var row formationRow + if err := rows.Scan(&row.id, &row.proto); err != nil { + return nil, err } + sourceRows = append(sourceRows, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for _, row := range sourceRows { + questID, proto := row.id, row.proto charGroupID, err := optionalScalar(proto, 4) if err != nil { return nil, fmt.Errorf("gamedata: quest %d char group: %w", questID, err) @@ -188,12 +182,26 @@ func loadStoryCostumeGroup(db *sql.DB, groupID uint64) ([]QuestCostumeDesign, er } defer rows.Close() var result []QuestCostumeDesign + type storyRow struct { + id int + proto []byte + } + var sourceRows []storyRow for rows.Next() { - var rowID int - var proto []byte - if err := rows.Scan(&rowID, &proto); err != nil { + var row storyRow + if err := rows.Scan(&row.id, &row.proto); err != nil { return nil, err } + sourceRows = append(sourceRows, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for _, row := range sourceRows { + rowID, proto := row.id, row.proto costumeID, err := requiredScalar(proto, 1) if err != nil { return nil, fmt.Errorf("story costume group %d row %d: %w", groupID, rowID, err) diff --git a/go/internal/server/gamedata/quest_formation_test.go b/go/internal/server/gamedata/quest_formation_test.go index fb3f6c4..594b006 100644 --- a/go/internal/server/gamedata/quest_formation_test.go +++ b/go/internal/server/gamedata/quest_formation_test.go @@ -17,6 +17,9 @@ func TestLoadQuestFormationsDB(t *testing.T) { t.Fatal(err) } defer db.Close() + // Match production's single-connection database lease so nested formation + // and costume lookups require source rows to be released first. + db.SetMaxOpenConns(1) for _, statement := range []string{ "CREATE TABLE QuestTable21 (id INTEGER PRIMARY KEY, ProtoBuf BLOB NOT NULL)", "CREATE TABLE CharGroupTable (id INTEGER NOT NULL, GroupId INTEGER NOT NULL, ProtoBuf BLOB NOT NULL)", diff --git a/go/internal/server/gamedata/quests.go b/go/internal/server/gamedata/quests.go index d359990..1a9eca4 100644 --- a/go/internal/server/gamedata/quests.go +++ b/go/internal/server/gamedata/quests.go @@ -3,8 +3,6 @@ package gamedata import ( "database/sql" "fmt" - "os" - "path/filepath" _ "modernc.org/sqlite" ) @@ -26,24 +24,11 @@ func LoadQuestDesign(root, version string, packID int) (map[int]QuestDesign, err if packID <= 0 { return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID) } - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-quests-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "quests.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() return loadQuestDesignDB(db, packID) } @@ -56,13 +41,29 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) { return nil, fmt.Errorf("gamedata: query QuestTable%d: %w", packID, err) } defer rows.Close() - result := make(map[int]QuestDesign) + type questRow struct { + id int + proto []byte + } + var questRows []questRow for rows.Next() { - var id int - var proto []byte - if err := rows.Scan(&id, &proto); err != nil { + var row questRow + if err := rows.Scan(&row.id, &row.proto); err != nil { return nil, err } + questRows = append(questRows, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + // Collection lookups reuse the database's single connection after the + // quest result set is closed. + result := make(map[int]QuestDesign) + for _, row := range questRows { + id, proto := row.id, row.proto if id <= 0 { continue } diff --git a/go/internal/server/gamedata/random_box.go b/go/internal/server/gamedata/random_box.go index 6122b64..adb3024 100644 --- a/go/internal/server/gamedata/random_box.go +++ b/go/internal/server/gamedata/random_box.go @@ -1,11 +1,8 @@ package gamedata import ( - "database/sql" "errors" "fmt" - "os" - "path/filepath" _ "modernc.org/sqlite" ) @@ -24,24 +21,11 @@ type RandomBoxDesign struct { // empty, weighted, malformed, cyclic, or resolves to a non-ItemDBInfo reward; // callers then fail closed when a client tries to open it. func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) { - plain, err := ReadQuestDatabase(root, version) + db, release, err := OpenDatabase(root, version, "common") if err != nil { return nil, err } - dir, err := os.MkdirTemp("", "bd2-random-box-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, err - } - defer db.Close() + defer release() groups := map[uint64][]BattleReward{} rows, err := db.Query("SELECT id, ProtoBuf FROM RewardGroupTable") diff --git a/go/internal/server/gamedata/recruit_npc.go b/go/internal/server/gamedata/recruit_npc.go index 0412723..1659a05 100644 --- a/go/internal/server/gamedata/recruit_npc.go +++ b/go/internal/server/gamedata/recruit_npc.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "math" - "os" - "path/filepath" ) type RecruitNPC struct { @@ -20,24 +18,11 @@ func LoadRecruitNPC(root, version string, packID int, npcID uint64) (RecruitNPC, if packID <= 0 || npcID == 0 || npcID > math.MaxInt32 { return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC") } - plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID)) + db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID)) if err != nil { return RecruitNPC{}, err } - dir, err := os.MkdirTemp("", "bd2-recruit-npc-") - if err != nil { - return RecruitNPC{}, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "pack.db") - if err = os.WriteFile(path, plain, 0o600); err != nil { - return RecruitNPC{}, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return RecruitNPC{}, err - } - defer db.Close() + defer release() npc, err := loadRecruitNPC(db, npcID) if err != nil { return RecruitNPC{}, err diff --git a/go/internal/server/gamedata/reward_costume.go b/go/internal/server/gamedata/reward_costume.go new file mode 100644 index 0000000..0f5812e --- /dev/null +++ b/go/internal/server/gamedata/reward_costume.go @@ -0,0 +1,73 @@ +package gamedata + +// LoadRewardCostumeCatalog covers every installed costume, including event-only +// rewards that never occur in a regular gacha pool. +func LoadRewardCostumeCatalog(root, version string) (*RegularGachaCatalog, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + chars, e := db.Query("SELECT uniqueCharId,ProtoBuf FROM CharTable") + if e != nil { + return nil, e + } + eligible := map[uint64]uint64{} + for chars.Next() { + var unique uint64 + var raw []byte + if e = chars.Scan(&unique, &raw); e != nil { + chars.Close() + return nil, e + } + growth, e := optionalScalar(raw, 10) + if e != nil { + chars.Close() + return nil, e + } + temporary, e := optionalScalar(raw, 21) + if e != nil { + chars.Close() + return nil, e + } + if growth == 1 && temporary == 0 { + eligible[unique]++ + } + } + if e = chars.Err(); e != nil { + chars.Close() + return nil, e + } + chars.Close() + rows, err := db.Query("SELECT id,useUniqueCharId FROM CostumeTable ORDER BY id") + if err != nil { + return nil, err + } + var ids []uint64 + for rows.Next() { + var id, unique uint64 + if err = rows.Scan(&id, &unique); err != nil { + rows.Close() + return nil, err + } + if eligible[unique] == 1 { + ids = append(ids, id) + } + } + if err = rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + out := &RegularGachaCatalog{characters: map[uint64]CharacterDesign{}} + for _, id := range ids { + design, e := loadGachaCharacterDesign(db, id) + if e != nil { + // Non-player prototype costumes lack a grantable growth boundary. + // They remain unavailable to Economy rather than inventing metadata. + continue + } + out.characters[id] = design + } + return out, nil +} diff --git a/go/internal/server/gamedata/reward_costume_test.go b/go/internal/server/gamedata/reward_costume_test.go new file mode 100644 index 0000000..ec61c29 --- /dev/null +++ b/go/internal/server/gamedata/reward_costume_test.go @@ -0,0 +1,24 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestRewardCostumeCatalogInstalledSmoke(t *testing.T) { + root := os.Getenv("BD2_GAMEDATA_ROOT") + version := os.Getenv("BD2_GAMEDATA_VERSION") + if root == "" || version == "" { + t.Skip("explicit installed GameData integration environment required") + } + d, e := LoadRewardCostumeCatalog(root, version) + if e != nil { + t.Fatal(e) + } + if len(d.characters) == 0 { + t.Fatal("empty catalog") + } + if _, ok := d.Character(60601); !ok { + t.Fatal("installed event costume omitted") + } +} diff --git a/go/internal/server/gamedata/reward_equipment.go b/go/internal/server/gamedata/reward_equipment.go new file mode 100644 index 0000000..e97ed74 --- /dev/null +++ b/go/internal/server/gamedata/reward_equipment.go @@ -0,0 +1,38 @@ +package gamedata + +// LoadRewardEquipmentCatalog reads every equipment definition for rewards, +// independently of which gacha banners the server has opened. +func LoadRewardEquipmentCatalog(root, version string) (*EquipmentGachaCatalog, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + rows, err := db.Query("SELECT id FROM EquipmentTable ORDER BY id") + if err != nil { + return nil, err + } + var ids []uint64 + for rows.Next() { + var id uint64 + if err = rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + if err = rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err = rows.Close(); err != nil { + return nil, err + } + c := &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}} + for _, id := range ids { + if err = c.loadEquipmentTree(db, WeightedEquipment{ID: id}); err != nil { + return nil, err + } + } + return c, nil +} diff --git a/go/internal/server/gamedata/reward_equipment_test.go b/go/internal/server/gamedata/reward_equipment_test.go new file mode 100644 index 0000000..778c1c6 --- /dev/null +++ b/go/internal/server/gamedata/reward_equipment_test.go @@ -0,0 +1,25 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestInstalledRewardEquipmentCatalog(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("explicit installed GameData integration environment required") + } + d, err := LoadRewardEquipmentCatalog(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + if len(d.equipment) == 0 { + t.Fatal("empty equipment catalog") + } + for id := range d.equipment { + if _, _, _, err := d.RollOptions(id); err != nil { + t.Fatalf("equipment %d: %v", id, err) + } + } +} diff --git a/go/internal/server/gamedata/reward_graph.go b/go/internal/server/gamedata/reward_graph.go new file mode 100644 index 0000000..a5808dc --- /dev/null +++ b/go/internal/server/gamedata/reward_graph.go @@ -0,0 +1,239 @@ +package gamedata + +import ( + "crypto/rand" + "fmt" + "math" + "math/big" + "sort" + "sync" +) + +// RewardGraph resolves random-box nodes; costume/equipment leaves retain +// their design IDs for the owning grant domain. +type RewardGraph struct { + mu sync.Mutex + boxes map[uint64]uint64 + direct map[uint64]bool + groups map[uint64][]byte + sample func(uint64) (uint64, error) +} + +func LoadRewardGraph(root, version string) (*RewardGraph, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + g := &RewardGraph{boxes: map[uint64]uint64{}, direct: map[uint64]bool{}, groups: map[uint64][]byte{}} + g.sample = func(n uint64) (uint64, error) { + if n == 0 { + return 0, fmt.Errorf("gamedata: empty reward pool") + } + v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) + if e != nil { + return 0, e + } + return v.Uint64(), nil + } + rows, e := db.Query("SELECT id,ProtoBuf FROM RandomBoxTable") + if e != nil { + return nil, e + } + for rows.Next() { + var id uint64 + var raw []byte + if e = rows.Scan(&id, &raw); e != nil { + rows.Close() + return nil, e + } + gid, e := optionalScalar(raw, 9) + if e != nil { + rows.Close() + return nil, e + } + drop, err := optionalScalar(raw, 1) + if err != nil { + rows.Close() + return nil, err + } + g.direct[id] = drop == 1 + g.boxes[id] = gid + } + e = rows.Err() + rows.Close() + if e != nil { + return nil, e + } + rows, e = db.Query("SELECT id,ProtoBuf FROM RewardGroupTable") + if e != nil { + return nil, e + } + for rows.Next() { + var id uint64 + var raw []byte + if e = rows.Scan(&id, &raw); e != nil { + rows.Close() + return nil, e + } + g.groups[id] = append([]byte(nil), raw...) + } + e = rows.Err() + rows.Close() + return g, e +} +func (g *RewardGraph) SetSampler(f func(uint64) (uint64, error)) { + g.mu.Lock() + defer g.mu.Unlock() + g.sample = f +} +func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) { + g.mu.Lock() + defer g.mu.Unlock() + budget := uint64(100000) + totals := map[[2]uint64]uint64{} + seen := map[uint64]bool{} + var emit func(BattleReward) error + emit = func(r BattleReward) error { + if r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 0 { + return fmt.Errorf("gamedata: invalid reward graph quantity/type") + } + if budget == 0 { + return fmt.Errorf("gamedata: reward graph operation limit") + } + budget-- + if r.Type != 9 { + k := [2]uint64{r.Type, r.ID} + if totals[k] > math.MaxInt32-r.Count { + return fmt.Errorf("gamedata: reward graph overflow") + } + totals[k] += r.Count + return nil + } + if seen[r.ID] || len(seen) >= 32 { + return fmt.Errorf("gamedata: reward graph cycle") + } + gid, ok := g.boxes[r.ID] + if !ok { + return fmt.Errorf("gamedata: unknown random box %d", r.ID) + } + raw, ok := g.groups[gid] + if !ok { + return fmt.Errorf("gamedata: missing reward group %d", gid) + } + drop, e := optionalScalar(raw, 2) + if e != nil { + return e + } + count, e := optionalScalar(raw, 1) + if e != nil { + return e + } + children, e := eventGameRewards(raw, 6, 5, 4) + if e != nil || len(children) == 0 { + return fmt.Errorf("gamedata: invalid random box reward group %d", gid) + } + weights, e := packedInts(raw, 8) + if e != nil { + return e + } + if drop > 1 || len(weights) != len(children) || drop == 0 && count == 0 { + return fmt.Errorf("gamedata: invalid reward group selection mode") + } + seen[r.ID] = true + defer delete(seen, r.ID) + for n := uint64(0); n < r.Count; n++ { + if budget == 0 { + return fmt.Errorf("gamedata: reward graph operation limit") + } + budget-- + if drop == 1 { + // Local server policy: direct components independently roll their GameData percentage. + for i, child := range children { + if weights[i] > 100 { + return fmt.Errorf("gamedata: direct reward percent exceeds 100") + } + x, e := g.sample(100) + if e != nil { + return e + } + if x >= 100 { + return fmt.Errorf("gamedata: sampler out of range") + } + if x >= weights[i] { + continue + } + if e = emit(child); e != nil { + return e + } + } + continue + } + sum := uint64(0) + for _, w := range weights { + if sum > math.MaxUint64-w { + return fmt.Errorf("gamedata: reward graph weight overflow") + } + sum += w + } + if sum == 0 || g.sample == nil { + return fmt.Errorf("gamedata: empty weighted reward pool") + } + for i := uint64(0); i < count; i++ { + if budget == 0 { + return fmt.Errorf("gamedata: reward graph operation limit") + } + x, e := g.sample(sum) + if e != nil { + return e + } + if x >= sum { + return fmt.Errorf("gamedata: random result outside reward pool") + } + for j, w := range weights { + if x < w { + if e = emit(children[j]); e != nil { + return e + } + break + } + x -= w + } + } + } + return nil + } + for _, r := range rewards { + if e := emit(r); e != nil { + return nil, e + } + } + var out []BattleReward + for k, v := range totals { + out = append(out, BattleReward{Type: k[0], ID: k[1], Count: v}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Type != out[j].Type { + return out[i].Type < out[j].Type + } + return out[i].ID < out[j].ID + }) + return out, nil +} + +// ResolveGranted preserves manually opened boxes; only RbdDirect boxes expand. +func (g *RewardGraph) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) { + var out []BattleReward + for _, r := range rewards { + if r.Type == 9 && !g.direct[r.ID] { + out = append(out, r) + continue + } + expanded, e := g.Resolve([]BattleReward{r}) + if e != nil { + return nil, e + } + out = append(out, expanded...) + } + return out, nil +} diff --git a/go/internal/server/gamedata/reward_graph_test.go b/go/internal/server/gamedata/reward_graph_test.go new file mode 100644 index 0000000..b87732e --- /dev/null +++ b/go/internal/server/gamedata/reward_graph_test.go @@ -0,0 +1,60 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "os" + "testing" +) + +func group(drop, count uint64, rs []BattleReward, ws []uint64) []byte { + b := wire.AppendVarint(nil, 2, drop) + b = wire.AppendVarint(b, 1, count) + for _, r := range rs { + b = wire.AppendVarint(b, 6, r.Type) + b = wire.AppendVarint(b, 5, r.ID) + b = wire.AppendVarint(b, 4, r.Count) + } + for _, w := range ws { + b = wire.AppendVarint(b, 8, w) + } + return b +} +func TestRewardGraphWeightedNestedAndEquipmentLeaves(t *testing.T) { + g := &RewardGraph{boxes: map[uint64]uint64{1: 1, 2: 2}, groups: map[uint64][]byte{1: group(0, 1, []BattleReward{{9, 2, 1}, {8, 8, 2}}, []uint64{1, 3}), 2: group(1, 0, []BattleReward{{10, 10, 1}, {11, 11, 1}}, []uint64{100, 100})}, sample: func(uint64) (uint64, error) { return 0, nil }} + out, e := g.Resolve([]BattleReward{{9, 1, 2}}) + if e != nil || len(out) != 2 || out[0].Type != 10 || out[0].Count != 2 || out[1].Type != 11 { + t.Fatalf("out=%v e=%v", out, e) + } + g.SetSampler(func(n uint64) (uint64, error) { return n - 1, nil }) + out, e = g.Resolve([]BattleReward{{9, 1, 1}}) + if e != nil || len(out) != 1 || out[0].Type != 8 || out[0].Count != 2 { + t.Fatalf("out=%v e=%v", out, e) + } +} +func TestRewardGraphCycleBadRandomAndOverflow(t *testing.T) { + g := &RewardGraph{boxes: map[uint64]uint64{1: 1}, groups: map[uint64][]byte{1: group(1, 0, []BattleReward{{9, 1, 1}}, nil)}} + if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil { + t.Fatal("cycle accepted") + } + g.groups[1] = group(0, 1, []BattleReward{{8, 1, 1}}, []uint64{1}) + g.sample = func(n uint64) (uint64, error) { return n, nil } + if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil { + t.Fatal("bad random accepted") + } + if _, e := g.Resolve([]BattleReward{{8, 1, 2147483647}, {8, 1, 1}}); e == nil { + t.Fatal("overflow accepted") + } +} +func TestRewardGraphInstalledLoad(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not set") + } + g, e := LoadRewardGraph(root, "20260923193640") + if e != nil { + t.Fatal(e) + } + if len(g.boxes) == 0 || len(g.groups) == 0 { + t.Fatal("empty graph") + } +} diff --git a/go/internal/server/gamedata/reward_skin.go b/go/internal/server/gamedata/reward_skin.go new file mode 100644 index 0000000..623c5cf --- /dev/null +++ b/go/internal/server/gamedata/reward_skin.go @@ -0,0 +1,28 @@ +package gamedata + +func LoadPrestigeSkins(root, version string) (map[uint64]uint64, error) { + db, done, e := openStatDatabase(root, version) + if e != nil { + return nil, e + } + defer done() + rows, e := db.Query("SELECT id,ProtoBuf FROM PrestigeSkinTable") + if e != nil { + return nil, e + } + defer rows.Close() + out := map[uint64]uint64{} + for rows.Next() { + var id uint64 + var raw []byte + if e = rows.Scan(&id, &raw); e != nil { + return nil, e + } + costume, e := optionalScalar(raw, 1) + if e != nil { + return nil, e + } + out[id] = costume + } + return out, rows.Err() +} diff --git a/go/internal/server/gamedata/stat_design.go b/go/internal/server/gamedata/stat_design.go index a7d2513..df61976 100644 --- a/go/internal/server/gamedata/stat_design.go +++ b/go/internal/server/gamedata/stat_design.go @@ -5,8 +5,6 @@ import ( "encoding/binary" "fmt" "math" - "os" - "path/filepath" "bd2server/internal/server/wire" _ "modernc.org/sqlite" @@ -326,23 +324,5 @@ func fixed32Floats(proto []byte, number int) ([]float64, error) { } func openStatDatabase(root, version string) (*sql.DB, func(), error) { - plain, err := ReadQuestDatabase(root, version) - if err != nil { - return nil, nil, err - } - dir, err := os.MkdirTemp("", "bd2-stats-") - if err != nil { - return nil, nil, err - } - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - os.RemoveAll(dir) - return nil, nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - os.RemoveAll(dir) - return nil, nil, err - } - return db, func() { _ = db.Close(); _ = os.RemoveAll(dir) }, nil + return OpenDatabase(root, version, "common") } diff --git a/go/internal/server/gamedata/waypoint.go b/go/internal/server/gamedata/waypoint.go index 57c289d..643f127 100644 --- a/go/internal/server/gamedata/waypoint.go +++ b/go/internal/server/gamedata/waypoint.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "math" - "os" - "path/filepath" ) // WaypointPack uses the per-pack FieldWaypointTable and common PackTable. @@ -43,24 +41,11 @@ func LoadWaypointPack(root, version string, packID uint64) (WaypointPack, error) *target = values[0] } } - plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID)) + db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID)) if err != nil { return WaypointPack{}, err } - dir, err := os.MkdirTemp("", "bd2-waypoint-") - if err != nil { - return WaypointPack{}, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "pack.db") - if err = os.WriteFile(path, plain, 0600); err != nil { - return WaypointPack{}, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return WaypointPack{}, err - } - defer db.Close() + defer release() design.Points, err = loadWaypointPoints(db) return design, err } diff --git a/go/internal/server/gamedata/waypoint_cache_benchmark_test.go b/go/internal/server/gamedata/waypoint_cache_benchmark_test.go new file mode 100644 index 0000000..0619cc0 --- /dev/null +++ b/go/internal/server/gamedata/waypoint_cache_benchmark_test.go @@ -0,0 +1,69 @@ +package gamedata + +import ( + "os" + "testing" +) + +// Opt-in performance verification, not a source for game design assertions. +func BenchmarkInstalledWaypointPackWarm(b *testing.B) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + b.Skip("BD2_REAL_GAMEDATA not configured") + } + if err := CloseDatabaseCache(); err != nil { + b.Fatal(err) + } + b.Cleanup(func() { + if err := CloseDatabaseCache(); err != nil { + b.Error(err) + } + }) + if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkInstalledWaypointPackCold(b *testing.B) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + b.Skip("BD2_REAL_GAMEDATA not configured") + } + b.Cleanup(func() { _ = CloseDatabaseCache() }) + for i := 0; i < b.N; i++ { + b.StopTimer() + if err := CloseDatabaseCache(); err != nil { + b.Fatal(err) + } + b.StartTimer() + if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkInstalledPackDetailWarm(b *testing.B) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + b.Skip("BD2_REAL_GAMEDATA not configured") + } + if err := CloseDatabaseCache(); err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = CloseDatabaseCache() }) + if _, err := LoadPackDetailDesign(root, "20260923193640", 21); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := LoadPackDetailDesign(root, "20260923193640", 21); err != nil { + b.Fatal(err) + } + } +} diff --git a/go/internal/server/hunting/ap_refresh.go b/go/internal/server/hunting/ap_refresh.go new file mode 100644 index 0000000..bf358a8 --- /dev/null +++ b/go/internal/server/hunting/ap_refresh.go @@ -0,0 +1,89 @@ +package hunting + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "encoding/json" + "fmt" + "time" +) + +type apClock struct { + Version string `json:"version"` + Next int64 `json:"next"` +} + +func (s *Service) AttachAPRefresh(design gamedata.HuntingAPDesign) error { + s.mu.Lock() + defer s.mu.Unlock() + if design.Max == 0 || design.Max > 2147483647 || design.ResetSeconds < 0 || design.ResetSeconds >= 86400 { + return fmt.Errorf("hunting: invalid AP refresh design") + } + s.apDesign = &design + s.now = time.Now + return nil +} + +// The client clock is UTC+9. DailyResetTime is expressed in that clock. +func (s *Service) refreshAP() error { + if s.apDesign == nil { + return nil + } + now := s.now().UnixMilli() + offset := (s.apDesign.ResetSeconds - 9*3600) * 1000 + day := (now - offset) / 86400000 + nextTime := (day+1)*86400000 + offset + b, e := s.storage.Load("huntingapclock") + if e != nil { + return e + } + clock := apClock{versionconfig.State(), nextTime} + if b != nil { + if e = stateio.RequireExactJSONObject(b, "version", "next"); e != nil { + return e + } + if e = json.Unmarshal(b, &clock); e != nil { + return e + } + if clock.Version != versionconfig.State() || clock.Next <= 0 { + return fmt.Errorf("hunting: invalid AP clock") + } + if now < clock.Next { + return nil + } + next := s.clone() + next.Free = s.apDesign.Max + if e = s.persist(next); e != nil { + return e + } + clock.Next = nextTime + } + raw, e := json.Marshal(clock) + if e != nil { + return e + } + return s.storage.Save("huntingapclock", raw) +} + +func (s *Service) APChargeInfo() ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if e := s.refreshAP(); e != nil { + return nil, e + } + raw, e := s.storage.Load("huntingapclock") + if e != nil || raw == nil { + return nil, e + } + var clock apClock + if e = json.Unmarshal(raw, &clock); e != nil { + return nil, e + } + b := wire.AppendVarint(nil, 1, uint64(clock.Next-86400000)) + item := wire.AppendVarint(nil, 3, 21) + item = wire.AppendVarint(item, 4, s.state.Free) + b = wire.AppendBytes(b, 2, item) + return wire.AppendBytes(nil, 1, b), nil +} diff --git a/go/internal/server/hunting/dispatch.go b/go/internal/server/hunting/dispatch.go index 600cca6..c8354e9 100644 --- a/go/internal/server/hunting/dispatch.go +++ b/go/internal/server/hunting/dispatch.go @@ -41,7 +41,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ if strings.HasPrefix(path, "/HuntDispatch") { return s.handleDispatch(path, req, session) } - return s.Handle(path, req) + return s.handleGroundSession(path, req, session) } func (s *Service) handleDispatch(path string, req []byte, session string) (int, []byte, bool, error) { code := map[string]int{"/HuntDispatchInfo": 189, "/HuntDispatchStart": 190, "/HuntDispatchEnd": 191, "/HuntDispatchRewardPreview": 194, "/HuntDispatch": 0}[path] @@ -50,6 +50,9 @@ func (s *Service) handleDispatch(path string, req []byte, session string) (int, } s.mu.Lock() defer s.mu.Unlock() + if e := s.refreshAP(); e != nil { + return code, nil, true, e + } seq, found, err := wire.Varint(req, 1) if err != nil || !found || seq == 0 { return code, nil, true, fmt.Errorf("hunting: missing sequence") @@ -264,11 +267,30 @@ func (s *Service) handleDispatch(path string, req []byte, session string) (int, ds.Jobs[jobKey] = job out = wire.AppendBytes(out, 1, dispatchJobWire(job)) } else { + beforeFree, beforeBonus := s.state.Free, s.state.Bonus bundle, e := s.dispatchGrant("dispatch:"+key, rewards) if e != nil { return code, nil, true, e } out = wire.AppendBytes(out, 1, bundle) + if s.state.Free >= beforeFree { + next.Free += s.state.Free - beforeFree + } else { + delta := beforeFree - s.state.Free + if next.Free < delta { + return code, nil, true, fmt.Errorf("hunting: AP grant conflict") + } + next.Free -= delta + } + if s.state.Bonus >= beforeBonus { + next.Bonus += s.state.Bonus - beforeBonus + } else { + delta := beforeBonus - s.state.Bonus + if next.Bonus < delta { + return code, nil, true, fmt.Errorf("hunting: AP grant conflict") + } + next.Bonus -= delta + } } if e = s.persist(next); e != nil { return code, nil, true, e @@ -316,6 +338,31 @@ func dispatchPreview(rs []gamedata.BattleReward) []byte { return out } func (s *Service) dispatchGrant(identity string, rs []gamedata.BattleReward) ([]byte, error) { + if s.grant != nil { + var rewards []gamedata.Reward + var ap []gamedata.BattleReward + for _, r := range rs { + if r.Type == 21 || r.Type == 23 { + ap = append(ap, r) + } else { + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + } + } + s.mu.Unlock() + bundle, err := s.grant(identity, rewards) + s.mu.Lock() + if err != nil { + return nil, err + } + bundle = append(bundle, dispatchPreview(ap)...) + for _, r := range ap { + v := wire.AppendVarint(nil, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, v) + } + return bundle, nil + } + var currency []gamedata.Reward var items []gamedata.BattleReward for _, r := range rs { diff --git a/go/internal/server/hunting/hunting.go b/go/internal/server/hunting/hunting.go index d019c6e..d35adb5 100644 --- a/go/internal/server/hunting/hunting.go +++ b/go/internal/server/hunting/hunting.go @@ -13,6 +13,7 @@ import ( "math" "strconv" "sync" + "time" ) const BattleMode uint64 = 5 @@ -39,6 +40,10 @@ type Service struct { load func(int) (*gamedata.HuntingPack, error) dispatchLoad func(uint64, uint64) (*gamedata.DispatchDesign, error) dispatchEligibility func(*gamedata.DispatchDesign) error + eligibility func(int, uint64) error + grant func(string, []gamedata.Reward) ([]byte, error) + apDesign *gamedata.HuntingAPDesign + now func() time.Time } func Open(store stateio.Store, root, version string, inventory *player.Inventory, wallet *player.Wallet, currentPack func() (int, error), free, bonus uint64) (*Service, error) { @@ -108,6 +113,9 @@ func Open(store stateio.Store, root, version string, inventory *player.Inventory func (s *Service) HuntingAP() (uint64, uint64, error) { s.mu.Lock() defer s.mu.Unlock() + if e := s.refreshAP(); e != nil { + return 0, 0, e + } return s.state.Free, s.state.Bonus, nil } func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { @@ -140,6 +148,7 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { return 387, nil, true, err } if len(d.Grounds) == 0 { + response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 5, id)) continue } info, err := s.info(int(id), d) @@ -158,13 +167,18 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { if err != nil { return 134, nil, true, err } - if len(d.Grounds) == 0 { - return 134, nil, true, fmt.Errorf("hunting: pack has no hunting ground") - } if path == "/HuntingGroundInfo" { + // A catalog entry is not an account's active hunting run. The detail + // endpoint preserves an explicitly present empty message until entry. + if s.state.Packs[strconv.Itoa(int(pack))].Current == 0 { + return 134, wire.AppendBytes(nil, 1, nil), true, nil + } b, err := s.info(int(pack), d) return 134, wire.AppendBytes(nil, 1, b), true, err } + if len(d.Grounds) == 0 { + return 110, nil, true, fmt.Errorf("hunting: pack has no hunting ground") + } current, err := s.currentPack() if err != nil || current != int(pack) { return 110, nil, true, fmt.Errorf("hunting: enter pack is not current") @@ -182,20 +196,17 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { if !ok { return 110, nil, true, fmt.Errorf("hunting: unknown ground") } - first := d.Grounds[0].ID - if id != first && id > st.Highest { - previous := uint64(0) - for _, v := range d.Grounds { - if v.ID < id { - previous = v.ID - } - } - if previous == 0 || previous > st.Highest { - return 110, nil, true, fmt.Errorf("hunting: ground difficulty locked") + if s.eligibility != nil { + if err := s.eligibility(int(pack), g.Difficulty); err != nil { + return 110, nil, true, err } + } else if id != d.Grounds[0].ID { + return 110, nil, true, fmt.Errorf("hunting: main quest difficulty eligibility unavailable") + } + if st.Current != id { + st.Defeated = nil } st.Current, st.Auto = id, auto != 0 - st.Defeated = nil next := s.clone() next.Packs[strconv.Itoa(int(pack))] = st if err := s.persist(next); err != nil { @@ -209,6 +220,7 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { } func (s *Service) info(pack int, d *gamedata.HuntingPack) ([]byte, error) { st := s.state.Packs[strconv.Itoa(pack)] + initial := st.Current == 0 if st.Current == 0 { st.Current = d.Grounds[0].ID } @@ -217,12 +229,24 @@ func (s *Service) info(pack int, d *gamedata.HuntingPack) ([]byte, error) { return nil, fmt.Errorf("hunting: saved ground missing from GameData") } b := wire.AppendVarint(nil, 2, st.Current) - b = wire.AppendVarint(b, 3, st.Highest) + if st.Highest != 0 { + b = wire.AppendVarint(b, 3, st.Highest) + } b = wire.AppendVarint(b, 5, uint64(pack)) if st.Auto { b = wire.AppendVarint(b, 1, 1) } - for _, m := range monstersWithState(d, g, st.Defeated) { + var monsterInfos [][]byte + if initial { + // The initial catalog lists ordinary monsters only. A boss is not an + // active account encounter simply because its design row exists. + for _, id := range g.Monsters { + monsterInfos = append(monsterInfos, monsterWire(d.Monsters[id], true)) + } + } else { + monsterInfos = monstersWithState(d, g, st.Defeated) + } + for _, m := range monsterInfos { b = wire.AppendBytes(b, 4, m) } return b, nil @@ -247,16 +271,22 @@ func monsters(d *gamedata.HuntingPack, g gamedata.HuntingGround) [][]byte { return monstersWithState(d, g, nil) } func monstersWithState(d *gamedata.HuntingPack, g gamedata.HuntingGround, defeated []uint64) [][]byte { - ids := append(append([]uint64(nil), g.Monsters...), g.BossID) - out := make([][]byte, 0, len(ids)) - for _, id := range ids { - active := true - for _, dead := range defeated { - if dead == id { - active = false - } - } - out = append(out, monsterWire(d.Monsters[id], active)) + dead := map[uint64]bool{} + for _, id := range defeated { + dead[id] = true + } + boss := len(g.Monsters) == 0 + all := true + for _, id := range g.Monsters { + all = all && dead[id] + } + boss = boss || all + out := make([][]byte, 0, len(g.Monsters)+1) + for _, id := range g.Monsters { + out = append(out, monsterWire(d.Monsters[id], !dead[id])) + } + if boss { + out = append(out, monsterWire(d.Monsters[g.BossID], true)) } return out } @@ -266,6 +296,9 @@ func (s *Service) ValidateBattle(pack int, mode, monster, deck uint64) error { } s.mu.Lock() defer s.mu.Unlock() + if e := s.refreshAP(); e != nil { + return e + } _, _, _, err := s.validate(pack, monster, deck) return err } @@ -284,6 +317,17 @@ func (s *Service) validate(pack int, monster, deck uint64) (*gamedata.HuntingPac member = member || id == monster } m := d.Monsters[monster] + if monster == g.BossID { + for _, id := range g.Monsters { + done := false + for _, dead := range st.Defeated { + done = done || dead == id + } + if !done { + return nil, g, m, fmt.Errorf("hunting: boss locked until ordinary monsters are defeated") + } + } + } for _, dead := range st.Defeated { if dead == monster { return nil, g, m, fmt.Errorf("hunting: defeated monster requires reentry") @@ -314,8 +358,16 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s if receipt == "" { return nil, nil, fmt.Errorf("hunting: missing battle receipt") } - if s.state.Receipts[receipt] { - return nil, nil, nil + fingerprint := fmt.Sprintf("%d:%d:%d:%d", pack, mode, monster, deck) + ledger, err := s.loadBattleReceipts() + if err != nil { + return nil, nil, err + } + if saved, ok := ledger[receipt]; ok { + if saved.Fingerprint != fingerprint { + return nil, nil, fmt.Errorf("hunting: battle receipt conflict") + } + return saved.Bundle, saved.Monsters, nil } d, g, m, err := s.validate(pack, monster, deck) if err != nil { @@ -332,15 +384,30 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s stack = append(stack, r) } } - if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil { - return nil, nil, err - } - items, err := s.inventory.GrantOnce(identity+":items", stack) - if err != nil { - return nil, nil, err - } - if len(items) == 0 { - items = s.inventory.GrantedItems(identity + ":items") + var grantedBundle []byte + var items []player.Item + if s.grant != nil { + rs := make([]gamedata.Reward, len(rewards)) + for i, r := range rewards { + rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} + } + s.mu.Unlock() + grantedBundle, err = s.grant(identity, rs) + s.mu.Lock() + if err != nil { + return nil, nil, err + } + } else { + if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil { + return nil, nil, err + } + items, err = s.inventory.GrantOnce(identity+":items", stack) + if err != nil { + return nil, nil, err + } + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } } next := s.clone() cost := d.NormalAP @@ -353,21 +420,21 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s next.Bonus -= cost - next.Free next.Free = 0 } + st := next.Packs[strconv.Itoa(pack)] if monster == g.BossID { - st := next.Packs[strconv.Itoa(pack)] if st.Highest < g.ID { st.Highest = g.ID } - next.Packs[strconv.Itoa(pack)] = st + st.Defeated = nil + } else { + st.Defeated = append(append([]uint64(nil), st.Defeated...), monster) } - next.Receipts[receipt] = true - st := next.Packs[strconv.Itoa(pack)] - st.Defeated = append(append([]uint64(nil), st.Defeated...), monster) next.Packs[strconv.Itoa(pack)] = st - if err := s.persist(next); err != nil { - return nil, nil, err - } + next.Receipts[receipt] = true var bundle []byte + if s.grant != nil { + bundle = grantedBundle + } for _, item := range items { bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) view := wire.AppendVarint(nil, 2, item.ID) @@ -376,11 +443,36 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s bundle = wire.AppendBytes(bundle, 6, view) } for _, r := range currency { + if s.grant != nil { + break + } b := wire.AppendVarint(nil, 3, r.Type) b = wire.AppendVarint(b, 4, r.Count) bundle = wire.AppendBytes(bundle, 1, b) } - return bundle, [][]byte{monsterWire(m, false)}, nil + updates := [][]byte{monsterWire(m, false)} + if monster == g.BossID { + for _, id := range g.Monsters { + updates = append(updates, monsterWire(d.Monsters[id], true)) + } + } else if len(st.Defeated) == len(g.Monsters) { + updates = append(updates, monsterWire(d.Monsters[g.BossID], true)) + } + if err := s.persist(next); err != nil { + return nil, nil, err + } + latest, err := s.loadBattleReceipts() + if err != nil { + return nil, nil, err + } + for key, value := range latest { + ledger[key] = value + } + ledger[receipt] = battleReceipt{Fingerprint: fingerprint, Bundle: bundle, Monsters: updates} + if err := s.saveBattleReceipts(ledger); err != nil { + return nil, nil, err + } + return bundle, updates, nil } func (s *Service) clone() snapshot { next := s.state diff --git a/go/internal/server/hunting/hunting_test.go b/go/internal/server/hunting/hunting_test.go index 0bda5a4..be7caa0 100644 --- a/go/internal/server/hunting/hunting_test.go +++ b/go/internal/server/hunting/hunting_test.go @@ -5,9 +5,86 @@ import ( "bd2server/internal/server/player" "bd2server/internal/server/stateio" "bd2server/internal/server/wire" + "bytes" + "fmt" "testing" + "time" ) +func TestHuntingCatalogPlaceholdersAndDetailRequireEnteredRun(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + wallet, _ := player.OpenWallet(store, player.Currency{}) + s, err := Open(store, "", "", inv, wallet, func() (int, error) { return 73, nil }, 20, 5) + if err != nil { + t.Fatal(err) + } + d := &gamedata.HuntingPack{Grounds: []gamedata.HuntingGround{{ID: 9, MapID: 123, BossID: 41, Monsters: []uint64{31, 32, 33, 34, 35}}}, Monsters: map[uint64]gamedata.HuntingMonster{}} + for _, id := range []uint64{31, 32, 33, 34, 35, 41} { + d.Monsters[id] = gamedata.HuntingMonster{ID: id, Decks: []uint64{id + 100}} + } + s.load = func(pack int) (*gamedata.HuntingPack, error) { + switch pack { + case 73: + return d, nil + case 84, 85: + return &gamedata.HuntingPack{}, nil + default: + return nil, fmt.Errorf("unknown pack") + } + } + list := wire.AppendVarint(nil, 1, 1) + for _, id := range []uint64{84, 73, 85} { + list = wire.AppendVarint(list, 2, id) + } + code, response, ok, err := s.Handle("/HuntingGroundInfoList", list) + if err != nil || !ok || code != 387 { + t.Fatalf("list %d %v %v", code, ok, err) + } + var entries [][]byte + if err := wire.Walk(response, func(f wire.Field) error { + if f.Number == 1 { + entries = append(entries, f.Value) + } + return nil + }); err != nil { + t.Fatal(err) + } + if len(entries) != 3 || !bytes.Equal(entries[0], wire.AppendVarint(nil, 5, 84)) || !bytes.Equal(entries[2], wire.AppendVarint(nil, 5, 85)) { + t.Fatalf("catalog lost ordered pack placeholders: %x", entries) + } + current, _, _ := wire.Varint(entries[1], 2) + monsters := 0 + _ = wire.Walk(entries[1], func(f wire.Field) error { + if f.Number == 4 { + monsters++ + } + return nil + }) + if current != 9 || monsters != 5 { + t.Fatalf("catalog current=%d monsters=%d", current, monsters) + } + for _, id := range []uint64{73, 84} { + req := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, id) + _, detail, _, err := s.Handle("/HuntingGroundInfo", req) + if err != nil || !bytes.Equal(detail, wire.AppendBytes(nil, 1, nil)) { + t.Fatalf("unentered pack %d returned active state: %x %v", id, detail, err) + } + } + if len(s.state.Packs) != 0 { + t.Fatal("query created hunting progress") + } + enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 73), 3, 9) + if _, _, _, err := s.Handle("/HuntingGroundEnter", enter); err != nil { + t.Fatal(err) + } + _, detail, _, err := s.Handle("/HuntingGroundInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, 73)) + active, found, _ := wire.Bytes(detail, 1) + if err != nil || !found || len(active) == 0 { + t.Fatalf("entered detail missing: %x %v", detail, err) + } +} + func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) { store := stateio.NewMemory() inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) @@ -68,14 +145,26 @@ func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) { if err := enter(9); err != nil { t.Fatal(err) } + if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionB:2"); err == nil { + t.Fatal("same difficulty entry reset round") + } + if _, _, err := s.CompleteBattle(73, 5, 41, 91, "sessionB:boss"); err != nil { + t.Fatal(err) + } if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionB:2"); err != nil { t.Fatal(err) } - if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 4 { + var normal uint64 + for _, item := range inv.All() { + if item.ID == 999 { + normal += item.Count + } + } + if normal != 4 { t.Fatalf("repeated rewards=%+v", inv.All()) } free, _, _ = s.HuntingAP() - if free != 14 { + if free != 7 { t.Fatalf("repeat AP=%d", free) } if _, _, err := s.CompleteBattle(73, 5, 41, 91, "sessionB:3"); err != nil { @@ -85,3 +174,132 @@ func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) { t.Fatal("boss did not unlock next ground") } } + +func TestHuntingStageLoopAndBattleReplay(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + wallet, _ := player.OpenWallet(store, player.Currency{}) + s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 100, 0) + d := &gamedata.HuntingPack{NormalAP: 1, BossAP: 1, Grounds: []gamedata.HuntingGround{{ID: 1, MapID: 13, BossID: 3, Monsters: []uint64{1, 2}}}, Monsters: map[uint64]gamedata.HuntingMonster{}} + for _, id := range []uint64{1, 2, 3} { + typ := uint64(0) + if id == 3 { + typ = 1 + } + d.Monsters[id] = gamedata.HuntingMonster{ID: id, Type: typ, Decks: []uint64{id}, Rewards: map[uint64][]gamedata.BattleReward{id: {{Type: 4, Count: 10}}}} + } + s.load = func(int) (*gamedata.HuntingPack, error) { return d, nil } + if _, e := s.EnsureForPack(1); e != nil { + t.Fatal(e) + } + if e := s.ValidateBattle(1, 5, 3, 3); e == nil { + t.Fatal("early boss accepted") + } + if _, _, e := s.CompleteBattle(1, 5, 1, 1, "a:1"); e != nil { + t.Fatal(e) + } + req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1), 3, 1) + if _, _, _, e := s.HandleSession("/HuntingGroundEnter", req, "a"); e != nil { + t.Fatal(e) + } + if e := s.ValidateBattle(1, 5, 1, 1); e == nil { + t.Fatal("enter reset partial stage") + } + bundle, updates, e := s.CompleteBattle(1, 5, 2, 2, "a:3") + if e != nil || len(updates) != 2 { + t.Fatalf("boss transition %v %+v", e, updates) + } + again, replay, e := s.CompleteBattle(1, 5, 2, 2, "a:3") + if e != nil || !bytes.Equal(bundle, again) || len(replay) != 2 { + t.Fatal("battle replay lost response") + } + if _, _, e = s.CompleteBattle(1, 5, 3, 3, "a:3"); e == nil { + t.Fatal("battle receipt conflict accepted") + } + _, updates, e = s.CompleteBattle(1, 5, 3, 3, "a:4") + if e != nil || len(updates) != 3 || len(s.state.Packs["1"].Defeated) != 0 { + t.Fatalf("round reset %v %v", e, updates) + } + if e = s.ValidateBattle(1, 5, 1, 1); e != nil { + t.Fatal(e) + } + free, _, _ := s.HuntingAP() + if free != 97 || wallet.Snapshot().Gold != 30 { + t.Fatal("duplicate AP/reward") + } +} +func TestHuntingAPExchangeReplayAndOverflow(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + wallet, _ := player.OpenWallet(store, player.Currency{}) + s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 2) + cost := []gamedata.Reward{{Type: 21, Count: 4}} + reward := []gamedata.Reward{{Type: 23, Count: 3}} + if e := s.ExchangeAPOnce("x", cost, reward); e != nil { + t.Fatal(e) + } + if e := s.ExchangeAPOnce("x", cost, reward); e != nil { + t.Fatal(e) + } + free, bonus, _ := s.HuntingAP() + if free != 6 || bonus != 5 { + t.Fatal("AP replay") + } + if e := s.ExchangeAPOnce("x", nil, reward); e == nil { + t.Fatal("identity conflict") + } + if e := s.CanExchangeAP(nil, []gamedata.Reward{{Type: 21, Count: 2147483647}}); e == nil { + t.Fatal("AP overflow") + } +} + +func TestHuntingRewardAPCallbackDoesNotDeadlock(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + wallet, _ := player.OpenWallet(store, player.Currency{}) + s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 0) + s.load = func(int) (*gamedata.HuntingPack, error) { + return &gamedata.HuntingPack{NormalAP: 1, Grounds: []gamedata.HuntingGround{{ID: 1, BossID: 2, Monsters: []uint64{1}}}, Monsters: map[uint64]gamedata.HuntingMonster{1: {ID: 1, Decks: []uint64{1}, Rewards: map[uint64][]gamedata.BattleReward{1: {{Type: 21, Count: 3}}}}, 2: {ID: 2, Decks: []uint64{2}}}}, nil + } + s.AttachRewards(func(id string, _ []gamedata.Reward) ([]byte, error) { + return nil, s.ExchangeAPOnce(id, nil, []gamedata.Reward{{Type: 21, Count: 3}}) + }) + s.EnsureForPack(1) + done := make(chan error, 1) + go func() { _, _, e := s.CompleteBattle(1, 5, 1, 1, "apreward"); done <- e }() + select { + case e := <-done: + if e != nil { + t.Fatal(e) + } + case <-time.After(2 * time.Second): + t.Fatal("reward callback deadlocked") + } + free, _, _ := s.HuntingAP() + if free != 12 { + t.Fatalf("AP grant lost %d", free) + } +} + +func TestHuntingAPDailyRefreshPreservesBonus(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + wallet, _ := player.OpenWallet(store, player.Currency{}) + s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 4, 7) + if e := s.AttachAPRefresh(gamedata.HuntingAPDesign{Max: 90, ResetSeconds: 9 * 3600}); e != nil { + t.Fatal(e) + } + now := time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC) + s.now = func() time.Time { return now } + s.HuntingAP() + now = now.Add(2 * time.Minute) + free, bonus, e := s.HuntingAP() + if e != nil || free != 90 || bonus != 7 { + t.Fatalf("refresh %d %d %v", free, bonus, e) + } + s.state.Free = 12 + free, _, _ = s.HuntingAP() + if free != 12 { + t.Fatal("refreshed same day twice") + } +} diff --git a/go/internal/server/hunting/progress.go b/go/internal/server/hunting/progress.go new file mode 100644 index 0000000..dc00a01 --- /dev/null +++ b/go/internal/server/hunting/progress.go @@ -0,0 +1,224 @@ +package hunting + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "bytes" + "encoding/json" + "fmt" + "strconv" +) + +type battleReceipt struct { + Fingerprint string + Bundle []byte + Monsters [][]byte +} +type battleLedger struct { + Version string `json:"version"` + Receipts map[string]battleReceipt `json:"receipts"` +} + +func (s *Service) loadBattleReceipts() (map[string]battleReceipt, error) { + b, e := s.storage.Load("huntingbattle") + if e != nil { + return nil, e + } + if b == nil { + return map[string]battleReceipt{}, nil + } + if e = stateio.RequireExactJSONObject(b, "version", "receipts"); e != nil { + return nil, e + } + var l battleLedger + d := json.NewDecoder(bytes.NewReader(b)) + d.DisallowUnknownFields() + if e = d.Decode(&l); e != nil { + return nil, e + } + if l.Version != versionconfig.State() || l.Receipts == nil { + return nil, fmt.Errorf("hunting: invalid battle ledger") + } + return l.Receipts, nil +} +func (s *Service) saveBattleReceipts(rs map[string]battleReceipt) error { + b, e := json.Marshal(battleLedger{versionconfig.State(), rs}) + if e != nil { + return e + } + return s.storage.Save("huntingbattle", b) +} +func (s *Service) AttachEligibility(check func(int, uint64) error) { + s.mu.Lock() + defer s.mu.Unlock() + s.eligibility = check +} +func (s *Service) AttachRewards(grant func(string, []gamedata.Reward) ([]byte, error)) { + s.mu.Lock() + defer s.mu.Unlock() + s.grant = grant +} + +// EnsureForPack initializes the ordinary stage when the world loads a pack. +func (s *Service) EnsureForPack(pack int) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + d, e := s.load(pack) + if e != nil { + return nil, e + } + if len(d.Grounds) == 0 { + return nil, nil + } + st := s.state.Packs[strconv.Itoa(pack)] + if st.Current == 0 { + st.Current = d.Grounds[0].ID + next := s.clone() + next.Packs[strconv.Itoa(pack)] = st + if e = s.persist(next); e != nil { + return nil, e + } + } + return s.info(pack, d) +} +func (s *Service) SnapshotForPack(pack int) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + d, e := s.load(pack) + if e != nil { + return nil, e + } + if len(d.Grounds) == 0 { + return nil, nil + } + return s.info(pack, d) +} + +// Ground mutations have their own persisted session+sequence replay ledger. +func (s *Service) handleGroundSession(path string, req []byte, session string) (int, []byte, bool, error) { + if path != "/HuntingGroundEnter" { + return s.Handle(path, req) + } + seq, found, e := wire.Varint(req, 1) + if e != nil || !found || seq == 0 { + return 110, nil, true, fmt.Errorf("hunting: missing sequence") + } + key := "enter:" + session + ":" + strconv.FormatUint(seq, 10) + rs, e := s.loadBattleReceipts() + if e != nil { + return 110, nil, true, e + } + fp := fmt.Sprintf("%x", req) + if r, ok := rs[key]; ok { + if r.Fingerprint != fp { + return 110, nil, true, fmt.Errorf("hunting: enter sequence conflict") + } + return 110, r.Bundle, true, nil + } + code, out, ok, e := s.Handle(path, req) + if e != nil || !ok { + return code, out, ok, e + } + rs[key] = battleReceipt{Fingerprint: fp, Bundle: out} + if e = s.saveBattleReceipts(rs); e != nil { + return code, nil, true, e + } + return code, out, ok, nil +} +func (s *Service) reload() error { + b, e := s.storage.Load("hunting") + if e != nil { + return e + } + if b == nil { + return nil + } + var st snapshot + if e = json.Unmarshal(b, &st); e != nil { + return e + } + s.state = st + return nil +} +func (s *Service) CanExchangeAP(costs, rewards []gamedata.Reward) error { + s.mu.Lock() + defer s.mu.Unlock() + if e := s.reload(); e != nil { + return e + } + if e := s.refreshAP(); e != nil { + return e + } + _, e := s.exchangeAP(costs, rewards) + return e +} +func (s *Service) exchangeAP(costs, rewards []gamedata.Reward) (snapshot, error) { + st := s.clone() + apply := func(rs []gamedata.Reward, cost bool) error { + for _, r := range rs { + if (r.Type != 21 && r.Type != 23) || r.ID != 0 || r.Count == 0 { + return fmt.Errorf("hunting: invalid AP exchange") + } + p := &st.Free + if r.Type == 23 { + p = &st.Bonus + } + if cost { + if *p < r.Count { + return fmt.Errorf("hunting: insufficient AP") + } + *p -= r.Count + } else { + if r.Count > 2147483647-*p { + return fmt.Errorf("hunting: AP overflow") + } + *p += r.Count + } + } + return nil + } + if e := apply(costs, true); e != nil { + return st, e + } + return st, apply(rewards, false) +} +func (s *Service) ExchangeAPOnce(identity string, costs, rewards []gamedata.Reward) error { + s.mu.Lock() + defer s.mu.Unlock() + if identity == "" { + return fmt.Errorf("hunting: missing AP identity") + } + if e := s.reload(); e != nil { + return e + } + key := "apexchange:" + identity + fpb, e := json.Marshal([][]gamedata.Reward{costs, rewards}) + if e != nil { + return e + } + rs, e := s.loadBattleReceipts() + if e != nil { + return e + } + fp := string(fpb) + if r, ok := rs[key]; ok { + if r.Fingerprint != fp { + return fmt.Errorf("hunting: AP identity conflict") + } + return nil + } + if e := s.refreshAP(); e != nil { + return e + } + st, e := s.exchangeAP(costs, rewards) + if e != nil { + return e + } + if e = s.persist(st); e != nil { + return e + } + rs[key] = battleReceipt{Fingerprint: fp} + return s.saveBattleReceipts(rs) +} diff --git a/go/internal/server/missions/events.go b/go/internal/server/missions/events.go index 2600cdd..e9f4431 100644 --- a/go/internal/server/missions/events.go +++ b/go/internal/server/missions/events.go @@ -18,6 +18,13 @@ const ( // mission. The caller supplies quest unlock state; locked and scheduled event // missions cannot become eligible merely because they share the condition. func (s *Service) RecordEvent(conditionType, subType, count uint64, unlocked func(uint64, uint64) bool) error { + if observer, ok := s.eventHandler.(interface { + RecordEvent(uint64, uint64, uint64, func(uint64, uint64) bool) error + }); ok { + if err := observer.RecordEvent(conditionType, subType, count, unlocked); err != nil { + return err + } + } s.mu.Lock() defer s.mu.Unlock() if err := s.rolloverLocked(); err != nil { @@ -30,6 +37,13 @@ func (s *Service) RecordEvent(conditionType, subType, count uint64, unlocked fun // progress map stores the period marker, so reconnects and restarts cannot // inflate weekly connection missions and no additional save format is needed. func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error { + if observer, ok := s.eventHandler.(interface { + RecordLogin(func(uint64, uint64) bool) error + }); ok { + if err := observer.RecordLogin(unlocked); err != nil { + return err + } + } s.mu.Lock() defer s.mu.Unlock() if err := s.rolloverLocked(); err != nil { @@ -44,6 +58,13 @@ func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error { // CompleteSingleTargetEvent covers evidence of at least one occurrence, without // inventing a count or subtype for richer missions (e.g. a monster encounter win). func (s *Service) CompleteSingleTargetEvent(conditionType uint64, unlocked func(uint64, uint64) bool) error { + if observer, ok := s.eventHandler.(interface { + CompleteSingleTargetEvent(uint64, func(uint64, uint64) bool) error + }); ok { + if err := observer.CompleteSingleTargetEvent(conditionType, unlocked); err != nil { + return err + } + } s.mu.Lock() defer s.mu.Unlock() if err := s.rolloverLocked(); err != nil { diff --git a/go/internal/server/missions/missions.go b/go/internal/server/missions/missions.go index 14035af..b54a780 100644 --- a/go/internal/server/missions/missions.go +++ b/go/internal/server/missions/missions.go @@ -46,6 +46,9 @@ func (s *Service) AttachAchievementProgress(source AchievementProgressSource) er } type Service struct { + eventHandler interface { + Handle(string, []byte) (int, []byte, bool, error) + } levelRewards *gamedata.AchievementLevelDesign levelReward uint64 achievementProgress AchievementProgressSource @@ -217,6 +220,37 @@ func (s *Service) applyCompletionDependencies(next *snapshot, completed gamedata } func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if s.eventHandler != nil && path == "/MissionUpdate" { + regular, event, err := splitMissionUpdates(request) + if err != nil { + return 119, nil, true, err + } + if len(event) > 0 { + if len(regular) > 0 { + s.mu.Lock() + err = s.validateRegularUpdates(regular) + s.mu.Unlock() + if err != nil { + return 119, nil, true, err + } + } + if _, _, handled, err := s.eventHandler.Handle(path, event); err != nil || !handled { + if err == nil { + err = ErrInvalidRequest + } + return 119, nil, true, err + } + if len(regular) == 0 { + return 119, nil, true, nil + } + request = regular + } + } + if s.eventHandler != nil && path == "/MissionClear" { + if code, response, handled, err := s.eventHandler.Handle(path, request); handled { + return code, response, handled, err + } + } s.mu.Lock() defer s.mu.Unlock() if err := s.rolloverLocked(); err != nil { @@ -255,6 +289,84 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } } +// Each update carries its own event identity; a client can acknowledge regular +// and scheduled missions together in one request. +func splitMissionUpdates(request []byte) (regular, event []byte, err error) { + if err = requireSeq(request); err != nil { + return + } + seq, _, _ := wire.Varint(request, 1) + err = wire.Walk(request, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return ErrInvalidRequest + } + uid, e := scalar(f.Value, 4) + if e != nil { + return e + } + if uid == 0 { + if len(regular) == 0 { + regular = wire.AppendVarint(nil, 1, seq) + } + regular = wire.AppendBytes(regular, 2, f.Value) + } else { + if len(event) == 0 { + event = wire.AppendVarint(nil, 1, seq) + } + event = wire.AppendBytes(event, 2, f.Value) + } + return nil + }) + return +} + +func (s *Service) validateRegularUpdates(request []byte) error { + return wire.Walk(request, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return ErrInvalidRequest + } + group, err := scalar(f.Value, 1) + if err != nil || group == 0 { + return ErrInvalidRequest + } + id, err := scalar(f.Value, 2) + if err != nil || id == 0 { + return ErrInvalidRequest + } + if _, err = scalar(f.Value, 3); err != nil { + return ErrInvalidRequest + } + matches := 0 + for key := range s.design.Missions { + if key.GroupType != 2 && key.GroupID == group && key.ID == id { + matches++ + } + } + if matches != 1 { + return ErrInvalidRequest + } + return nil + }) +} + +// AttachEventHandler delegates scheduled event operations before regular +// mission processing. The event service validates calendar and player state. +func (s *Service) AttachEventHandler(handler interface { + Handle(string, []byte) (int, []byte, bool, error) +}) error { + if handler == nil { + return errors.New("missions: nil event handler") + } + s.eventHandler = handler + return nil +} + func dailyPeriod(now time.Time) string { return now.UTC().Format("2006-01-02") } func weeklyPeriod(now time.Time) string { diff --git a/go/internal/server/missions/mixed_update_test.go b/go/internal/server/missions/mixed_update_test.go new file mode 100644 index 0000000..7e8c492 --- /dev/null +++ b/go/internal/server/missions/mixed_update_test.go @@ -0,0 +1,36 @@ +package missions + +import ( + "bd2server/internal/server/wire" + "testing" +) + +func TestSplitMixedMissionUpdates(t *testing.T) { + regular := wire.AppendVarint(nil, 1, 1) + regular = wire.AppendVarint(regular, 2, 101) + regular = wire.AppendVarint(regular, 3, 2) + event := wire.AppendVarint(nil, 1, 9) + event = wire.AppendVarint(event, 2, 10) + event = wire.AppendVarint(event, 3, 1) + event = wire.AppendVarint(event, 4, 77) + req := wire.AppendVarint(nil, 1, 40) + req = wire.AppendBytes(req, 2, regular) + req = wire.AppendBytes(req, 2, event) + r, e, err := splitMissionUpdates(req) + if err != nil { + t.Fatal(err) + } + for _, part := range [][]byte{r, e} { + seq, _, _ := wire.Varint(part, 1) + if seq != 40 { + t.Fatal("lost sequence") + } + } + rr, _, _ := wire.Bytes(r, 2) + ee, _, _ := wire.Bytes(e, 2) + rid, _, _ := wire.Varint(rr, 2) + eid, _, _ := wire.Varint(ee, 4) + if rid != 101 || eid != 77 { + t.Fatal("mixed update was not split by event identity") + } +} diff --git a/go/internal/server/monsterhunt/calendar_test.go b/go/internal/server/monsterhunt/calendar_test.go new file mode 100644 index 0000000..d1e9aa7 --- /dev/null +++ b/go/internal/server/monsterhunt/calendar_test.go @@ -0,0 +1,76 @@ +package monsterhunt + +import ( + "testing" + "time" + + "bd2server/internal/server/readonly" + "bd2server/internal/server/wire" +) + +func TestFutureSeasonDoesNotReplaceActiveOrRewardSeason(t *testing.T) { + rows := []season{ + {ID: 5, Start: 100, End: 200}, + {ID: 3, Start: 300, End: 400}, + {ID: 999, Start: 500, End: 600}, + } + for _, tc := range []struct{ now, want uint64 }{ + {50, 5}, {100, 5}, {199, 5}, {200, 5}, {299, 5}, + {300, 3}, {400, 3}, {500, 999}, + } { + if got := selectSeason(rows, tc.now); got.ID != tc.want { + t.Fatalf("time=%d season=%d want=%d", tc.now, got.ID, tc.want) + } + } +} + +func TestScheduleProjectsOneRegularPreservesIndependentAndHistory(t *testing.T) { + field := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} } + row := func(id uint64, independent bool) readonly.Field { + flag := uint64(0) + if independent { + flag = 1 + } + return readonly.Field{Number: 1, Type: 2, Fields: []readonly.Field{ + {Number: 1, Type: 2, Fields: []readonly.Field{field(1, id)}}, field(6, flag), + }} + } + s := &Service{now: func() time.Time { return time.UnixMilli(150) }, + seasons: []season{{ID: 1, Start: 100, End: 200}, {ID: 2, Start: 300, End: 400}, {ID: 3, Start: 100, End: 200, Independent: true}}, + seed: &readonly.Seed{Responses: map[string]readonly.Response{"/MonsterHuntScheduleInfo": { + Fields: []readonly.Field{row(1, false), row(2, false), row(3, true), field(2, 5), {Number: 3, Type: 2, Fields: []readonly.Field{field(1, 77)}}}, + }}}, + } + for _, now := range []int64{150, 350} { + s.now = func() time.Time { return time.UnixMilli(now) } + _, response, _, err := s.scheduleInfo(wire.AppendVarint(nil, 1, 1)) + if err != nil { + t.Fatal(err) + } + var ids []uint64 + history := 0 + if err := wire.Walk(response, func(f wire.Field) error { + if f.Number == 1 { + nested, _, _ := wire.Bytes(f.Value, 1) + id, _, _ := wire.Varint(nested, 1) + ids = append(ids, id) + } + if f.Number == 3 { + history++ + } + return nil + }); err != nil { + t.Fatal(err) + } + want := uint64(1) + if now == 350 { + want = 2 + } + if len(ids) != 2 || ids[0] != want || ids[1] != 3 || history != 1 { + t.Fatalf("time=%d rows=%v history=%d", now, ids, history) + } + if len(s.seed.Responses["/MonsterHuntScheduleInfo"].Fields) != 5 { + t.Fatal("projection mutated published calendars") + } + } +} diff --git a/go/internal/server/monsterhunt/monsterhunt.go b/go/internal/server/monsterhunt/monsterhunt.go index b904095..d0fd196 100644 --- a/go/internal/server/monsterhunt/monsterhunt.go +++ b/go/internal/server/monsterhunt/monsterhunt.go @@ -25,7 +25,10 @@ import ( const BattleMode uint64 = 8 const PracticeMode uint64 = 24 -type season struct{ ID, Hunt, Start, End, Calculate, RankGroup uint64 } +type season struct { + ID, Hunt, Start, End, Calculate, RankGroup uint64 + Independent bool +} type user struct { Season, Hunt, Level, StartHP, HighestHP, CurrentDamage, DailyDamage, HighestDate, DailyLevel, DailyDate uint64 Played, Claimed bool @@ -59,6 +62,7 @@ type Service struct { now func() time.Time active map[string]encounter load func(uint64) (*gamedata.MonsterHunt, error) + rewardGrant func(string, []gamedata.Reward) ([]byte, error) } func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) { @@ -99,6 +103,8 @@ func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inve c.Hunt = v.Varint case 4: c.Calculate = v.Varint + case 6: + c.Independent = v.Varint != 0 case 7: c.RankGroup = v.Varint } @@ -169,6 +175,9 @@ func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inve } return s, nil } +func (s *Service) AttachRewards(grant func(string, []gamedata.Reward) ([]byte, error)) { + s.rewardGrant = grant +} func (s *Service) AttachCharacters(c *player.CharacterStore) { s.characters = c } func (s *Service) AttachPresetRuntime(c *player.CharacterStore, e *player.EquipmentInventory, col *player.CollectionStore) error { if c == nil || e == nil || col == nil { @@ -197,17 +206,81 @@ func (s *Service) BeginSession(id string) { // Encounters are keyed by session-specific receipts and survive repeated activation. } func (s *Service) current() season { - c := s.seasons[0] - for _, v := range s.seasons { - if v.ID > c.ID { - c = v + // Future rows are public calendar data, not an instruction to switch the + // player's current hunt before its window begins. Regular client schedules + // have a single slot; independent hunts use their separate client list. + now := uint64(s.now().UnixMilli()) + var regular []season + for _, c := range s.seasons { + if !c.Independent { + regular = append(regular, c) } } - return c + if len(regular) == 0 { + regular = s.seasons + } + return selectSeason(regular, now) +} + +func selectSeason(rows []season, now uint64) season { + var active, begun, future season + for _, c := range rows { + if c.Start <= now { + if begun.ID == 0 || c.Start > begun.Start || c.Start == begun.Start && c.ID > begun.ID { + begun = c + } + if now < c.End && (active.ID == 0 || c.Start > active.Start || c.Start == active.Start && c.ID > active.ID) { + active = c + } + } else if future.ID == 0 || c.Start < future.Start || c.Start == future.Start && c.ID < future.ID { + future = c + } + } + if active.ID != 0 { + return active + } + if begun.ID != 0 { + return begun + } + return future +} + +func (s *Service) scheduleInfo(req []byte) (int, []byte, bool, error) { + current := s.current() + response := s.seed.Responses["/MonsterHuntScheduleInfo"] + fields := make([]readonly.Field, 0, len(response.Fields)) + for _, field := range response.Fields { + if field.Number == 1 && field.Type == 2 { + var id uint64 + independent := false + for _, f := range field.Fields { + if f.Number == 1 { + for _, v := range f.Fields { + if v.Number == 1 { + id = v.Varint + } + } + } + if f.Number == 6 { + independent = f.Varint != 0 + } + } + if !independent && id != current.ID { + continue + } + } + fields = append(fields, field) + } + // Keep the entire loaded calendar immutable; projection only affects this + // response so subsequent requests can cross a season boundary without reload. + projected := &readonly.Seed{Version: s.seed.Version, Responses: map[string]readonly.Response{ + "/MonsterHuntScheduleInfo": {PacketCode: response.PacketCode, Fields: fields}, + }} + return projected.Handle("/MonsterHuntScheduleInfo", req) } func (s *Service) playing(c season) bool { n := uint64(s.now().UnixMilli()) - return n >= c.Start && n <= c.End + return n >= c.Start && n < c.End } func (s *Service) getUser(c season) (user, error) { u, ok := s.state.Users[strconv.FormatUint(c.ID, 10)] @@ -333,6 +406,9 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ } return code, r.Response, true, nil } + if path == "/MonsterHuntScheduleInfo" { + return s.scheduleInfo(req) + } c := s.current() u, e := s.getUser(c) if e != nil { @@ -344,8 +420,6 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ var updateReceipt string var updateEncounter encounter switch path { - case "/MonsterHuntScheduleInfo": - return s.seed.Handle(path, req) case "/MonsterHuntUserInfo": out = wire.AppendBytes(nil, 1, s.encodeUser(u, c)) if u.Played { diff --git a/go/internal/server/monsterhunt/monsterhunt_test.go b/go/internal/server/monsterhunt/monsterhunt_test.go index 7439ef5..7031046 100644 --- a/go/internal/server/monsterhunt/monsterhunt_test.go +++ b/go/internal/server/monsterhunt/monsterhunt_test.go @@ -1,6 +1,7 @@ package monsterhunt import ( + "bd2server/internal/server/calendar" "bd2server/internal/server/gamedata" "bd2server/internal/server/player" "bd2server/internal/server/readonly" @@ -23,6 +24,14 @@ func installed(t *testing.T) (*Service, *stateio.Memory) { if e != nil { t.Fatal(e) } + calendars, e := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", "20260923193640") + if e != nil { + t.Fatal(e) + } + seed, e = calendars.ApplyReadonly(seed) + if e != nil { + t.Fatal(e) + } store := stateio.NewMemory() inv, e := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) if e != nil { diff --git a/go/internal/server/monsterhunt/rewards.go b/go/internal/server/monsterhunt/rewards.go index 794737c..97bbd16 100644 --- a/go/internal/server/monsterhunt/rewards.go +++ b/go/internal/server/monsterhunt/rewards.go @@ -56,6 +56,13 @@ func (s *Service) rankRewards(d *gamedata.MonsterHunt, group uint64) []gamedata. return selected.Rewards } func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) { + if s.rewardGrant != nil { + rs := make([]gamedata.Reward, len(rewards)) + for i, r := range rewards { + rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} + } + return s.rewardGrant(identity, rs) + } var currency []gamedata.Reward var stack []gamedata.BattleReward for _, r := range rewards { diff --git a/go/internal/server/player/commerce_items.go b/go/internal/server/player/commerce_items.go new file mode 100644 index 0000000..4b52131 --- /dev/null +++ b/go/internal/server/player/commerce_items.go @@ -0,0 +1,76 @@ +package player + +import ( + "fmt" + "math" +) + +// GrantCommerceOnce preserves the exact item expiry supplied by the commerce +// entitlement policy. It shares the existing atomic inventory layout. +func (s *Inventory) GrantCommerceOnce(identity string, rewards []Item) ([]Item, error) { + if identity == "" { + return nil, fmt.Errorf("player: missing commerce reward identity") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.owned.Granted[identity] { + var out []Item + for _, idx := range s.owned.GrantItems[identity] { + for _, item := range s.owned.Items { + if item.InvenIndex == idx { + out = append(out, item) + } + } + } + return out, nil + } + next := cloneOwnedSnapshot(s.owned) + var out []Item + for _, r := range rewards { + if r.ID == 0 || (r.Type != 62 && r.Type != 19) || r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 19 && r.ExpiryTime == 0 || next.NextIndex == math.MaxUint64 { + return nil, fmt.Errorf("player: invalid commerce item") + } + if r.Type == 19 { + found := false + for i, current := range next.Items { + if current.Type == 19 && current.ID == r.ID { + r.InvenIndex = current.InvenIndex + r.Count = 1 + next.Items[i] = r + next.GrantItems[identity] = append(next.GrantItems[identity], r.InvenIndex) + out = append(out, r) + found = true + break + } + } + if found { + continue + } + r.Count = 1 + } + r.InvenIndex = next.NextIndex + next.NextIndex++ + next.Items = append(next.Items, r) + next.GrantItems[identity] = append(next.GrantItems[identity], r.InvenIndex) + out = append(out, r) + } + next.Granted[identity] = true + if err := s.commitOwned(next); err != nil { + return nil, err + } + s.owned = next + return out, nil +} +func (s *Inventory) ContentTicketExpiry(id uint64) uint64 { + s.mu.Lock() + defer s.mu.Unlock() + var expiry uint64 + for _, items := range [][]Item{s.starter.Items, s.owned.Items} { + for _, i := range items { + if i.Type == 19 && i.ID == id && i.Count > 0 && i.ExpiryTime > expiry { + expiry = i.ExpiryTime + } + } + } + return expiry +} diff --git a/go/internal/server/player/commerce_items_test.go b/go/internal/server/player/commerce_items_test.go new file mode 100644 index 0000000..86ee91e --- /dev/null +++ b/go/internal/server/player/commerce_items_test.go @@ -0,0 +1,28 @@ +package player + +import ( + "bd2server/internal/server/stateio" + "testing" +) + +func TestCommerceTimedGrantRenewalAndRetry(t *testing.T) { + inv, err := OpenInventory(stateio.NewMemory(), &Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + first, err := inv.GrantCommerceOnce("a", []Item{{Type: 19, ID: 38, Count: 1, ExpiryTime: 1000}}) + if err != nil || len(first) != 1 { + t.Fatalf("%+v %v", first, err) + } + second, err := inv.GrantCommerceOnce("b", []Item{{Type: 19, ID: 38, Count: 2, ExpiryTime: 2000}}) + if err != nil || len(second) != 1 || second[0].InvenIndex != first[0].InvenIndex || inv.ContentTicketExpiry(38) != 2000 { + t.Fatalf("renewal %+v %v", second, err) + } + retry, err := inv.GrantCommerceOnce("b", nil) + if err != nil || len(retry) != 1 || len(inv.owned.Items) != 1 { + t.Fatalf("retry %+v %v", retry, err) + } + if _, err = inv.GrantCommerceOnce("bad", []Item{{Type: 19, ID: 38, Count: 1}}); err == nil { + t.Fatal("untimed ticket accepted") + } +} diff --git a/go/internal/server/player/wallet_exchange.go b/go/internal/server/player/wallet_exchange.go new file mode 100644 index 0000000..35b01de --- /dev/null +++ b/go/internal/server/player/wallet_exchange.go @@ -0,0 +1,69 @@ +package player + +import ( + "fmt" + "math" + + "bd2server/internal/server/gamedata" +) + +// ExchangeOnce applies a server-calculated cost and reward in one wallet save. +// The caller's account transaction also owns inventory and gameplay receipts. +func (s *Wallet) ExchangeOnce(identity string, costs, rewards []gamedata.Reward) error { + if identity == "" { + return fmt.Errorf("player: missing wallet exchange identity") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.state.Spent[identity] { + return nil + } + next := cloneWallet(s.state) + if err := exchangeCurrency(&next.Currency, costs, rewards); err != nil { + return err + } + next.Spent[identity] = true + return s.commit(next, entry("spent", identity, []byte("true"))...) +} + +func (s *Wallet) CanExchange(costs, rewards []gamedata.Reward) error { + c := s.Snapshot() + return exchangeCurrency(&c, costs, rewards) +} + +func exchangeCurrency(c *Currency, costs, rewards []gamedata.Reward) error { + value := func(t uint64) *uint64 { + switch t { + case 2: + return &c.Jewelry + case 3: + return &c.FreeJewelry + case 4: + return &c.Gold + case 12: + return &c.Catalyst + case 20: + return &c.Mileage + case 22: + return &c.HopePowder + case 68: + return &c.EquipMileage + } + return nil + } + for _, r := range costs { + v := value(r.Type) + if v == nil || r.ID != 0 || r.Count == 0 || *v < r.Count { + return fmt.Errorf("player: invalid or insufficient currency %d", r.Type) + } + *v -= r.Count + } + for _, r := range rewards { + v := value(r.Type) + if v == nil || r.ID != 0 || r.Count == 0 || *v > math.MaxInt64-r.Count || r.Count > math.MaxInt64 { + return fmt.Errorf("player: invalid or overflowing currency %d", r.Type) + } + *v += r.Count + } + return nil +} diff --git a/go/internal/server/progress/research.go b/go/internal/server/progress/research.go new file mode 100644 index 0000000..f07ad5c --- /dev/null +++ b/go/internal/server/progress/research.go @@ -0,0 +1,61 @@ +package progress + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +func (s *Store) ResearchObjects(pack int) ([]int, error) { + s.mu.Lock() + defer s.mu.Unlock() + store, err := s.fieldRewardEntries() + if err != nil { + return nil, err + } + entries, err := store.ListEntries("progress", "field_research") + if err != nil { + return nil, err + } + var ids []int + prefix := fmt.Sprintf("%d:", pack) + for key, raw := range entries { + if string(raw) != "once" { + return nil, fmt.Errorf("progress: invalid research entry") + } + if strings.HasPrefix(key, prefix) { + id, e := strconv.Atoi(strings.TrimPrefix(key, prefix)) + if e != nil || id <= 0 { + return nil, fmt.Errorf("progress: invalid research identity") + } + ids = append(ids, id) + } + } + sort.Ints(ids) + return ids, nil +} +func (s *Store) ResearchObjectReply(pack, id int) ([]byte, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + store, err := s.fieldRewardEntries() + if err != nil { + return nil, false, err + } + return store.LoadEntry("progress", "field_research_rewards", fieldRewardKey(pack, id)) +} +func (s *Store) MarkResearchObject(pack, id int, rewardItems []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + if pack <= 0 || id <= 0 { + return fmt.Errorf("progress: invalid research identity") + } + store, err := s.fieldRewardEntries() + if err != nil { + return err + } + if err = store.PutEntry("progress", "field_research_rewards", fieldRewardKey(pack, id), rewardItems); err != nil { + return err + } + return store.PutEntry("progress", "field_research", fieldRewardKey(pack, id), []byte("once")) +} diff --git a/go/internal/server/readonly/readonly_test.go b/go/internal/server/readonly/readonly_test.go index 5ee8a12..3442f2f 100644 --- a/go/internal/server/readonly/readonly_test.go +++ b/go/internal/server/readonly/readonly_test.go @@ -47,10 +47,11 @@ func TestSeedProtocolEncodingAndRoundTrip(t *testing.T) { } func TestCashProductEventIndexIsUniqueSemanticSeedFact(t *testing.T) { - seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json")) - if err != nil { - t.Fatal(err) - } + seed := &Seed{Version: StateVersion(), Responses: map[string]Response{ + "/CashShopInfo": {PacketCode: 60, Fields: []Field{{Number: 1, Type: 2, Fields: []Field{ + {Number: 1, Type: 0, Varint: 1100001}, {Number: 2, Type: 0, Varint: 9100033}, {Number: 8, Type: 0, Varint: 1171}, + }}}}, + }} event, err := seed.CashProductEventIndex(1100001, 9100033) if err != nil || event != 1171 { t.Fatalf("event=%d err=%v", event, err) diff --git a/go/internal/server/schedule/schedule.go b/go/internal/server/schedule/schedule.go index cdd07b2..aa2bcfb 100644 --- a/go/internal/server/schedule/schedule.go +++ b/go/internal/server/schedule/schedule.go @@ -4,11 +4,8 @@ package schedule import ( "bd2server/internal/server/versionconfig" - "encoding/json" "errors" - "fmt" "math" - "os" "bd2server/internal/server/wire" ) @@ -44,23 +41,6 @@ type Service struct { Regular []RegularSeason `json:"regular"` } -// Load reads this server's versioned calendar policy. It does not infer live -// official seasons from today's clock or from static reward design tables. -func Load(path string) (*Service, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("schedule: read seed: %w", err) - } - var s Service - if err := json.Unmarshal(raw, &s); err != nil { - return nil, fmt.Errorf("schedule: decode seed: %w", err) - } - if err := s.Validate(); err != nil { - return nil, err - } - return &s, nil -} - func (s *Service) Validate() error { if s == nil || s.Version != versionconfig.State() || s.CalculateMilliseconds == 0 || s.CalculateMilliseconds > math.MaxInt32 || len(s.Contents) == 0 { return errors.New("schedule: invalid calendar version/calculation interval") diff --git a/go/internal/server/schedule/schedule_test.go b/go/internal/server/schedule/schedule_test.go index 76c91e8..01cf07e 100644 --- a/go/internal/server/schedule/schedule_test.go +++ b/go/internal/server/schedule/schedule_test.go @@ -1,15 +1,13 @@ package schedule import ( - "os" - "path/filepath" + "encoding/json" "testing" "bd2server/internal/server/wire" ) -func TestCalendarReloadsChangedIDsAndTimesFromFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "schedule.json") +func TestCalendarUsesInjectedIDsAndTimes(t *testing.T) { for _, revision := range []struct { id, start, end uint64 raw string @@ -17,11 +15,11 @@ func TestCalendarReloadsChangedIDsAndTimesFromFile(t *testing.T) { {709, 101, 303, `{"version":"2.35.10","calculate_milliseconds":17,"contents":[{"id":81,"current":{"id":709,"start_milliseconds":101,"end_milliseconds":303},"next":{"id":710,"start_milliseconds":401,"end_milliseconds":501}}],"regular":[{"content_id":81,"season":9}]}`}, {811, 701, 903, `{"version":"2.35.10","calculate_milliseconds":17,"contents":[{"id":81,"current":{"id":811,"start_milliseconds":701,"end_milliseconds":903},"next":{"id":812,"start_milliseconds":1001,"end_milliseconds":1201}}],"regular":[{"content_id":81,"season":11}]}`}, } { - if err := os.WriteFile(path, []byte(revision.raw), 0600); err != nil { + var service Service + if err := json.Unmarshal([]byte(revision.raw), &service); err != nil { t.Fatal(err) } - service, err := Load(path) - if err != nil { + if err := service.Validate(); err != nil { t.Fatal(err) } _, response, _, err := service.Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 1)) @@ -37,40 +35,6 @@ func TestCalendarReloadsChangedIDsAndTimesFromFile(t *testing.T) { t.Fatalf("season=%d times=%d/%d", id, start, end) } } - if err := os.WriteFile(path, []byte(`{"version":"2.35.10","calculate_milliseconds":1,"contents":[{"id":1,"current":{"id":2,"start_milliseconds":4,"end_milliseconds":3}}]}`), 0600); err != nil { - t.Fatal(err) - } - if _, err := Load(path); err == nil { - t.Fatal("reversed date accepted") - } -} - -func TestVersion23510ContainsRequiredContentSix(t *testing.T) { - service, err := Load("../../../seed/v2_35_10/schedule.json") - if err != nil { - t.Fatal(err) - } - code, response, handled, err := service.Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil || !handled || code != 117 { - t.Fatalf("schedule code=%d handled=%v err=%v", code, handled, err) - } - found := false - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 2 { - id, _, _ := wire.Varint(field.Value, 1) - if id == 6 { - found = true - current, ok, _ := wire.Bytes(field.Value, 2) - season, _, _ := wire.Varint(current, 1) - if !ok || season != 26 { - t.Fatalf("content 6 current season=%d found=%v", season, ok) - } - } - } - return nil - }); err != nil || !found { - t.Fatalf("content 6 missing err=%v", err) - } } func TestScheduleRejectsMissingSequence(t *testing.T) { diff --git a/go/internal/server/transport/commerce.go b/go/internal/server/transport/commerce.go new file mode 100644 index 0000000..d276627 --- /dev/null +++ b/go/internal/server/transport/commerce.go @@ -0,0 +1,23 @@ +package transport + +import ( + "encoding/json" + "net/http" +) + +func (h HTTP) clientCommerce(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + if h.CommerceManifest == nil { + http.Error(w, "server purchases are unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err := json.NewEncoder(w).Encode(h.CommerceManifest()); err != nil { + h.logger().Error("encode commerce manifest", "error", err) + } +} diff --git a/go/internal/server/transport/commerce_test.go b/go/internal/server/transport/commerce_test.go new file mode 100644 index 0000000..957acef --- /dev/null +++ b/go/internal/server/transport/commerce_test.go @@ -0,0 +1,32 @@ +package transport + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCommerceManifestIsReadOnlyAndUnavailableWithoutPolicy(t *testing.T) { + without := httptest.NewRecorder() + HTTP{}.Handler().ServeHTTP(without, httptest.NewRequest(http.MethodGet, "/client/commerce", nil)) + if without.Code != http.StatusServiceUnavailable { + t.Fatalf("missing policy status=%d", without.Code) + } + calls := 0 + h := HTTP{CommerceManifest: func() any { + calls++ + return map[string]any{"schema_version": 1, "products": []any{map[string]any{"sku": "test", "item_type": 2, "amount": 1000}}} + }}.Handler() + post := httptest.NewRecorder() + h.ServeHTTP(post, httptest.NewRequest(http.MethodPost, "/client/commerce", strings.NewReader(`{"amount":0}`))) + if post.Code != http.StatusMethodNotAllowed || calls != 0 { + t.Fatalf("policy accepted mutation: status=%d calls=%d", post.Code, calls) + } + get := httptest.NewRecorder() + h.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/client/commerce", nil)) + if get.Code != http.StatusOK || calls != 1 || get.Header().Get("Cache-Control") != "no-store" || + !strings.Contains(get.Body.String(), `"amount":1000`) { + t.Fatalf("manifest status=%d calls=%d headers=%v body=%s", get.Code, calls, get.Header(), get.Body.String()) + } +} diff --git a/go/internal/server/transport/http.go b/go/internal/server/transport/http.go index 72f0a8e..2fa954a 100644 --- a/go/internal/server/transport/http.go +++ b/go/internal/server/transport/http.go @@ -109,6 +109,7 @@ type HTTP struct { ResourcePolicy resourcepolicy.Public Availability Availability InstanceID string + CommerceManifest func() any } func (h HTTP) Handler() http.Handler { @@ -118,6 +119,7 @@ func (h HTTP) Handler() http.Handler { mux.HandleFunc("/auth/config", h.authenticationConfig) mux.HandleFunc("/client/resources", h.clientResources) mux.HandleFunc("/client/runtime", h.clientRuntime) + mux.HandleFunc("/client/commerce", h.clientCommerce) if h.AuthenticationHandler != nil { mux.Handle("/auth/", h.withAvailability(h.AuthenticationHandler)) } diff --git a/go/internal/server/versionconfig/config.go b/go/internal/server/versionconfig/config.go index ea17c44..21816b0 100644 --- a/go/internal/server/versionconfig/config.go +++ b/go/internal/server/versionconfig/config.go @@ -41,6 +41,7 @@ type PluginVersions struct { LocalIdentity string `json:"local_identity"` CaptureEnvironment string `json:"capture_environment"` LoginUI string `json:"login_ui"` + CashShop string `json:"cash_shop"` } // Load reads one explicit version file. Unknown fields and trailing JSON are diff --git a/go/internal/server/world/cash_packages.go b/go/internal/server/world/cash_packages.go new file mode 100644 index 0000000..4614e8b --- /dev/null +++ b/go/internal/server/world/cash_packages.go @@ -0,0 +1,19 @@ +package world + +// CashPackagePackCleared verifies the complete main quest chain at the +// requested difficulty using persisted server progress. +func (s *Service) CashPackagePackCleared(packID, difficulty uint64) bool { + if s.storyCatalog == nil || difficulty > 2 || packID == 0 { + return false + } + pack, ok := s.storyCatalog.Packs[int(packID)] + if !ok || len(pack.MainQuestIDs) == 0 { + return false + } + for _, quest := range pack.MainQuestIDs { + if !s.state.QuestCleared(quest, int(packID), int(difficulty)) { + return false + } + } + return true +} diff --git a/go/internal/server/world/hunting.go b/go/internal/server/world/hunting.go new file mode 100644 index 0000000..c281e84 --- /dev/null +++ b/go/internal/server/world/hunting.go @@ -0,0 +1,35 @@ +package world + +import "fmt" + +func (s *Service) AttachHuntingGround(provider interface{ EnsureForPack(int) ([]byte, error) }) error { + if provider == nil { + return fmt.Errorf("world: missing hunting ground provider") + } + s.huntingGround = provider + return nil +} + +// HuntingEligibility follows the hunting tutorial: hard/extreme grounds need +// completion of the same story difficulty, independently of the last hunt boss. +func (s *Service) HuntingEligibility(pack int, difficulty uint64) error { + if difficulty > 2 || !s.packUnlocked(pack) { + return fmt.Errorf("world: hunting pack or difficulty locked") + } + if difficulty == 0 { + return nil + } + if s.storyCatalog == nil { + return fmt.Errorf("world: hunting story design unavailable") + } + d, ok := s.storyCatalog.Packs[pack] + if !ok || len(d.MainQuestIDs) == 0 || !s.questDifficulties[pack][int(difficulty)] { + return fmt.Errorf("world: hunting difficulty unavailable") + } + for _, id := range d.MainQuestIDs { + if !s.state.QuestCleared(id, pack, int(difficulty)) { + return fmt.Errorf("world: hunting requires completed story difficulty") + } + } + return nil +} diff --git a/go/internal/server/world/hunting_test.go b/go/internal/server/world/hunting_test.go new file mode 100644 index 0000000..56a5fae --- /dev/null +++ b/go/internal/server/world/hunting_test.go @@ -0,0 +1,39 @@ +package world + +import ( + "testing" + + "bd2server/internal/server/wire" +) + +type fakeHuntingGround struct { + calls []int + row []byte +} + +func (h *fakeHuntingGround) EnsureForPack(pack int) ([]byte, error) { + h.calls = append(h.calls, pack) + return h.row, nil +} + +func TestPackLoadRestoresActualHuntingState(t *testing.T) { + s := testService() + h := &fakeHuntingGround{row: wire.AppendVarint(nil, 2, 91)} + if err := s.AttachHuntingGround(h); err != nil { + t.Fatal(err) + } + out, err := s.packInfoFor(s.seed.PackID) + if err != nil { + t.Fatal(err) + } + b, ok, err := wire.Bytes(out, 12) + if err != nil || !ok { + t.Fatalf("ground missing: %v", err) + } + if id, _, _ := wire.Varint(b, 2); id != 91 { + t.Fatalf("seed placeholder replaced state: %d", id) + } + if len(h.calls) != 1 || h.calls[0] != s.seed.PackID { + t.Fatal("wrong loaded pack") + } +} diff --git a/go/internal/server/world/pack_reward_counts.go b/go/internal/server/world/pack_reward_counts.go new file mode 100644 index 0000000..8ccb360 --- /dev/null +++ b/go/internal/server/world/pack_reward_counts.go @@ -0,0 +1,262 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "encoding/binary" + "fmt" +) + +func (s *Service) AttachResearchRuntime(root, version string, economy interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) +}) error { + if economy == nil { + return fmt.Errorf("world: nil research economy") + } + chars, err := gamedata.LoadResearchCharacters(root, version) + if err != nil { + return err + } + s.researchCharacters = chars + s.researchEconomy = economy + s.researchDesigns = map[int]gamedata.FieldResearchDesign{} + s.researchLoader = func(pack int) (gamedata.FieldResearchDesign, error) { + return gamedata.LoadFieldResearch(root, version, pack) + } + return nil +} +func (s *Service) researchDesign(pack int) (gamedata.FieldResearchDesign, error) { + if d, ok := s.researchDesigns[pack]; ok { + return d, nil + } + if s.researchLoader == nil { + return gamedata.FieldResearchDesign{}, fmt.Errorf("world: research design unavailable") + } + d, err := s.researchLoader(pack) + if err != nil { + return d, err + } + s.researchDesigns[pack] = d + return d, nil +} +func intsRequest(raw []byte, number int) ([]uint64, error) { + var out []uint64 + err := wire.Walk(raw, func(f wire.Field) error { + if f.Number != number { + return nil + } + if f.Type == 0 { + v, n := binary.Uvarint(f.Value) + if n <= 0 { + return wire.ErrMalformed + } + out = append(out, v) + return nil + } + if f.Type != 2 { + return ErrInvalidRequest + } + for raw := f.Value; len(raw) > 0; { + v, n := binary.Uvarint(raw) + if n <= 0 { + return wire.ErrMalformed + } + out = append(out, v) + raw = raw[n:] + } + return nil + }) + return out, err +} +func (s *Service) handleFieldResearch(request []byte) (int, []byte, bool, error) { + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 59, nil, true, ErrInvalidRequest + } + pack, err := requestPack(request) + if err != nil { + return 59, nil, true, err + } + id, _, err := wire.Varint(request, 3) + if err != nil || id == 0 || !s.packUnlocked(pack) || s.state.ActivePackID() != pack { + return 59, nil, true, ErrInvalidRequest + } + design, err := s.researchDesign(pack) + if err != nil { + return 59, nil, true, err + } + obj, ok := design.Objects[int(id)] + if !ok || obj.CollectionID == 0 && obj.Reward.Type == 0 { + return 59, nil, true, ErrInvalidRequest + } + position, saved := s.state.Position() + mapOK := false + for _, mapID := range obj.Maps { + if saved && position.PackID == pack && position.Position.MapID == mapID { + mapOK = true + } + } + if !mapOK { + return 59, nil, true, fmt.Errorf("%w: research outside current map", ErrInvalidRequest) + } + // Quest interactions use QuestUpdate, while FieldObjectResearch grants the + // object's collection/reward after its quest interaction is no longer active. + for _, quest := range obj.InteractionQuests { + if _, active := s.state.QuestInPack(quest, pack, s.questDifficulty(pack)); active && !s.state.QuestCleared(quest, pack, s.questDifficulty(pack)) { + return 59, nil, true, fmt.Errorf("%w: research belongs to active quest", ErrInvalidRequest) + } + } + if obj.Type == 1 { + eligible := false + // This server validates a learned research talent. Client animation and + // temporary highlight flags are presentation state rather than authority. + if s.characters != nil { + for _, c := range s.characters.All() { + if c.TalentLevel > 0 && s.researchCharacters[c.ID] { + eligible = true + break + } + } + } + if !eligible { + return 59, nil, true, fmt.Errorf("%w: research talent unavailable", ErrInvalidRequest) + } + } + prior, err := s.state.ResearchObjects(pack) + if err != nil { + return 59, nil, true, err + } + for _, v := range prior { + if v == int(id) { + items, found, e := s.state.ResearchObjectReply(pack, int(id)) + if e != nil { + return 59, nil, true, e + } + if !found { + return 59, nil, true, fmt.Errorf("world: researched object reward receipt missing") + } + return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil + } + } + if s.researchEconomy == nil { + return 59, nil, true, fmt.Errorf("world: research economy unavailable") + } + var rewards []gamedata.Reward + if obj.Reward.Type != 0 && obj.Reward.Count > 0 { + rewards = append(rewards, obj.Reward) + } + if obj.CollectionID > 0 { + rewards = append(rewards, gamedata.Reward{Type: 17, ID: uint64(obj.CollectionID), Count: 1}) + } + bundle, err := s.researchEconomy.Apply(fmt.Sprintf("research:%d:%d", pack, id), nil, rewards) + if err != nil { + return 59, nil, true, err + } + var items []byte + err = wire.Walk(bundle, func(f wire.Field) error { + if f.Number == 1 && f.Type == 2 { + items = wire.AppendBytes(items, 2, f.Value) + } + return nil + }) + if err != nil { + return 59, nil, true, err + } + if err = s.state.MarkResearchObject(pack, int(id), items); err != nil { + return 59, nil, true, err + } + return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil +} +func fieldCountType(obj gamedata.FieldRewardObject) uint64 { + if obj.Type == 3 { + return 2 + } + if obj.Type == 6 { + return 4 + } + return 3 +} +func (s *Service) handlePackRewardCounts(request []byte) (int, []byte, bool, error) { + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 226, nil, true, ErrInvalidRequest + } + packs, err := intsRequest(request, 2) + if err != nil || len(packs) == 0 { + return 226, nil, true, ErrInvalidRequest + } + types, err := intsRequest(request, 3) + if err != nil { + return 226, nil, true, err + } + if len(types) == 0 { + types = []uint64{1, 2, 3, 4} + } + var out []byte + seen := map[[2]uint64]bool{} + for _, p := range packs { + pack := int(p) + if pack <= 0 || !s.packUnlocked(pack) { + return 226, nil, true, ErrInvalidRequest + } + for _, t := range types { + if t < 1 || t > 4 { + return 226, nil, true, ErrInvalidRequest + } + key := [2]uint64{p, t} + if seen[key] { + continue + } + seen[key] = true + var count, max uint64 + if t == 1 { + d, e := s.researchDesign(pack) + if e != nil { + return 226, nil, true, e + } + ids, e := s.state.ResearchObjects(pack) + if e != nil { + return 226, nil, true, e + } + for _, o := range d.Objects { + if o.CollectionID > 0 || o.Reward.Type > 0 { + max++ + } + } + for _, id := range ids { + if o, ok := d.Objects[id]; ok && (o.CollectionID > 0 || o.Reward.Type > 0) { + count++ + } + } + } else { + d, e := s.fieldObjectDesign(pack) + if e != nil { + return 226, nil, true, e + } + for _, o := range d.Objects { + if fieldCountType(o) != t { + continue + } + max++ + period, e := s.fieldObjectPeriod(o) + if e != nil { + continue + } + opened, e := s.state.FieldRewardOpened(pack, o.ID, period) + if e != nil { + return 226, nil, true, e + } + if opened { + count++ + } + } + } + info := wire.AppendVarint(nil, 1, t) + info = wire.AppendVarint(info, 2, p) + info = wire.AppendVarint(info, 3, count) + info = wire.AppendVarint(info, 4, max) + out = wire.AppendBytes(out, 1, info) + } + } + return 226, out, true, nil +} diff --git a/go/internal/server/world/pack_reward_counts_test.go b/go/internal/server/world/pack_reward_counts_test.go new file mode 100644 index 0000000..ee07281 --- /dev/null +++ b/go/internal/server/world/pack_reward_counts_test.go @@ -0,0 +1,83 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/progress" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "testing" +) + +type researchEconomySpy struct { + calls int + rewards []gamedata.Reward +} + +func (e *researchEconomySpy) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) { + e.calls++ + e.rewards = rewards + item := wire.AppendVarint(nil, 1, 17) + item = wire.AppendVarint(item, 2, 103) + item = wire.AppendVarint(item, 3, 1) + return wire.AppendBytes(nil, 1, item), nil +} +func TestResearchRequiresCurrentMapPersistsRewardAndCounts(t *testing.T) { + s := testService() + storage := stateio.NewMemory() + var err error + s.state, err = progress.OpenStore(storage) + if err != nil { + t.Fatal(err) + } + if err = s.state.SetActivePackID(21); err != nil { + t.Fatal(err) + } + save := wire.AppendVarint(nil, 1, 1) + save = wire.AppendVarint(save, 2, 21) + save = wire.AppendString(save, 3, `{"MapId":211,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null}`) + if err = s.state.SaveUserPosition(save); err != nil { + t.Fatal(err) + } + s.researchDesigns = map[int]gamedata.FieldResearchDesign{21: {Objects: map[int]gamedata.FieldResearchObject{401: {ID: 401, Maps: []int{211}, CollectionID: 103}, 402: {ID: 402, Maps: []int{212}, CollectionID: 104}}}} + spy := &researchEconomySpy{} + s.researchEconomy = spy + req := wire.AppendVarint(nil, 1, 10) + req = wire.AppendVarint(req, 2, 21) + bad := wire.AppendVarint(append([]byte(nil), req...), 3, 402) + if _, _, _, err = s.Handle("/FieldObjectResearch", bad); err == nil { + t.Fatal("accepted different map") + } + req = wire.AppendVarint(req, 3, 401) + code, first, handled, err := s.Handle("/FieldObjectResearch", req) + if err != nil || !handled || code != 59 { + t.Fatalf("research: %d %v %v", code, handled, err) + } + if len(spy.rewards) != 1 || spy.rewards[0].Type != 17 || spy.rewards[0].ID != 103 { + t.Fatalf("collection reward must use type17: %+v", spy.rewards) + } + s.state, err = progress.OpenStore(storage) + if err != nil { + t.Fatal(err) + } + _, again, _, err := s.Handle("/FieldObjectResearch", req) + if err != nil || !bytes.Equal(first, again) || spy.calls != 1 { + t.Fatalf("replay duplicated reward or lost response: calls%d err%v", spy.calls, err) + } + counts := wire.AppendVarint(nil, 1, 11) + counts = wire.AppendVarint(counts, 2, 21) + counts = wire.AppendVarint(counts, 3, 1) + code, body, _, err := s.Handle("/PackRewardObjectCount", counts) + if err != nil || code != 226 { + t.Fatal(err) + } + info, _, err := wire.Bytes(body, 1) + if err != nil { + t.Fatal(err) + } + count, _, _ := wire.Varint(info, 3) + max, _, _ := wire.Varint(info, 4) + if count != 1 || max != 2 { + t.Fatalf("count%d max%d", count, max) + } +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index f459ea5..494cb6f 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -169,6 +169,7 @@ func (s *Service) setCurrentPack(packID int) { } type Service struct { + huntingGround interface{ EnsureForPack(int) ([]byte, error) } battleActive func() bool questDifficulties map[int]map[int]bool startingPackID int @@ -183,22 +184,28 @@ type Service struct { fieldObjects map[int]gamedata.FieldObjectDesign fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error) fieldReset gamedata.FieldResetSchedule - squadLevel func() (uint64, error) - seed Seed - state *progress.Store - starter *player.Starter - equipment *player.EquipmentInventory - inventory *player.Inventory - wallet *player.Wallet - characters *player.CharacterStore - collection *player.CollectionStore - decks *deck.Store - quests map[int]gamedata.QuestDesign - transition gamedata.PackTransition - packs map[int]map[int]gamedata.QuestDesign - transitions map[int]gamedata.PackTransition - activePackMu sync.RWMutex - activePack int + researchDesigns map[int]gamedata.FieldResearchDesign + researchLoader func(int) (gamedata.FieldResearchDesign, error) + researchCharacters map[uint64]bool + researchEconomy interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) + } + squadLevel func() (uint64, error) + seed Seed + state *progress.Store + starter *player.Starter + equipment *player.EquipmentInventory + inventory *player.Inventory + wallet *player.Wallet + characters *player.CharacterStore + collection *player.CollectionStore + decks *deck.Store + quests map[int]gamedata.QuestDesign + transition gamedata.PackTransition + packs map[int]map[int]gamedata.QuestDesign + transitions map[int]gamedata.PackTransition + activePackMu sync.RWMutex + activePack int } func (s *Service) AttachCollection(collection *player.CollectionStore) error { @@ -225,6 +232,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return s.handleFieldObjectInfo(request) case "/FieldObjectReward": return s.handleFieldObjectReward(request) + case "/FieldObjectResearch": + return s.handleFieldResearch(request) + case "/PackRewardObjectCount": + return s.handlePackRewardCounts(request) case "/QuestInfo", "/QuestAccept", "/QuestGiveUp": return s.handleQuestSelection(path, request) case "/PackBuy": @@ -562,6 +573,22 @@ func (s *Service) packInfo() ([]byte, error) { } func (s *Service) packInfoFor(packID int) ([]byte, error) { + out, err := s.basePackInfoFor(packID) + if err != nil || s.huntingGround == nil { + return out, err + } + ground, err := s.huntingGround.EnsureForPack(packID) + if err != nil { + return nil, err + } + if len(ground) == 0 { + return out, nil + } + out, _, err = wire.ReplaceBytes(out, 12, ground) + return out, err +} + +func (s *Service) basePackInfoFor(packID int) ([]byte, error) { var out []byte if active := s.firstUnclearedQuestFor(packID); active != 0 { quest := s.questInfoWire(packID, active) diff --git a/go/seed/v2_35_10/gacha_schedule.json b/go/seed/v2_35_10/gacha_schedule.json deleted file mode 100644 index 19ddbea..0000000 --- a/go/seed/v2_35_10/gacha_schedule.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "client_version": "2.35.10", - "schedules": [ - { "group_id": 166, "start_time": 1790121600000, "end_time": 1791417599000 }, - { "group_id": 30010, "start_time": 1788998400000, "end_time": 1791417599000 }, - { "group_id": 30011, "start_time": 1788998400000, "end_time": 1791417599000 }, - { "group_id": 206, "start_time": 1788998400000, "end_time": 1791417599000 }, - { "group_id": 205, "start_time": 1788998400000, "end_time": 1791417599000 }, - { "group_id": 208, "start_time": 1790121600000, "end_time": 1791417599000 }, - { "group_id": 153, "start_time": 1790121600000, "end_time": 1791417599000 }, - { "group_id": 72, "start_time": 1790121600000, "end_time": 1791417599000 }, - { "group_id": 71, "start_time": 1790121600000, "end_time": 1791417599000 }, - { "group_id": 207, "start_time": 1790121600000, "end_time": 1791417599000 }, - { "group_id": 1009, "start_time": 1789776000000, "end_time": 1790985600000 } - ], - "step_ups": [ - { "group_id": 29, "start_time": 1788998400000, "end_time": 1791417599000 }, - { "group_id": 30, "start_time": 1790121600000, "end_time": 1791417599000 } - ] -} diff --git a/go/seed/v2_35_10/readonly.json b/go/seed/v2_35_10/readonly.json index 48f108a..4867f3a 100644 --- a/go/seed/v2_35_10/readonly.json +++ b/go/seed/v2_35_10/readonly.json @@ -1,1318 +1,6 @@ { "version": "2.35.10", "responses": { - "/CashShopInfo": { - "packet_code": 60, - "fields": [ - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 76 - }, - { - "number": 4, - "type": 0, - "varint": 1714003200000 - }, - { - "number": 5, - "type": 0, - "varint": 4102444800000 - }, - { - "number": 6, - "type": 0, - "varint": 3 - }, - { - "number": 8, - "type": 0, - "varint": 164 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 77 - }, - { - "number": 4, - "type": 0, - "varint": 1714003200000 - }, - { - "number": 5, - "type": 0, - "varint": 4102444800000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 165 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 160 - }, - { - "number": 4, - "type": 0, - "varint": 1734393600000 - }, - { - "number": 5, - "type": 0, - "varint": 7289621999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 354 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 159 - }, - { - "number": 4, - "type": 0, - "varint": 1734393600000 - }, - { - "number": 5, - "type": 0, - "varint": 7289621999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 355 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 158 - }, - { - "number": 4, - "type": 0, - "varint": 1734393600000 - }, - { - "number": 5, - "type": 0, - "varint": 7289621999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 356 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 293 - }, - { - "number": 4, - "type": 0, - "varint": 1757548800000 - }, - { - "number": 5, - "type": 0, - "varint": 7289621999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 618 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 292 - }, - { - "number": 4, - "type": 0, - "varint": 1757548800000 - }, - { - "number": 5, - "type": 0, - "varint": 7289621999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 619 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 291 - }, - { - "number": 4, - "type": 0, - "varint": 1757548800000 - }, - { - "number": 5, - "type": 0, - "varint": 7289621999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 620 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 302 - }, - { - "number": 4, - "type": 0, - "varint": 1758758400000 - }, - { - "number": 5, - "type": 0, - "varint": 7289621999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 649 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 301 - }, - { - "number": 4, - "type": 0, - "varint": 1758758400000 - }, - { - "number": 5, - "type": 0, - "varint": 7289621999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 650 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 419 - }, - { - "number": 4, - "type": 0, - "varint": 1774483200000 - }, - { - "number": 5, - "type": 0, - "varint": 253377331199000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 930 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 418 - }, - { - "number": 4, - "type": 0, - "varint": 1774483200000 - }, - { - "number": 5, - "type": 0, - "varint": 253374911999000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 931 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 431 - }, - { - "number": 4, - "type": 0, - "varint": 1774483200000 - }, - { - "number": 5, - "type": 0, - "varint": 253378108799000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 937 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 445 - }, - { - "number": 4, - "type": 0, - "varint": 1778112000000 - }, - { - "number": 5, - "type": 0, - "varint": 3482351999000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 997 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 446 - }, - { - "number": 4, - "type": 0, - "varint": 1778112000000 - }, - { - "number": 5, - "type": 0, - "varint": 3483475199000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 998 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 447 - }, - { - "number": 4, - "type": 0, - "varint": 1778112000000 - }, - { - "number": 5, - "type": 0, - "varint": 3483475199000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 999 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 503 - }, - { - "number": 4, - "type": 0, - "varint": 1787788800000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1145 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 600001 - }, - { - "number": 2, - "type": 0, - "varint": 75 - }, - { - "number": 4, - "type": 0, - "varint": 1787788800000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1154 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 1200001 - }, - { - "number": 2, - "type": 0, - "varint": 31 - }, - { - "number": 4, - "type": 0, - "varint": 1787788800000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1155 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 507 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1156 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 508 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1157 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 509 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1158 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 510 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1159 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 511 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1160 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 512 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1161 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 513 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1162 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 514 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1163 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 515 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 8, - "type": 0, - "varint": 1164 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 516 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 8, - "type": 0, - "varint": 1165 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 517 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 8, - "type": 0, - "varint": 1166 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 500001 - }, - { - "number": 2, - "type": 0, - "varint": 357 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1167 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 500001 - }, - { - "number": 2, - "type": 0, - "varint": 358 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1168 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 500001 - }, - { - "number": 2, - "type": 0, - "varint": 359 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1169 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 500001 - }, - { - "number": 2, - "type": 0, - "varint": 360 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1170 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 1100001 - }, - { - "number": 2, - "type": 0, - "varint": 9100033 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0, - "varint": 5 - }, - { - "number": 8, - "type": 0, - "varint": 1171 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 1500001 - }, - { - "number": 2, - "type": 0, - "varint": 9100037 - }, - { - "number": 4, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 0 - }, - { - "number": 8, - "type": 0, - "varint": 1172 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 310001 - }, - { - "number": 2, - "type": 0, - "varint": 233 - }, - { - "number": 4, - "type": 0, - "varint": 1789835146000 - }, - { - "number": 5, - "type": 0, - "varint": 1790094346000 - }, - { - "number": 6, - "type": 0, - "varint": 10 - } - ] - }, - { - "number": 2, - "type": 0, - "varint": 1789862400000 - }, - { - "number": 3, - "type": 0, - "varint": 1789948800000 - }, - { - "number": 4, - "type": 0, - "varint": 1790812800000 - } - ] - }, "/ColosseumUserInfo": { "packet_code": 473, "fields": [ @@ -1375,325 +63,6 @@ } ] }, - "/EventHubInfo": { - "packet_code": 0, - "fields": [ - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 74 - }, - { - "number": 2, - "type": 0, - "varint": 57 - }, - { - "number": 3, - "type": 0, - "varint": 1787788800000 - }, - { - "number": 4, - "type": 0, - "varint": 1788998399000 - }, - { - "number": 5, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 1 - }, - { - "number": 2, - "type": 0, - "varint": 2 - }, - { - "number": 3, - "type": 2, - "bytes": "9ww=" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 2 - }, - { - "number": 2, - "type": 0, - "varint": 2 - }, - { - "number": 3, - "type": 2, - "bytes": "+Aw=" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 4 - }, - { - "number": 2, - "type": 0, - "varint": 3 - }, - { - "number": 3, - "type": 2, - "bytes": "TQ==" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 6 - }, - { - "number": 2, - "type": 0, - "varint": 9 - }, - { - "number": 3, - "type": 2, - "bytes": "+Qw=" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 7 - }, - { - "number": 2, - "type": 0, - "varint": 8 - }, - { - "number": 3, - "type": 2, - "bytes": "7Qw=" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 12 - }, - { - "number": 2, - "type": 0, - "varint": 15 - }, - { - "number": 3, - "type": 2, - "bytes": "+gz7DPwM/Qz+DP8M" - } - ] - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 75 - }, - { - "number": 2, - "type": 0, - "varint": 58 - }, - { - "number": 3, - "type": 0, - "varint": 1788998400000 - }, - { - "number": 4, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 5, - "type": 0, - "varint": 1791417599000 - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 1 - }, - { - "number": 2, - "type": 0, - "varint": 2 - }, - { - "number": 3, - "type": 2, - "bytes": "jg0=" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 2 - }, - { - "number": 2, - "type": 0, - "varint": 2 - }, - { - "number": 3, - "type": 2, - "bytes": "jw0=" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 4 - }, - { - "number": 2, - "type": 0, - "varint": 3 - }, - { - "number": 3, - "type": 2, - "bytes": "Tg==" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 6 - }, - { - "number": 2, - "type": 0, - "varint": 9 - }, - { - "number": 3, - "type": 2, - "bytes": "kA0=" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 7 - }, - { - "number": 2, - "type": 0, - "varint": 8 - }, - { - "number": 3, - "type": 2, - "bytes": "hg0=" - } - ] - }, - { - "number": 6, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 8 - }, - { - "number": 2, - "type": 0, - "varint": 10 - }, - { - "number": 3, - "type": 2, - "bytes": "jQ0=" - } - ] - } - ] - } - ] - }, "/FishingInfo": { "packet_code": 456, "fields": [ @@ -1813,1422 +182,6 @@ } ] }, - "/MiniGameHubInfo": { - "packet_code": 390, - "fields": [ - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 2, - "type": 0, - "varint": 705 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 1 - }, - { - "number": 2, - "type": 0, - "varint": 788 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 2 - }, - { - "number": 2, - "type": 0, - "varint": 870 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 3 - }, - { - "number": 2, - "type": 0, - "varint": 1007 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 4 - }, - { - "number": 2, - "type": 0, - "varint": 1103 - } - ] - }, - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 5 - }, - { - "number": 2, - "type": 0, - "varint": 1434 - } - ] - } - ] - }, - "/MonsterHuntScheduleInfo": { - "packet_code": 0, - "fields": [ - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 78 - }, - { - "number": 2, - "type": 0, - "varint": 1789516800000 - }, - { - "number": 3, - "type": 0, - "varint": 1790089199000 - }, - { - "number": 6, - "type": 0, - "varint": 1 - } - ] - }, - { - "number": 2, - "type": 0, - "varint": 73 - }, - { - "number": 3, - "type": 0, - "varint": 6 - }, - { - "number": 4, - "type": 0, - "varint": 1790121599000 - }, - { - "number": 7, - "type": 0, - "varint": 1 - } - ] - }, - { - "number": 2, - "type": 0, - "varint": 5 - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 1 - }, - { - "number": 2, - "type": 0, - "varint": 1 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 2 - }, - { - "number": 2, - "type": 0, - "varint": 2 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 3 - }, - { - "number": 2, - "type": 0, - "varint": 3 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 4 - }, - { - "number": 2, - "type": 0, - "varint": 4 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 5 - }, - { - "number": 2, - "type": 0, - "varint": 5 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 6 - }, - { - "number": 2, - "type": 0, - "varint": 6 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 7 - }, - { - "number": 2, - "type": 0, - "varint": 7 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 8 - }, - { - "number": 2, - "type": 0, - "varint": 8 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 9 - }, - { - "number": 2, - "type": 0, - "varint": 9 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 10 - }, - { - "number": 2, - "type": 0, - "varint": 10 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 11 - }, - { - "number": 2, - "type": 0, - "varint": 11 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 12 - }, - { - "number": 2, - "type": 0, - "varint": 12 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 13 - }, - { - "number": 2, - "type": 0, - "varint": 13 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 14 - }, - { - "number": 2, - "type": 0, - "varint": 14 - }, - { - "number": 4, - "type": 0, - "varint": 1 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 15 - }, - { - "number": 2, - "type": 0, - "varint": 15 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 16 - }, - { - "number": 2, - "type": 0, - "varint": 16 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 17 - }, - { - "number": 2, - "type": 0, - "varint": 17 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 18 - }, - { - "number": 2, - "type": 0, - "varint": 18 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 19 - }, - { - "number": 2, - "type": 0, - "varint": 19 - }, - { - "number": 4, - "type": 0, - "varint": 1 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 20 - }, - { - "number": 2, - "type": 0, - "varint": 20 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 21 - }, - { - "number": 2, - "type": 0, - "varint": 21 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 22 - }, - { - "number": 2, - "type": 0, - "varint": 14 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 23 - }, - { - "number": 2, - "type": 0, - "varint": 23 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 24 - }, - { - "number": 2, - "type": 0, - "varint": 24 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 25 - }, - { - "number": 2, - "type": 0, - "varint": 25 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 26 - }, - { - "number": 2, - "type": 0, - "varint": 19 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 27 - }, - { - "number": 2, - "type": 0, - "varint": 26 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 28 - }, - { - "number": 2, - "type": 0, - "varint": 27 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 29 - }, - { - "number": 2, - "type": 0, - "varint": 28 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 30 - }, - { - "number": 2, - "type": 0, - "varint": 29 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 31 - }, - { - "number": 2, - "type": 0, - "varint": 30 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 32 - }, - { - "number": 2, - "type": 0, - "varint": 31 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 33 - }, - { - "number": 2, - "type": 0, - "varint": 32 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 34 - }, - { - "number": 2, - "type": 0, - "varint": 33 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 35 - }, - { - "number": 2, - "type": 0, - "varint": 34 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 36 - }, - { - "number": 2, - "type": 0, - "varint": 35 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 37 - }, - { - "number": 2, - "type": 0, - "varint": 36 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 38 - }, - { - "number": 2, - "type": 0, - "varint": 37 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 39 - }, - { - "number": 2, - "type": 0, - "varint": 38 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 40 - }, - { - "number": 2, - "type": 0, - "varint": 39 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 41 - }, - { - "number": 2, - "type": 0, - "varint": 40 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 42 - }, - { - "number": 2, - "type": 0, - "varint": 41 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 43 - }, - { - "number": 2, - "type": 0, - "varint": 42 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 44 - }, - { - "number": 2, - "type": 0, - "varint": 43 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 45 - }, - { - "number": 2, - "type": 0, - "varint": 44 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 46 - }, - { - "number": 2, - "type": 0, - "varint": 45 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 47 - }, - { - "number": 2, - "type": 0, - "varint": 46 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 48 - }, - { - "number": 2, - "type": 0, - "varint": 47 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 49 - }, - { - "number": 2, - "type": 0, - "varint": 48 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 50 - }, - { - "number": 2, - "type": 0, - "varint": 49 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 51 - }, - { - "number": 2, - "type": 0, - "varint": 50 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 52 - }, - { - "number": 2, - "type": 0, - "varint": 51 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 53 - }, - { - "number": 2, - "type": 0, - "varint": 52 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 54 - }, - { - "number": 2, - "type": 0, - "varint": 53 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 55 - }, - { - "number": 2, - "type": 0, - "varint": 54 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 56 - }, - { - "number": 2, - "type": 0, - "varint": 55 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 57 - }, - { - "number": 2, - "type": 0, - "varint": 56 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 58 - }, - { - "number": 2, - "type": 0, - "varint": 57 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 59 - }, - { - "number": 2, - "type": 0, - "varint": 59 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 60 - }, - { - "number": 2, - "type": 0, - "varint": 60 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 61 - }, - { - "number": 2, - "type": 0, - "varint": 58 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 62 - }, - { - "number": 2, - "type": 0, - "varint": 55 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 63 - }, - { - "number": 2, - "type": 0, - "varint": 61 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 64 - }, - { - "number": 2, - "type": 0, - "varint": 62 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 65 - }, - { - "number": 2, - "type": 0, - "varint": 59 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 66 - }, - { - "number": 2, - "type": 0, - "varint": 63 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 67 - }, - { - "number": 2, - "type": 0, - "varint": 64 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 68 - }, - { - "number": 2, - "type": 0, - "varint": 65 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 69 - }, - { - "number": 2, - "type": 0, - "varint": 66 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 70 - }, - { - "number": 2, - "type": 0, - "varint": 67 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 71 - }, - { - "number": 2, - "type": 0, - "varint": 68 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 72 - }, - { - "number": 2, - "type": 0, - "varint": 69 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 73 - }, - { - "number": 2, - "type": 0, - "varint": 68 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 74 - }, - { - "number": 2, - "type": 0, - "varint": 70 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 75 - }, - { - "number": 2, - "type": 0, - "varint": 65 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 76 - }, - { - "number": 2, - "type": 0, - "varint": 71 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 77 - }, - { - "number": 2, - "type": 0, - "varint": 72 - } - ] - }, - { - "number": 3, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 78 - }, - { - "number": 2, - "type": 0, - "varint": 73 - } - ] - } - ] - }, "/PassInfo": { "packet_code": 124, "fields": [ diff --git a/go/seed/v2_35_10/schedule.json b/go/seed/v2_35_10/schedule.json deleted file mode 100644 index 1b8381c..0000000 --- a/go/seed/v2_35_10/schedule.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "version": "2.35.10", - "calculate_milliseconds": 32400000, - "contents": [ - { - "id": 1, - "current": { - "id": 160, - "start_milliseconds": 1789344000000, - "end_milliseconds": 1789916399000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 161, - "start_milliseconds": 1789948800000, - "end_milliseconds": 1790521199000, - "rank_reward_group_id": 1 - } - }, - { - "id": 2, - "current": { - "id": 58, - "start_milliseconds": 1755129600000, - "end_milliseconds": 253370678399000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 58, - "start_milliseconds": 1755129600000, - "end_milliseconds": 253370678399000, - "rank_reward_group_id": 1 - } - }, - { - "id": 3, - "current": { - "id": 129, - "start_milliseconds": 1789657200000, - "end_milliseconds": 1790262000000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 999999, - "start_milliseconds": 253370764800000, - "end_milliseconds": 253370764800000, - "rank_reward_group_id": 1 - } - }, - { - "id": 4, - "current": { - "id": 862, - "start_milliseconds": 1789830000000, - "end_milliseconds": 1789916400000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 999999, - "start_milliseconds": 253370764800000, - "end_milliseconds": 253370764800000, - "rank_reward_group_id": 1 - } - }, - { - "id": 5, - "current": { - "id": 29, - "start_milliseconds": 1787788800000, - "end_milliseconds": 1790089199000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 30, - "start_milliseconds": 1790121600000, - "end_milliseconds": 1791385199000, - "rank_reward_group_id": 1 - } - }, - { - "id": 6, - "current": { - "id": 26, - "start_milliseconds": 1787788800000, - "end_milliseconds": 1788361199000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 27, - "start_milliseconds": 1790121600000, - "end_milliseconds": 1790693999000, - "rank_reward_group_id": 1 - } - }, - { - "id": 7, - "current": { - "id": 14, - "start_milliseconds": 1788220800000, - "end_milliseconds": 1790812800000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 999999, - "start_milliseconds": 253370764800000, - "end_milliseconds": 253370764800000, - "rank_reward_group_id": 1 - } - }, - { - "id": 8, - "current": { - "id": 42, - "start_milliseconds": 1789603200000, - "end_milliseconds": 1790089199000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 43, - "start_milliseconds": 1790121600000, - "end_milliseconds": 1790780399000, - "rank_reward_group_id": 1 - } - }, - { - "id": 9, - "current": { - "id": 2, - "start_milliseconds": 1787788800000, - "end_milliseconds": 1792594799000, - "rank_reward_group_id": 1 - }, - "next": { - "id": 999999, - "start_milliseconds": 253370764800000, - "end_milliseconds": 253370764800000, - "rank_reward_group_id": 1 - } - } - ], - "regular": [ - { - "content_id": 1, - "season": 5 - }, - { - "content_id": 2, - "season": 6 - }, - { - "content_id": 5, - "season": 5 - }, - { - "content_id": 8, - "season": 0 - }, - { - "content_id": 9, - "season": 0 - } - ] -} diff --git a/plugins/CashShop/AttendanceRewardEnvelope.cs b/plugins/CashShop/AttendanceRewardEnvelope.cs new file mode 100644 index 0000000..0dd8940 --- /dev/null +++ b/plugins/CashShop/AttendanceRewardEnvelope.cs @@ -0,0 +1,35 @@ +using System; +using Google.Protobuf; + +namespace Bd2CashShop; + +// Owned-server additions stay in protobuf unknown fields. The frozen client +// AttendanceResponse/EventRewardResponse schemas and their handlers stay intact. +internal static class AttendanceRewardEnvelope +{ + internal static bool TryRead(IMessage response, out ByteString rewards, out string receipt) + { + rewards = null; + receipt = null; + if (response == null || response.CalculateSize() > 8 * 1024 * 1024) return false; + using (var input = new CodedInputStream(response.ToByteArray())) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + if (tag == (1001u << 3 | 2u)) + { + if (rewards != null) return false; + rewards = input.ReadBytes(); + } + else if (tag == (1002u << 3 | 2u)) + { + if (receipt != null) return false; + receipt = input.ReadString(); + } + else input.SkipLastField(); + } + } + return rewards != null && !string.IsNullOrWhiteSpace(receipt) && receipt.Length <= 1024; + } +} diff --git a/plugins/CashShop/CashShop.csproj b/plugins/CashShop/CashShop.csproj new file mode 100644 index 0000000..b90cb09 --- /dev/null +++ b/plugins/CashShop/CashShop.csproj @@ -0,0 +1,37 @@ + + + netstandard2.1 + latest + disable + BD2CashShop + Bd2CashShop + cash_shop + 2.35.10 + + + + + $(BD2BepInExDir)\core\BepInEx.dll + false + + + $(BD2BepInExDir)\core\0Harmony.dll + false + + + $(BD2ManagedDir)\UnityEngine.CoreModule.dll + false + + + $(BD2ManagedDir)\UnityEngine.dll + false + + + $(BD2ManagedDir)\Newtonsoft.Json.dll + false + + $(BD2ManagedDir)/Unity.TextMeshPro.dllfalse + $(BD2ManagedDir)/UnityEngine.UI.dllfalse + $(BD2ManagedDir)/Google.Protobuf.dllfalse + + diff --git a/plugins/CashShop/CommerceCatalog.cs b/plugins/CashShop/CommerceCatalog.cs new file mode 100644 index 0000000..6d6422b --- /dev/null +++ b/plugins/CashShop/CommerceCatalog.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Bd2CashShop; + +internal sealed class CommerceCatalog +{ + [JsonProperty("schema_version", Required = Required.Always)] public int SchemaVersion { get; set; } + [JsonProperty("game_version", Required = Required.Always)] public string GameVersion { get; set; } + [JsonProperty("products", Required = Required.Always)] public CommerceProduct[] Products { get; set; } + + internal Dictionary Validate(string gameVersion) + { + if (SchemaVersion != 1 || GameVersion != gameVersion || Products == null) + throw new InvalidOperationException("Commerce catalog version mismatch"); + var result = new Dictionary(StringComparer.Ordinal); + foreach (CommerceProduct product in Products) + { + if (product == null || string.IsNullOrWhiteSpace(product.Sku) || product.GroupId <= 0 || product.ProductId <= 0 || + product.SaleGroup < 0 || product.Amount < 0 || product.ItemType != 0 && product.ItemType != 2 && product.ItemType != 3 && product.ItemType != 4 || + product.ItemType == 0 && product.Amount != 0 || !product.Recharge && product.ItemType != 2 || result.ContainsKey(product.Key)) + throw new InvalidOperationException("Invalid or duplicate commerce SKU"); + result.Add(product.Key, product); + } + return result; + } +} + +internal sealed class CommerceProduct +{ + internal string Key => MakeKey(GroupId, ProductId, SaleGroup, Sku); + internal static string MakeKey(int group, int id, int sale, string sku) => group + "/" + id + "/" + sale + "/" + sku; + [JsonProperty("sku", Required = Required.Always)] public string Sku { get; set; } + [JsonProperty("group_id", Required = Required.Always)] public int GroupId { get; set; } + [JsonProperty("product_id", Required = Required.Always)] public int ProductId { get; set; } + [JsonProperty("sale_group", Required = Required.Always)] public int SaleGroup { get; set; } + [JsonProperty("item_type", Required = Required.Always)] public int ItemType { get; set; } + [JsonProperty("amount", Required = Required.Always)] public long Amount { get; set; } + [JsonProperty("recharge", Required = Required.Always)] public bool Recharge { get; set; } + [JsonProperty("enabled", Required = Required.Always)] public bool Enabled { get; set; } +} diff --git a/plugins/CashShop/CommercePayments.cs b/plugins/CashShop/CommercePayments.cs new file mode 100644 index 0000000..1cc6cae --- /dev/null +++ b/plugins/CashShop/CommercePayments.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Bd2CashShop; + +internal static class CommercePayments +{ + private static readonly HashSet Applied = new HashSet(); + + internal static string Receipt(long payment, CommerceProduct quote) => + "bd2-local-commerce-v1:" + payment.ToString(CultureInfo.InvariantCulture) + ":" + + quote.ItemType.ToString(CultureInfo.InvariantCulture) + ":" + quote.Amount.ToString(CultureInfo.InvariantCulture); + + internal static Action AcceptOnce(long payment, Func isCurrentSession, Action debit, Action continuation) + { + return delegate + { + // The transport invokes this callback only on a successful response. + // A replay must not charge the displayed client balance twice. + if (isCurrentSession() && Applied.Add(payment)) debit(); + continuation?.Invoke(); + }; + } + + internal static void Clear() => Applied.Clear(); +} diff --git a/plugins/CashShop/Plugin.cs b/plugins/CashShop/Plugin.cs new file mode 100644 index 0000000..c6ffb04 --- /dev/null +++ b/plugins/CashShop/Plugin.cs @@ -0,0 +1,636 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading.Tasks; +using BD2.GameNames; +using BepInEx; +using HarmonyLib; +using Newtonsoft.Json; +using TMPro; +using UnityEngine; +using gamfs.Platform; +using gamfs; +using BDNetwork; +using Proto.Net; +using Proto.Design.common; +using Google.Protobuf; + +namespace Bd2CashShop; + +[BepInPlugin("bd2.cashshop", "BD2 Cash Shop", Bd2Build.Versions.Plugin)] +[BepInDependency("bd2.localidentity")] +public sealed class Plugin : BaseUnityPlugin +{ + private static Harmony harmony; + private static CommerceHost host; + private static bool applicationQuitting; + private static Dictionary products; + private static string catalogOrigin; + private static readonly Dictionary priceViews = new Dictionary(); + private static readonly HashSet attendanceReceipts = new HashSet(StringComparer.Ordinal); + private static readonly HashSet attendanceRefreshPayments = new HashSet(); + private sealed class PriceLabelState + { + internal string Text; + internal bool Active; + internal TextAlignmentOptions Alignment; + internal bool Wrap; + internal Vector2 AnchorMin, AnchorMax, Pivot, OffsetMin, OffsetMax; + internal bool Layout; + internal Dictionary Drivers; + } + private static readonly Dictionary> unavailablePriceLabels = new Dictionary>(); + private static BepInEx.Logging.ManualLogSource Log; + private const string Unavailable = "当前服务器未开通购买功能"; + private const string Marker = " Logger.LogInfo(message)); + harmony = new Harmony("bd2.cashshop"); + Patch(typeof(PlatformManager), "Purchase", nameof(Purchase), false, 3); + Patch(typeof(PlatformManager), "FinishPurchase", nameof(Suppress), false, 2); + Patch(typeof(PlatformManager), "RetryUnfinishedPurchase", nameof(Suppress), false, 0); + Patch(typeof(PlatformManager), "SendInAppEventPurchase", nameof(Suppress), false, 4); + Patch(typeof(CashShopPacket), "SendInAppEventPurchase", nameof(Suppress), false, 2); + Patch(typeof(CashShopPacket), "AddSubscribeAttendanceInfo", nameof(Suppress), false, 1); + Patch(typeof(CashShopPacket), "SendPlatformCashShopBuyRequest", nameof(BeforeRequest), false, 10); + Patch(typeof(PlatformManager), "GetProductMarketPriceWithSymbol", nameof(PriceLabel), false, 1); + Patch(typeof(PlatformManager), "GetProductMarketPriceOnly", nameof(PriceNumber), false, 1); + Patch(typeof(PlatformManager), "GetProductCurrencyCode", nameof(CurrencyCode), false, 1); + Patch(typeof(PlatformRuler), "GetProductAsync", nameof(ProductPrefetch), false, 6); + Patch(typeof(PlatformManager), "GetPriceLocalization", nameof(LocalPriceLocalization), false, 4); + Patch(typeof(PlatformRuler), "InitializeGPG", nameof(SkipOfficialBilling), false, 1); + Patch(typeof(PlatformRuler), "CheckGPGOAuthStatus", nameof(LocalBillingStatus), false, 1); + Patch(typeof(PlatformRuler), "OnGetPriceLocalizationBySplit", nameof(LocalSplitPrices), false, 4); + Patch(typeof(CurrencyButton), "SetCash", nameof(SetCash), false, 1); + Patch(typeof(CashShopBuyPopupUI), "SetProductInfo", nameof(PopupPrice), true, 8); + MethodInfo paymentPopup = typeof(UIManager).GetGameMethod("OpenSelectiveMessagePopupUI", All, null, + new[] { typeof(Action), typeof(string), typeof(string), typeof(string), + typeof(Action), typeof(bool), typeof(bool), typeof(MessagePopupUI.EMessagePopupButtonStyle) }, null); + if (paymentPopup == null) throw new MissingMethodException("UIManager payment confirmation overload"); + harmony.Patch(paymentPopup, prefix: new HarmonyMethod(typeof(Plugin), nameof(SuppressExternalPaymentConfirmation))); + Patch(typeof(PackagePurchaseButton), "SetPrice", nameof(PackagePrice), true, 0); + Patch(typeof(PassRootUI), "CheckIsInAppBuyButton", nameof(PassPrice), true, 0); + Patch(typeof(BuyConfirmPopupUI), "SetPassBuyPopup", nameof(PassPopupPrice), true, 5); + Patch(typeof(GachaProductButtonElement), "Set", nameof(GachaPrice), true, 6); + PatchGeneratedReceiver("b__0", nameof(LoginPassRewards)); + PatchGeneratedReceiver("b__0", nameof(EventRewards)); + MethodInfo attendance = typeof(EventPacket).GetGameMethod("RecvAttendanceResponse", All, null, + new[] { typeof(IMessage), typeof(int), typeof(int) }, null); + if (attendance == null) throw new MissingMethodException("EventPacket.RecvAttendanceResponse(IMessage,int,int)"); + harmony.Patch(attendance, postfix: new HarmonyMethod(typeof(Plugin), nameof(AttendanceRewards))); + Patch(typeof(IntroUI), "Awake", nameof(IntroAwake), true, 0); + EnsureCommerceHost(); + Logger.LogInfo("Cash shop runtime installed: persistent catalog host and server-owned billing hooks"); + } + catch (Exception exception) + { + products = null; + Logger.LogError("Cash shop initialization failed: " + exception); + } + } + + private void Patch(Type type, string name, string callback, bool postfix, int count) + { + MethodInfo target = type.GetMethods(All).Single(method => method.IsGameMethod(name) && method.GetParameters().Length == count); + var patch = new HarmonyMethod(typeof(Plugin), callback); + harmony.Patch(target, prefix: postfix ? null : patch, postfix: postfix ? patch : null); + } + + private void PatchGeneratedReceiver(string readableName, string callback) + { + // Compiler-generated declaring types are found by their mapped methods; + // neither an obfuscated type name nor a generated class number is pinned. + MethodInfo target = typeof(EventPacket).GetNestedTypes(All) + .SelectMany(type => type.GetMethods(All | BindingFlags.DeclaredOnly)) + .Single(method => method.IsGameMethod(readableName) && method.ReturnType == typeof(bool) && + method.GetParameters().Select(parameter => parameter.ParameterType) + .SequenceEqual(new[] { typeof(byte[]), typeof(int), typeof(int) })); + harmony.Patch(target, postfix: new HarmonyMethod(typeof(Plugin), callback)); + } + + private static string Origin() + { + var uri = typeof(ServerURLInfo).GetGameField(nameof(ServerURLInfo.MaintenanceUri), All).GetValue(null) as Uri; + if (uri == null || uri.UserInfo.Length != 0 || uri.Scheme != "http" && uri.Scheme != "https") + throw new InvalidOperationException("Commerce server origin unavailable"); + return uri.GetLeftPart(UriPartial.Authority); + } + + private static void IntroAwake() => EnsureCommerceHost(); + + private static void EnsureCommerceHost() + { + if (applicationQuitting || host != null && host.gameObject.activeInHierarchy) return; + if (host != null) UnityEngine.Object.Destroy(host.gameObject); + var owner = new GameObject("BD2 Cash Shop Host"); + UnityEngine.Object.DontDestroyOnLoad(owner); + host = owner.AddComponent(); + Log?.LogInfo("Commerce runtime host created: reason=startup-or-intro-restoration"); + host.StartCoroutine(RefreshCatalog()); + } + + public sealed class CommerceHost : MonoBehaviour + { + private void OnApplicationQuit() { applicationQuitting = true; } + private void OnDestroy() + { + if (ReferenceEquals(host, this)) host = null; + Log?.LogInfo("Commerce runtime host stopped: reason=" + (applicationQuitting ? "application-quit" : "scene-destruction-awaiting-intro-restoration")); + } + } + + private static IEnumerator RefreshCatalog() + { + float nextFetch = 0; + while (true) + { + string origin = null; + try { origin = Origin(); } catch { } + if (origin != catalogOrigin) { products = null; catalogOrigin = origin; nextFetch = 0; RefreshPrices(); } + if (origin != null && Time.realtimeSinceStartup >= nextFetch) + { + Task> request = Task.Run(() => Fetch(origin)); + while (!request.IsCompleted) + { + string current = null; + try { current = Origin(); } catch { } + if (current != origin) { products = null; } + yield return null; + } + string after = null; + try { after = Origin(); } catch { } + if (request.IsFaulted) { products = null; Log?.LogWarning("Commerce catalog unavailable: " + request.Exception.GetBaseException().Message); } + else if (after == origin) { products = request.Result; Log?.LogInfo("Loaded commerce policy for " + products.Count + " SKUs"); } + RefreshPrices(); + nextFetch = Time.realtimeSinceStartup + (products == null ? 3 : 30); + } + yield return new WaitForSecondsRealtime(1); + } + } + + private static Dictionary Fetch(string origin) + { + var request = (HttpWebRequest)WebRequest.Create(origin + "/client/commerce"); + request.Method = "GET"; + request.Timeout = 10000; + request.ReadWriteTimeout = 10000; + request.AllowAutoRedirect = false; + string proxy = Environment.GetEnvironmentVariable("BD2_CLIENT_PROXY_URL"); + if (new Uri(origin).IsLoopback || string.IsNullOrWhiteSpace(proxy)) request.Proxy = null; + else + { + if (!Uri.TryCreate(proxy, UriKind.Absolute, out var proxyUri) || proxyUri.Scheme != "http" || proxyUri.UserInfo.Length != 0 || proxyUri.AbsolutePath != "/" || proxyUri.Query.Length != 0 || proxyUri.Fragment.Length != 0) + throw new InvalidOperationException("Unsupported client commerce proxy"); + request.Proxy = new WebProxy(proxyUri); + } + using (var response = (HttpWebResponse)request.GetResponse()) + using (var stream = response.GetResponseStream()) + using (var reader = new StreamReader(stream)) + { + if (response.StatusCode != HttpStatusCode.OK) throw new IOException("Commerce HTTP status " + response.StatusCode); + var bodyBuilder = new System.Text.StringBuilder(); + var buffer = new char[8192]; + int read; + while ((read = reader.Read(buffer, 0, buffer.Length)) != 0) + { + if (bodyBuilder.Length + read > 4 * 1024 * 1024) throw new IOException("Commerce catalog too large"); + bodyBuilder.Append(buffer, 0, read); + } + string body = bodyBuilder.ToString(); + return JsonConvert.DeserializeObject(body).Validate(Bd2Build.Versions.Game); + } + } + + private static CommerceProduct Resolve(string sku) + { + try + { + if (products == null || catalogOrigin != Origin() || sku == null) return null; + CashProductTable table = CashShopInfo.GetCashProductDTO(sku, SG.App.MarketType); + if (table == null || !products.TryGetValue(CommerceProduct.MakeKey(table.GroupId, table.Id, table.SaleGroup, sku), out var policy)) return null; + if (table == null || table.PriceType != 1 || table.GroupId != policy.GroupId || table.Id != policy.ProductId || table.SaleGroup != policy.SaleGroup || + CashShopInfo.GetProductIdByMarket(table, SG.App.MarketType) != sku) return null; + return policy; + } + catch { return null; } + } + + private static CommerceProduct Resolve(int group, int id, int sale) + { + try + { + var table = CashShopInfo.GetCashProductDTO(group, id, sale); + if (table == null || table.PriceType != 1 || products == null || catalogOrigin != Origin()) return null; + string sku = CashShopInfo.GetProductIdByMarket(table, SG.App.MarketType); + return products.TryGetValue(CommerceProduct.MakeKey(group, id, sale, sku), out var policy) ? policy : null; + } + catch { return null; } + } + + private static bool Purchase(string __0, object __1, Action __2) + { + var policy = Resolve(__0); + if (policy == null || !policy.Enabled) + { + __2?.Invoke(); + ShowPurchaseFailure(); + return false; + } + if (!HasFunds(policy)) + { + __2?.Invoke(); + InsufficientFunds(policy); + return false; + } + long pay = System.Threading.Interlocked.Increment(ref paySequence); + var result = new PurchaseData(__0, pay, "bd2-local:" + pay, CommercePayments.Receipt(pay, policy)); + (__1 as Delegate)?.DynamicInvoke(result); + return false; + } + + private static bool Suppress() => false; + private static bool ProductPrefetch(Action __3) { __3?.Invoke(); return false; } + private static bool LocalPriceLocalization(Action __1) + { + Log?.LogInfo("Commerce SDK suppressed: operation=price-localization reason=server-owned-cash-shop"); + __1?.Invoke(); + return false; + } + private static bool SkipOfficialBilling() + { + Log?.LogInfo("Commerce SDK suppressed: operation=gpg-billing-initialization reason=server-owned-cash-shop"); + return false; + } + private static bool LocalBillingStatus(Action __0) + { + Log?.LogInfo("Commerce SDK suppressed: operation=gpg-oauth-status reason=server-owned-cash-shop"); + __0?.Invoke(false); + return false; + } + private static bool LocalSplitPrices(Action __2) + { + Log?.LogInfo("Commerce SDK suppressed: operation=gpg-split-price-localization reason=server-owned-cash-shop"); + __2?.Invoke(); + return false; + } + private static bool PriceNumber(ref string __result) { __result = "0"; return false; } + private static bool CurrencyCode(ref string __result) { __result = "BD2"; return false; } + + private static bool BeforeRequest(int __0, int __1, int __2, long __3, string __5, ref Action __7, Action __8, ref Action __9) + { + CommerceProduct quote = Resolve(__0, __1, __2); + if (quote == null || !quote.Enabled || quote.Sku != __5) + { + __8?.Invoke(1); + ShowPurchaseFailure(); + return false; + } + if (!HasFunds(quote)) + { + __8?.Invoke(1); + InsufficientFunds(quote); + return false; + } + // Cash products retain PriceType=Cash, so the original success handler has + // no local subtraction. Apply the accepted quote once, only after success. + Action continuation = __7; + long payment = __3; + string origin = catalogOrigin; + object user = CommonPacket.UserInfo; + __7 = CommercePayments.AcceptOnce(payment, () => origin == catalogOrigin && ReferenceEquals(user, CommonPacket.UserInfo), delegate + { + if (quote.Amount > 0) + { + CommonPacket.RemoveHaveCurrency((EElementType)quote.ItemType, quote.Amount, false, true); + UIManager.UpdateUseCurrency((EElementType)quote.ItemType, ECurrencyDirectDisplayType.FadeOutOnlySubtractionCurrency, quote.Amount, 0, null, false); + } + }, continuation); + // The server has already supplied the real ticket expiry. Prevent the + // client's additional +30-day estimate and reload authoritative claims. + Action bought = __9; + __9 = delegate + { + bought?.Invoke(); + if (origin == catalogOrigin && ReferenceEquals(user, CommonPacket.UserInfo) && attendanceRefreshPayments.Add(payment)) + RefreshAttendanceIndependent(); + }; + return true; + } + + private static bool HasFunds(CommerceProduct quote) => quote.Amount == 0 || + CommonPacket.UserInfo != null && CommonPacket.GetHaveCurrencyCount((EElementType)quote.ItemType, false) >= quote.Amount; + + private static void ShowPurchaseFailure() => + UIManager.OpenSelectiveMessagePopupUI(Unavailable, "购买失败", MessagePopupUI.EMessagePopupButtonStyle.ONLY_OK); + + private static bool SuppressExternalPaymentConfirmation(string __1, string __2) + { + // Only the official cash checkout prompt is removed. Its callers proceed + // synchronously to our Purchase prefix, which opens the failure dialog + // for disabled goods or performs the server-owned purchase for enabled goods. + return __1 != LocalTextInfo.GetText(13104) || __2 != LocalTextInfo.GetText(13103); + } + + private static void InsufficientFunds(CommerceProduct quote) + { + if (quote.ItemType == 2 || quote.ItemType == 3) + UIManager.OpenCashShopLinkPopupUI((EElementType)quote.ItemType, 0, ShopLinkPopupUI.EExitFieldMapType.None, null, null, null); + else UIManager.OpenSelectiveMessagePopupUI(LocalTextInfo.GetText(154), "", MessagePopupUI.EMessagePopupButtonStyle.ONLY_OK); + } + + private static bool PriceLabel(string __0, ref string __result) + { + var policy = Resolve(__0); + string text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : policy.Amount.ToString(CultureInfo.InvariantCulture); + __result = Marker + Uri.EscapeDataString(__0 ?? "") + ">" + text + ""; + return false; + } + + private static CommerceProduct FromLabel(string label) + { + if (label == null) return null; + int start = label.IndexOf(Marker, StringComparison.Ordinal); + if (start < 0) return null; + start += Marker.Length; + int end = label.IndexOf('>', start); + return end < 0 ? null : Resolve(Uri.UnescapeDataString(label.Substring(start, end - start))); + } + + private static bool SetCash(CurrencyButton __instance, string __0) + { + RestorePricePrefixes(__instance); + if (__0 == null || !__0.Contains(Marker)) return true; + string sku = ExtractSku(__0); + priceViews[__instance] = () => Render(__instance, Resolve(sku)); + Render(__instance, FromLabel(__0)); + return false; + } + + private static void Render(CurrencyButton button, CommerceProduct policy) + { + if (button == null) return; + RestorePricePrefixes(button); + button.SetActive(true); + if (policy == null || !policy.Enabled || policy.ItemType == 0) + { + button.SetCash(policy != null && policy.Enabled ? "Free" : Unavailable); + return; + } + button.Set((EElementType)policy.ItemType, 0, policy.Amount); + button.SetEnough(CommonPacket.GetHaveCurrencyCount((EElementType)policy.ItemType, false) >= policy.Amount); + } + + private static void RestorePricePrefixes(CurrencyButton button) + { + if (button == null || !unavailablePriceLabels.TryGetValue(button, out var saved)) return; + foreach (var entry in saved) + if (entry.Key != null) + { + entry.Key.text = entry.Value.Text; + entry.Key.gameObject.SetActive(entry.Value.Active); + if (entry.Value.Layout) + { + entry.Key.alignment = entry.Value.Alignment; + entry.Key.enableWordWrapping = entry.Value.Wrap; + RectTransform rect = entry.Key.rectTransform; + rect.anchorMin = entry.Value.AnchorMin; rect.anchorMax = entry.Value.AnchorMax; + rect.pivot = entry.Value.Pivot; rect.offsetMin = entry.Value.OffsetMin; rect.offsetMax = entry.Value.OffsetMax; + if (entry.Value.Drivers != null) + foreach (var driver in entry.Value.Drivers) if (driver.Key != null) driver.Key.enabled = driver.Value; + } + } + unavailablePriceLabels.Remove(button); + } + + private static void PopupPrice(CashShopBuyPopupUI __instance, EElementType __3, string __7) + { + CurrencyButton button = Field(__instance, "_currencyButton"); + if (button == null) return; + RestorePricePrefixes(button); + priceViews.Remove(button); + if (__3 != EElementType.Cash) return; + string sku = __7; + priceViews[button] = () => { Render(button, Resolve(sku)); ShowUnavailablePopupPrice(__instance, button, Resolve(sku)); }; + ShowUnavailablePopupPrice(__instance, button, Resolve(sku)); + } + + private static void ShowUnavailablePopupPrice(CashShopBuyPopupUI popup, CurrencyButton button, CommerceProduct policy) + { + if (policy != null && policy.Enabled) return; + // CurrencyParent is the gray price strip. Never search its parent, + // which also contains the separate confirmation and cancellation buttons. + Transform container = button.transform; + GameObject buy = Field(popup, "_objBuyButton"), cancel = Field(popup, "_objCancelButton"); + TMP_Text price = button.TextPrice; + var saved = new Dictionary(); + foreach (var label in container.GetComponentsInChildren(true)) + { + if (label == price || buy != null && label.transform.IsChildOf(buy.transform) || cancel != null && label.transform.IsChildOf(cancel.transform)) continue; + if (label.text != "购买费用") continue; + RectTransform rect = label.rectTransform; + saved[label] = new PriceLabelState { Text = label.text, Active = label.gameObject.activeSelf, + Layout = true, Alignment = label.alignment, Wrap = label.enableWordWrapping, + AnchorMin = rect.anchorMin, AnchorMax = rect.anchorMax, Pivot = rect.pivot, + OffsetMin = rect.offsetMin, OffsetMax = rect.offsetMax }; + var drivers = new Dictionary(); + foreach (var group in container.GetComponentsInChildren(true)) + { drivers[group] = group.enabled; group.enabled = false; } + foreach (var fitter in container.GetComponentsInChildren(true)) + { drivers[fitter] = fitter.enabled; fitter.enabled = false; } + saved[label].Drivers = drivers; + label.text = Unavailable; + label.gameObject.SetActive(true); + label.alignment = TextAlignmentOptions.Center; + label.enableWordWrapping = false; + rect.anchorMin = Vector2.zero; rect.anchorMax = Vector2.one; rect.pivot = new Vector2(0.5f, 0.5f); + rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero; + } + if (saved.Count == 0) + { + Log?.LogWarning("Commerce price layout: fee label missing inside CurrencyParent; centering existing price label"); + if (price == null) return; + RectTransform rect = price.rectTransform; + saved[price] = new PriceLabelState { Text = price.text, Active = price.gameObject.activeSelf, + Layout = true, Alignment = price.alignment, Wrap = price.enableWordWrapping, + AnchorMin = rect.anchorMin, AnchorMax = rect.anchorMax, Pivot = rect.pivot, + OffsetMin = rect.offsetMin, OffsetMax = rect.offsetMax }; + var drivers = new Dictionary(); + foreach (var group in container.GetComponentsInChildren(true)) + { drivers[group] = group.enabled; group.enabled = false; } + foreach (var fitter in container.GetComponentsInChildren(true)) + { drivers[fitter] = fitter.enabled; fitter.enabled = false; } + saved[price].Drivers = drivers; + price.text = Unavailable; + price.gameObject.SetActive(true); + price.alignment = TextAlignmentOptions.Center; + price.enableWordWrapping = false; + rect.anchorMin = Vector2.zero; rect.anchorMax = Vector2.one; rect.pivot = new Vector2(0.5f, 0.5f); + rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero; + unavailablePriceLabels[button] = saved; + return; + } + if (price != null) + { + saved[price] = new PriceLabelState { Text = price.text, Active = price.gameObject.activeSelf }; + price.gameObject.SetActive(false); + } + unavailablePriceLabels[button] = saved; + } + + private static T Field(object target, string name) + { + for (Type type = target.GetType(); type != null; type = type.BaseType) + { + FieldInfo field = type.GetGameField(name, All | BindingFlags.DeclaredOnly); + if (field != null) return (T)field.GetValue(target); + } + throw new MissingFieldException(target.GetType().FullName, name); + } + + private static void PackagePrice(PackagePurchaseButton __instance) + { + priceViews.Remove(__instance); + int group = Field(__instance, "_productGroupId"), id = Field(__instance, "_productId"), sale = Field(__instance, "_productSaleGroupId"); + var table = CashShopInfo.GetCashProductDTO(group, id, sale); + if (table == null || table.PriceType != 1) return; + var button = Field(__instance, "_btnCurrency"); + priceViews[__instance] = () => PackagePrice(__instance); + CommerceProduct policy = Resolve(group, id, sale); + TMP_Text label = Field(__instance, "_textPrice"); + RestorePricePrefixes(button); + if (policy == null || !policy.Enabled) + { + if (button != null) button.SetActive(false); + if (label != null) { label.text = Unavailable; label.gameObject.SetActive(true); } + return; + } + Render(button, policy); + if (button != null) label?.gameObject.SetActive(false); + } + + private static void PassPrice(PassRootUI __instance, bool __result) + { + priceViews.Remove(__instance); + if (!__result) return; + var table = PassInfo.GetPassBuyTable(Field(__instance, "_curPassTableID"), Define_PassBuyType.PbPremium2); + priceViews[__instance] = () => PassPrice(__instance, true); + Render(Field(__instance, "_imgPremiumBuyPrice"), Field(__instance, "_txtPremiumBuyPrice"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup)); + } + + private static void PassPopupPrice(BuyConfirmPopupUI __instance, int __0, Define_PassBuyType __1) + { + priceViews.Remove(__instance); + if (__1 != Define_PassBuyType.PbPremium2) return; + var table = PassInfo.GetPassBuyTable(__0, __1); + int pass = __0; + Define_PassBuyType buy = __1; + priceViews[__instance] = () => PassPopupPrice(__instance, pass, buy); + Render(Field(__instance, "_uiSpriteCurrency"), Field(__instance, "_textCurrency"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup)); + } + + private static void Render(UISprite sprite, TMP_Text text, CommerceProduct policy) + { + if (sprite != null) + { + sprite.gameObject.SetActive(policy != null && policy.Enabled && policy.ItemType != 0); + if (policy != null && policy.Enabled && policy.ItemType != 0) sprite.SetSprite(CurrencyInfo.GetCurrencyDTO((EElementType)policy.ItemType).IconSpriteNameSmall); + } + if (text != null) text.text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : policy.Amount.ToString(CultureInfo.InvariantCulture); + } + + private static void GachaPrice(GachaProductButtonElement __instance, string __4) + { + var buttons = Field(__instance, "_currencyGachas"); + if (buttons == null || buttons.Length == 0) return; + foreach (var button in buttons) if (button != null) priceViews.Remove(button); + if (__4 == null || !__4.Contains(Marker)) return; + string sku = ExtractSku(__4); + priceViews[buttons[0]] = () => Render(buttons[0], Resolve(sku)); + foreach (var button in buttons) button?.SetActive(false); + Render(buttons[0], FromLabel(__4)); + Field(__instance, "_textDescription").text = ""; + } + + private static void AttendanceRewards(IMessage __0, int __2, bool __result) + { + if (__2 != 0 || !__result) return; + try + { + if (!AttendanceRewardEnvelope.TryRead(__0, out ByteString encoded, out string receipt)) return; + RewardDBInfoBundle bundle = RewardDBInfoBundle.Parser.ParseFrom(encoded); + string key = Origin() + "/" + receipt; + if (!attendanceReceipts.Add(key)) return; + var rewards = new List(); + DataManager.AddRewardInfoBundle(bundle, ref rewards, true); + UIManager.RefreshCurrencyUI(); + UIManager.OpenCharNoticeUI(bundle.CharInfo, bundle.ItemAutoExchangeInfo, bundle.ItemAutoUpgradeInfo); + } + catch (Exception error) { Log?.LogWarning("Attendance commerce rewards could not be applied: " + error.Message); } + } + + private static void LoginPassRewards(byte[] __0, int __2, bool __result) + { + if (__2 == 0 && __result) ApplyRewardEnvelope(AttendanceResponse.Parser.ParseFrom(__0)); + } + + private static void EventRewards(byte[] __0, int __2, bool __result) + { + if (__2 == 0 && __result) ApplyRewardEnvelope(EventRewardResponse.Parser.ParseFrom(__0)); + } + + private static void ApplyRewardEnvelope(IMessage message) => AttendanceRewards(message, 0, true); + + private static void RefreshAttendanceIndependent() + { + // Own the receiver rather than setting EventPacket's single static + // onAttendanceRespSuccess slot, which may belong to an in-flight UI. + string origin = catalogOrigin; + object user = CommonPacket.UserInfo; + SG.Net.Send(new AttendanceRequest { Seq = BDNetwork.Sequence.ReqSeq }, (data, packetCode, error) => + { + if (error != 0 || origin != catalogOrigin || !ReferenceEquals(user, CommonPacket.UserInfo)) return false; + AttendanceResponse response = AttendanceResponse.Parser.ParseFrom(data); + CashShopPacket.RefreshAllAttendanceInfo(response.AttendanceInfo); + CashShopPacket.RefreshSubscribeAttendanceInfo(response.SubscribeAttendanceInfo); + CashShopPacket.RefreshAttendancePackageInfo(response.AttendancePackageInfo); + CashShopPacket.RefreshLoginPassPackageInfo(response.LoginPassPackageInfo); + ApplyRewardEnvelope(response); + return true; + }, false, null, false, null); + } + + private static string ExtractSku(string label) + { + int start = label.IndexOf(Marker, StringComparison.Ordinal) + Marker.Length; + int end = label.IndexOf('>', start); + return end < 0 ? null : Uri.UnescapeDataString(label.Substring(start, end - start)); + } + + private static void RefreshPrices() + { + foreach (var view in priceViews.ToArray()) + { + if (view.Key == null) { priceViews.Remove(view.Key); continue; } + try { view.Value(); } catch (Exception error) { Log?.LogWarning("Commerce UI refresh: " + error.Message); } + } + } + + private void OnDestroy() + { + // Unity may remove the BepInEx component during startup. Static hooks and + // the separate persistent host must survive that scene cleanup. + Log?.LogInfo("Cash shop plugin component destroyed: billing hooks retained on persistent runtime"); + } +} diff --git a/plugins/CashShop/README.md b/plugins/CashShop/README.md new file mode 100644 index 0000000..22b38fb --- /dev/null +++ b/plugins/CashShop/README.md @@ -0,0 +1,17 @@ +# BD2 Cash Shop + +把当前游戏的现金支付入口替换为本服的游戏内货币购买。插件依赖 `BD2LocalIdentity.dll`,目标游戏版本在项目中明确声明为 `2.35.10`,使用共享 `BD2.GameNames.dll` 和 GameSdk 构建。 + +普通现金商品固定使用付费钻石。充值商品的获取方式由服务端 `game.json` 的 `purchases.diamond_recharge` 设置:默认免费,也可使用金币、普通钻石,或者禁止充值。完整配置和换算说明见 [GAME_CONFIGURATION.md](../../GAME_CONFIGURATION.md#现金商品与付费钻石充值)。 + +客户端从当前服务器 `/client/commerce` 获取完整价格目录。配置未加载、版本不匹配或禁止购买时,购买入口会显示游戏提示框,不会调用外部支付 SDK。收费商品使用游戏原有货币图标与数量;免费充值显示 `Free`。 + +补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款与发奖;客户端发送的价格仅用于检测过期目录。 + +月卡、登录通行礼包和付费签到的领取状态在服务器保存,`AttendanceResponse` 与 `EventRewardResponse` 的本服扩展字段同步每日奖励并用回执防止重复应用。现金通行证激活必须消费购买权益。主线通关礼包按服务器主线进度领取;恶魔城奖励需要对应玩法提供真实完成记录,目前占位玩法不能领取通关奖励。 + +```powershell +dotnet build plugins/CashShop/CashShop.csproj -c Release +``` + +客户端开发入口和发布包会同时构建、安装此插件。手动部署时使用 `bin/Release/netstandard2.1/BD2CashShop.dll`,与 LocalIdentity 及共享运行时一起放入本服游戏的 `BepInEx/plugins`。原版客户端与官方抓包环境不要安装这个插件。 diff --git a/plugins/Generate-VersionSource.ps1 b/plugins/Generate-VersionSource.ps1 index 5316113..aaaf6e4 100644 --- a/plugins/Generate-VersionSource.ps1 +++ b/plugins/Generate-VersionSource.ps1 @@ -2,7 +2,7 @@ param( [Parameter(Mandatory)] [string]$Config, [Parameter(Mandatory)] [string]$Output, - [Parameter(Mandatory)] [ValidateSet('local_identity', 'capture_environment', 'login_ui')] [string]$Plugin + [Parameter(Mandatory)] [ValidateSet('local_identity', 'capture_environment', 'login_ui', 'cash_shop')] [string]$Plugin ) $ErrorActionPreference = 'Stop' diff --git a/plugins/LocalIdentity/LocalAccountPolicy.cs b/plugins/LocalIdentity/LocalAccountPolicy.cs index 15c40aa..688ef05 100644 --- a/plugins/LocalIdentity/LocalAccountPolicy.cs +++ b/plugins/LocalIdentity/LocalAccountPolicy.cs @@ -5,7 +5,6 @@ using System.Reflection; using BepInEx.Logging; using HarmonyLib; using UnityEngine; -using System.Linq; namespace Bd2LocalIdentity; @@ -81,83 +80,4 @@ internal static class LocalAccountPolicy }; } - internal static void InstallLocalPurchaseBypass(Harmony harmony) - { - Type platformRuler = typeof(gamfs.Platform.PlatformRuler); - MethodInfo getProducts = platformRuler? - .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .SingleOrDefault(method => - method.IsGameMethod(nameof(gamfs.Platform.PlatformRuler.GetProductAsync)) && - method.GetParameters().Length == 6 && - method.GetParameters()[0].ParameterType == typeof(string[]) && - method.GetParameters()[3].ParameterType == typeof(Action) && - method.GetParameters()[4].ParameterType == typeof(Action) && - method.GetParameters()[5].ParameterType == typeof(bool)); - if (getProducts == null) - { - throw new MissingMethodException("PlatformRuler.GetProductAsync was not found"); - } - harmony.Patch( - getProducts, - prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(GetProductsPrefix))); - - Type platformManager = typeof(gamfs.Platform.PlatformManager); - MethodInfo purchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public) - .SingleOrDefault(method => method.IsGameMethod(nameof(gamfs.Platform.PlatformManager.Purchase)) && - method.GetParameters().Length == 3 && - method.GetParameters()[0].ParameterType == typeof(string) && - method.GetParameters()[2].ParameterType == typeof(Action)); - MethodInfo finishPurchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public) - .SingleOrDefault(method => method.IsGameMethod(nameof(gamfs.Platform.PlatformManager.FinishPurchase)) && - method.GetParameters().Length == 2 && - method.GetParameters()[0].ParameterType == typeof(string) && - method.GetParameters()[1].ParameterType == typeof(long)); - if (purchase == null || finishPurchase == null) - throw new MissingMethodException("PlatformManager purchase methods were not found"); - harmony.Patch(purchase, prefix: new HarmonyMethod( - typeof(LocalAccountPolicy), nameof(LocalPurchasePrefix))); - harmony.Patch(finishPurchase, prefix: new HarmonyMethod( - typeof(LocalAccountPolicy), nameof(FinishLocalPurchasePrefix))); - Log?.LogInfo("Local purchase price lookup disabled"); - Log?.LogInfo("Infinite reroll confirmation is local/free; all other paid purchases are blocked"); - } - - private static bool GetProductsPrefix(Action __3) - { - // A private server has no Neon/GPG commerce identity. Treat price - // prefetch as complete so startup can continue without contacting the - // production payment API. No purchase result or currency is forged. - __3?.Invoke(); - return false; - } - - private static bool LocalPurchasePrefix(string __0, object __1, Action __2) - { - // Product 9100033 is the infinite-reroll confirmation. The - // local server grants the last preview through CashShopBuy without - // contacting Neon/GPG. No other real-money product is authorized. - if (__0 == "brd2_limited_pack_660" || __0 == "brd2_limited_pack_660_ios") - { - object result = new gamfs.Platform.PurchaseData(__0, 1L, - "bd2-local-free-infinite", "bd2-local-free-receipt"); - Log?.LogInfo("Approved local/free infinite-reroll confirmation"); - (__1 as Delegate)?.DynamicInvoke(result); - return false; - } - Log?.LogWarning("Blocked unsupported paid product: " + (__0 ?? "")); - __2?.Invoke(); - return false; - } - - private static bool FinishLocalPurchasePrefix(string __0) - { - if (__0 == "bd2-local-free-infinite") - { - Log?.LogInfo("Finished local/free infinite-reroll confirmation"); - return false; - } - return true; - } - - } diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs index e66fb41..e3b2cc4 100644 --- a/plugins/LocalIdentity/Plugin.cs +++ b/plugins/LocalIdentity/Plugin.cs @@ -61,7 +61,6 @@ public sealed class Plugin : BaseUnityPlugin TryInstall("performance overlay suppression", () => ClientPresentation.InstallPerformanceOverlaySuppression(harmony)); TryInstall("maintenance timeout guard", () => ClientDiagnostics.InstallMaintenanceTimeoutGuard(harmony, introUI)); TryInstall("age-gate persistence", () => LocalAccountPolicy.InstallAgeGatePersistence(harmony)); - TryInstall("local purchase bypass", () => LocalAccountPolicy.InstallLocalPurchaseBypass(harmony)); TryInstall("database diagnostics", () => ClientDiagnostics.InstallDatabaseDiagnostics(harmony)); Logger.LogInfo("Local identity active: AppManager.IsPlatformLogin => false"); } diff --git a/plugins/LoginUI/LoginController.cs b/plugins/LoginUI/LoginController.cs index d2ed93b..4e0510e 100644 --- a/plugins/LoginUI/LoginController.cs +++ b/plugins/LoginUI/LoginController.cs @@ -45,11 +45,12 @@ internal static class LoginController internal static void ClearPCLocalDataPostfix() { + Log?.LogInfo("event='Saved login clearing' reason=pc-local-data-cleared" + SessionDiagnostics.Context()); AccessTokens.Clear(); EstablishedGameSession = false; RuntimeProbeFailures = 0; ServerInstanceID = null; - DeleteCurrentRefresh(); + DeleteCurrentRefresh("pc-local-data-cleared"); } internal static bool SendMaintenancePrefix(object __instance, bool __0) @@ -101,7 +102,7 @@ internal static class LoginController } catch (Exception ex) { - Log?.LogError("Could not request the server authentication policy: " + ex); + Log?.LogError("Authentication policy request failed: exception=" + ex.GetType().Name); return false; } } @@ -121,7 +122,7 @@ internal static class LoginController } if (!request.Success) { - Log?.LogWarning("Authentication policy request failed: " + request.Error); + Log?.LogWarning("Authentication policy request failed: http=" + request.StatusCode); bool transient = request.StatusCode == 0 || request.StatusCode == 429 || request.StatusCode >= 500; if (transient && attempt < 2) { @@ -199,23 +200,35 @@ internal static class LoginController return; } FinishRecovery(false, "no usable credential is available"); - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, "recovery-secure-store-unsupported"); return; } - if (ServerLoginPreferences.IsAutoLogin(NormalizedServerOrigin()) && - ServerLoginPreferences.UseAutoLoginPC(NormalizedServerOrigin()) && - CanAttemptAutomaticLogin()) + string origin = NormalizedServerOrigin(); + bool automatic = ServerLoginPreferences.IsAutoLogin(origin); + bool checkbox = ServerLoginPreferences.UseAutoLoginPC(origin); + bool supported = RefreshCredentials.IsSupported; + bool automaticKeyPresent = PlayerPrefs.HasKey(ServerLoginPreferences.Key("BD2LoginV1_IsAutoLogin_", origin)); + bool checkboxKeyPresent = PlayerPrefs.HasKey(ServerLoginPreferences.Key("BD2LoginV1_StandaloneAutoLogin_", origin)); + bool contains = CanAttemptAutomaticLogin(out string inspectionReason); + Log?.LogInfo("event='Automatic login decision' origin=" + origin + + " autoLogin=" + automatic + " useAutoLoginPC=" + checkbox + + " autoLoginKeyPresent=" + automaticKeyPresent + " useAutoLoginPCKeyPresent=" + checkboxKeyPresent + + " secureStoreSupported=" + supported + " credentialPresent=" + contains + + " inspection=" + inspectionReason); + if (automatic && checkbox && contains) { LoginInProgress = true; StartIntroCoroutine(introUI, RefreshSession(introUI)); } else { - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, !supported ? "secure-store-unsupported" : + !automatic ? (automaticKeyPresent ? "auto-login-disabled" : "auto-login-setting-missing") : + !checkbox ? (checkboxKeyPresent ? "pc-auto-login-disabled" : "pc-auto-login-setting-missing") : inspectionReason); } } - private static void ShowLoginPanel(object introUI) + private static void ShowLoginPanel(object introUI, string reason) { // The official manual-login branch cancels the 10-second startup watchdog. // Browser authentication waits for the user and must not retain that timer. @@ -226,7 +239,7 @@ internal static class LoginController ConfigureLoginPanel(introUI); Type stateType = SetIntroState.GetParameters()[0].ParameterType; SetIntroState.Invoke(introUI, new[] { Enum.ToObject(stateType, 1) }); - Log?.LogInfo("Waiting for server-authorized third-party authentication"); + Log?.LogInfo("event='Login required' reason=" + reason + " origin=" + NormalizedServerOrigin()); } internal static void OpenLogin(object introUI, string provider) @@ -255,7 +268,7 @@ internal static class LoginController } catch (Exception ex) { - Log?.LogError("Could not start " + provider + " authentication: " + ex.Message); + Log?.LogError("Could not start " + provider + " authentication: exception=" + ex.GetType().Name); } } @@ -273,7 +286,7 @@ internal static class LoginController if (!request.Success) { LoginInProgress = false; - Log?.LogError("Could not create login transaction: " + request.Error); + Log?.LogError("Could not create login transaction: http=" + request.StatusCode); yield break; } DeviceStart start; @@ -319,7 +332,7 @@ internal static class LoginController if (!request.Success) { LoginInProgress = false; - Log?.LogError("Login transaction failed: " + request.Error); + Log?.LogError("Login transaction failed: http=" + request.StatusCode); yield break; } TokenResult result; @@ -387,7 +400,7 @@ internal static class LoginController } else { - DeleteCurrentRefresh(); + DeleteCurrentRefresh("interactive-auto-login-disabled"); result.refresh_token = null; } AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in); @@ -399,10 +412,10 @@ internal static class LoginController { result.access_token = null; result.refresh_token = null; - Log?.LogError("Could not finish interactive login: " + ex.Message); + Log?.LogError("event='Interactive login failed' reason=completion-failed exception=" + ex.GetType().Name); AccessTokens.Clear(); LoginInProgress = false; - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, "interactive-completion-failed"); } }; try @@ -429,6 +442,7 @@ internal static class LoginController yield break; } InvalidDataException invalid = null; + string invalidReason = "saved-credential-invalid"; Exception transient = null; try { @@ -437,10 +451,12 @@ internal static class LoginController catch (InvalidDataException ex) { invalid = ex; + invalidReason = ex.Data["login_reason"] as string ?? invalidReason; } catch (FileNotFoundException ex) { invalid = new InvalidDataException("saved automatic-login credential was not found", ex); + invalidReason = "credential-missing"; } catch (Exception ex) { @@ -448,22 +464,22 @@ internal static class LoginController } if (invalid != null) { - Log?.LogError("Saved automatic login is invalid: " + invalid.Message); - ClearSavedLogin(); + Log?.LogError("event='Refresh failed' reason=" + invalidReason + " exception=" + invalid.GetType().Name); + ClearSavedLogin(invalidReason); if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { FinishRecovery(false, "saved automatic-login credential is invalid", generation); } - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, invalidReason); yield break; } if (transient != null) { - Log?.LogWarning("Secure automatic-login storage is temporarily unavailable: " + transient.Message); + Log?.LogWarning("event='Refresh failed' reason=secure-store-unavailable exception=" + transient.GetType().Name); if (Volatile.Read(ref SessionRecoveryInProgress) == 0) { LoginInProgress = false; - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, "secure-store-unavailable"); yield break; } yield return new WaitForSecondsRealtime(2f); @@ -489,25 +505,25 @@ internal static class LoginController bool credentialRejected = (request.StatusCode == 401 || request.StatusCode == 409) && request.RefreshInvalid; if (credentialRejected) { - Log?.LogWarning("Automatic-login credential rejected: HTTP=" + request.StatusCode + + Log?.LogWarning("event='Refresh failed' reason=credential-rejected http=" + request.StatusCode + ", refreshInvalid=" + request.RefreshInvalid + ", classification=invalid-credential"); refreshToken = null; attemptID = null; - ClearSavedLogin(); + ClearSavedLogin("refresh-credential-rejected"); if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { FinishRecovery(false, "saved automatic-login credential was rejected", generation); } - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, "refresh-credential-rejected"); yield break; } if (!request.Success) { - Log?.LogWarning("Automatic login temporarily unavailable; the same refresh attempt will be retried: " + request.Error); + Log?.LogWarning("event='Refresh failed' reason=http-failure http=" + request.StatusCode); if (Volatile.Read(ref SessionRecoveryInProgress) == 0) { LoginInProgress = false; - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, "refresh-http-failure"); yield break; } } @@ -521,7 +537,7 @@ internal static class LoginController } catch (Exception ex) { - Log?.LogWarning("Automatic login returned an unreadable response; the same refresh attempt will be retried: " + ex.GetType().Name); + Log?.LogWarning("event='Refresh failed' reason=unreadable-response http=" + request.StatusCode + " exception=" + ex.GetType().Name); } if (ValidRefreshResult(result) && ProviderEnabled(result.provider)) { @@ -530,6 +546,7 @@ internal static class LoginController StoreRefresh(result); if (result.access_expires_in <= 30) { + Log?.LogInfo("event='Refresh retry' reason=access-token-near-expiry http=" + request.StatusCode); result.access_token = null; saved = PrepareRefreshAttempt(); refreshToken = saved.pending_refresh_token; @@ -549,11 +566,11 @@ internal static class LoginController { result.access_token = null; result.refresh_token = null; - Log?.LogWarning("Could not persist the rotated automatic-login credential; the committed attempt will be retrieved again: " + ex.Message); + Log?.LogWarning("event='Refresh failed' reason=rotation-storage-failed http=" + request.StatusCode + " exception=" + ex.GetType().Name); if (Volatile.Read(ref SessionRecoveryInProgress) == 0) { LoginInProgress = false; - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, "refresh-rotation-storage-failed"); yield break; } } @@ -563,11 +580,11 @@ internal static class LoginController yield break; } } - Log?.LogWarning("Automatic login returned incomplete credentials; the same refresh attempt will be retried"); + Log?.LogWarning("event='Refresh failed' reason=incomplete-credentials http=" + request.StatusCode); if (Volatile.Read(ref SessionRecoveryInProgress) == 0) { LoginInProgress = false; - ShowLoginPanel(introUI); + ShowLoginPanel(introUI, "refresh-incomplete-credentials"); yield break; } } @@ -601,19 +618,23 @@ internal static class LoginController owner.StartCoroutine(routine ?? throw new ArgumentNullException(nameof(routine))); } - private static bool CanAttemptAutomaticLogin() + private static bool CanAttemptAutomaticLogin(out string reason) { if (!RefreshCredentials.IsSupported) { + reason = "secure-store-unsupported"; return false; } try { - return RefreshCredentials.Contains(NormalizedServerOrigin()); + bool contains = RefreshCredentials.Contains(NormalizedServerOrigin()); + reason = contains ? "credential-present" : "credential-missing"; + return contains; } catch (Exception ex) { - Log?.LogWarning("Could not inspect the secure automatic-login credential: " + ex.Message); + reason = "credential-inspection-failed"; + Log?.LogWarning("event='Credential inspection failed' exception=" + ex.GetType().Name); return false; } } @@ -630,6 +651,7 @@ internal static class LoginController expires_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + result.refresh_expires_in }; RefreshCredentials.Save(origin, credential); + Log?.LogInfo("event='Credential stored' reason=refresh-rotation origin=" + origin); credential.refresh_token = null; result.refresh_token = null; } @@ -642,11 +664,16 @@ internal static class LoginController !ProviderEnabled(credential.provider) || string.IsNullOrEmpty(credential.refresh_token) || credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds()) { + string reason = credential == null ? "credential-missing" : + credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds() ? "saved-credential-expired" : "saved-credential-invalid"; + Log?.LogWarning("event='Refresh failed' reason=" + reason); if (credential != null) { credential.refresh_token = null; } - throw new InvalidDataException("saved automatic-login credential is invalid, expired, or belongs to another server"); + var invalid = new InvalidDataException("saved automatic-login credential is invalid, expired, or belongs to another server"); + invalid.Data["login_reason"] = reason; + throw invalid; } return credential; } @@ -663,6 +690,7 @@ internal static class LoginController credential.pending_attempt_id = System.Guid.NewGuid().ToString("N"); credential.pending_refresh_token = credential.refresh_token; RefreshCredentials.Save(origin, credential); + Log?.LogInfo("event='Credential stored' reason=refresh-attempt-prepared origin=" + origin); } return credential; } @@ -685,27 +713,31 @@ internal static class LoginController return Encoding.UTF8.GetBytes("{\"refresh_token\":\"" + token + "\",\"attempt_id\":\"" + attemptID + "\"}"); } - private static void ClearSavedLogin() + private static void ClearSavedLogin(string reason) { + Log?.LogInfo("event='Saved login clearing' reason=" + reason + SessionDiagnostics.Context()); AccessTokens.Clear(); ServerLoginPreferences.SetAutoLogin(NormalizedServerOrigin(), false); ServerLoginPreferences.SetUseAutoLoginPC(NormalizedServerOrigin(), false); - DeleteCurrentRefresh(); + DeleteCurrentRefresh(reason); } - private static void DeleteCurrentRefresh() + private static void DeleteCurrentRefresh(string reason) { if (ServerRoot == null || RefreshCredentials == null || !RefreshCredentials.IsSupported) { + Log?.LogInfo("event='Credential deletion skipped' reason=" + reason + " originAvailable=" + (ServerRoot != null) + + " secureStoreSupported=" + (RefreshCredentials != null && RefreshCredentials.IsSupported)); return; } try { RefreshCredentials.Delete(NormalizedServerOrigin()); + Log?.LogInfo("event='Credential deleted' reason=" + reason + " origin=" + NormalizedServerOrigin()); } catch (Exception ex) { - Log?.LogWarning("Could not delete the secure automatic-login credential: " + ex.Message); + Log?.LogWarning("event='Credential deletion failed' reason=" + reason + " exception=" + ex.GetType().Name); } } diff --git a/plugins/LoginUI/Plugin.cs b/plugins/LoginUI/Plugin.cs index daf6188..b44f8cb 100644 --- a/plugins/LoginUI/Plugin.cs +++ b/plugins/LoginUI/Plugin.cs @@ -23,6 +23,7 @@ public sealed class Plugin : BaseUnityPlugin private void OnApplicationQuit() { + ApplicationQuitting = true; DisposeGameRelay(); PlatformControlHttp.Shutdown(); } @@ -91,6 +92,7 @@ public sealed class Plugin : BaseUnityPlugin } Harmony harmony = new Harmony(Guid); + SessionDiagnostics.Install(harmony); harmony.Patch(awake, postfix: new HarmonyMethod(typeof(SessionRecovery), nameof(IntroAwakePostfix))); HarmonyMethod maintenancePrefix = new HarmonyMethod(typeof(LoginController), nameof(SendMaintenancePrefix)); maintenancePrefix.after = new[] { "bd2.localidentity" }; diff --git a/plugins/LoginUI/SecureGameRelay.cs b/plugins/LoginUI/SecureGameRelay.cs index 141f896..7fabf71 100644 --- a/plugins/LoginUI/SecureGameRelay.cs +++ b/plugins/LoginUI/SecureGameRelay.cs @@ -142,9 +142,9 @@ internal sealed class SecureGameRelay : IDisposable // The native hard deadline leaves margin inside Unity's 30s timeout. response = await PlatformControlHttp.Send(remote, start[0], body, null, 25, lifetime.Token, lifetime.Token, 64 * 1024 * 1024, headers).ConfigureAwait(false); - if (maintenance || response.StatusCode == 0) + if (maintenance || response.StatusCode == 0 || response.StatusCode >= 400) log?.LogInfo("Game relay upstream result: request_id=" + response.RequestId + " status=" + response.StatusCode + - " failure=" + (response.FailureKind ?? "none")); + " path=" + remote.AbsolutePath + " failure=" + (response.FailureKind ?? "none")); if (response.StatusCode == 0 || response.Data == null) { // Return a complete, recognizable local failure. The game sends all diff --git a/plugins/LoginUI/SessionDiagnostics.cs b/plugins/LoginUI/SessionDiagnostics.cs new file mode 100644 index 0000000..46a6379 --- /dev/null +++ b/plugins/LoginUI/SessionDiagnostics.cs @@ -0,0 +1,144 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using BD2.GameNames; +using BDNetwork; +using HarmonyLib; +using UnityEngine; + +using static Bd2LoginUI.LoginRuntime; +using static Bd2LoginUI.SessionRecovery; + +namespace Bd2LoginUI; + +// Diagnostics never log response bodies, cookies, credentials or URL queries. +internal static class SessionDiagnostics +{ + private const BindingFlags All = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + private static string LastFailure; + private static float LastFailureTime; + private static string LastResponseError; + private static float LastResponseErrorTime; + + internal static void Install(Harmony harmony) + { + // Keep diagnostic binding failures separate from functional login hooks. + TryPatch(harmony, typeof(NetworkManager), "ResponseCheck", nameof(ResponsePrefix), 4); + TryPatch(harmony, typeof(NetworkManager), "ServerNetworkError", nameof(ServerErrorPrefix), 2); + TryPatch(harmony, typeof(NetworkManager), "OpenPacketErrorPopup", nameof(PacketPopupPrefix), 3); + TryPatch(harmony, typeof(AppManager), "AppReStart", nameof(RestartPrefix), 0); + } + + private static void TryPatch(Harmony harmony, Type type, string name, string callback, int parameters) + { + try + { + MethodInfo target = type.GetGameMethod(name, All); + if (target == null || target.GetParameters().Length != parameters) throw new MissingMethodException(); + harmony.Patch(target, prefix: new HarmonyMethod(typeof(SessionDiagnostics), callback)); + } + catch (Exception error) + { + Log?.LogWarning("Session diagnostic binding unavailable: target=" + type.Name + "." + name + " exception=" + error.GetType().Name); + } + } + + internal static void Record(string failure) + { + try + { + LastFailure = failure; + LastFailureTime = Time.realtimeSinceStartup; + Log?.LogWarning("Session diagnostic: " + failure + Context(false)); + } + catch { /* A diagnostic must never interrupt the client's error handling. */ } + } + + internal static string Context(bool includeFailure = true) + { + try + { + string recent = LastFailure == null ? "none" : LastFailure; + string age = LastFailure == null ? "none" : Math.Max(0, Time.realtimeSinceStartup - LastFailureTime).ToString("F1", CultureInfo.InvariantCulture); + return " origin=" + (ServerRoot?.GetLeftPart(UriPartial.Authority) ?? "unset") + + " established=" + EstablishedGameSession + " recovering=" + (SessionRecoveryInProgress != 0) + + " generation=" + RecoveryGeneration + + (LastResponseError == null ? "" : " recent_response_error_age_s=" + Math.Max(0, Time.realtimeSinceStartup - LastResponseErrorTime).ToString("F1", CultureInfo.InvariantCulture) + " recent_response_error={" + LastResponseError + "}") + + (includeFailure ? " recent_failure_age_s=" + age + " recent_failure={" + recent + "}" : ""); + } + catch { return " diagnostic_context=unavailable"; } + } + + private static object Value(object target, string name) + { + if (target == null) return null; + Type type = target.GetType(); + return type.GetGameField(name, All)?.GetValue(target) ?? type.GetGameProperty(name, All)?.GetValue(target, null); + } + + private static void ResponsePrefix(string __0, object __1, object __2) + { + try + { + object code = Value(__2, "errorType"); + if (code == null || Convert.ToInt32(code) == 0) return; + LastResponseError = "path=" + Path(__0) + " sequence=" + Value(__1, "Sequence") + + " packet_code=" + Value(__2, "packetCode") + " error_code=" + code + " reason=" + ErrorReason(code); + LastResponseErrorTime = Time.realtimeSinceStartup; + Record("event=game-response-error " + LastResponseError); + } + catch (Exception error) { Log?.LogWarning("Session response diagnostic unavailable: exception=" + error.GetType().Name); } + } + + internal static string Code(object value) + { + try { return Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture); } + catch { return "unknown"; } + } + + internal static string Path(string value) => (value ?? "unknown").Split('?', '#')[0].Replace('\r', ' ').Replace('\n', ' '); + + private static void ServerErrorPrefix(object __0) => Record("event=server-network-error error_code=" + Code(__0) + " reason=" + ErrorReason(__0)); + + private static void PacketPopupPrefix(int __0, int __1, bool __2) => Record( + "event=packet-error-popup packet_code=" + __0 + " error_code=" + __1 + + " text_id=" + (100000L + __0 * 100L + __1) + " restart_on_confirm=" + __2); + + internal static string ErrorReason(object error) + { + try + { + if (error == null) return "unknown"; + int code = Convert.ToInt32(error); + // Resolve enum labels through the shared name table, including renamed fields. + foreach (FieldInfo field in typeof(EErrorType).GetFields(BindingFlags.Public | BindingFlags.Static)) + if (Convert.ToInt32(field.GetRawConstantValue()) == code) + { + foreach (string name in new[] { "SESSION_DST", "DB_CLOSE", "QUERY_ERROR", "WAIT_TIMEOUT", "REQUEST_FAILED", "NOT_PINGCHECK_SESSION_DST", "WAIT_PROCESS_REDIRECT", "RPE_NEON_API_ACCESS_TOKEN_EXPIRE", "PRE_NETWORK_ERROR_NEON_API", "CLINET_NET_TIMEOUT", "CLIENT_LOGIC_ERROR", "RPE_UPDATE_MAJOR_CLIENT_DOWN", "RPE_CDN_ASSET_BUNDLE_UPDATE", "RPE_PURCHASED_DISABLED", "RPE_BLOCK_IP", "PRE_BLOCK_NETWORK_TYPE", "RPE_REQUEST_MISSING_PARAM", "RPE_XML_DATA_NOT_FOUND", "RPE_PROTOBUF_ENCODING_ERROR" }) + if (field.Name == Game.MemberName(typeof(EErrorType), name, GameMemberKind.Field)) return name; + } + return "unclassified"; + } + catch { return "unclassified"; } + } + + private static void RestartPrefix() + { + try + { + // Method identities only; stack arguments and exception text may contain credentials. + var callers = new System.Text.StringBuilder(); + foreach (StackFrame frame in new StackTrace(false).GetFrames() ?? Array.Empty()) + { + MethodBase method = frame.GetMethod(); + if (method?.DeclaringType == null || method.DeclaringType == typeof(SessionDiagnostics)) continue; + if (callers.Length > 0) callers.Append(" <- "); + callers.Append(method.DeclaringType.FullName).Append('.').Append(method.Name); + if (callers.Length > 1200) break; + } + Log?.LogWarning("Client restart requested:" + Context() + " callers=" + callers); + } + catch { /* Preserve the original restart even if stack inspection fails. */ } + } +} diff --git a/plugins/LoginUI/SessionRecovery.cs b/plugins/LoginUI/SessionRecovery.cs index c155750..88002bb 100644 --- a/plugins/LoginUI/SessionRecovery.cs +++ b/plugins/LoginUI/SessionRecovery.cs @@ -20,6 +20,7 @@ namespace Bd2LoginUI; internal static class SessionRecovery { internal static int SessionRecoveryInProgress; + internal static bool ApplicationQuitting; private static RecoveryHost Owner; private static SecureGameRelay GameRelay; private static readonly List> GameRequests = new List>(); @@ -113,7 +114,12 @@ internal static class SessionRecovery { Lifetime.Cancel(); Lifetime.Dispose(); - Log?.LogWarning("Login recovery coroutine host was destroyed"); + if (ApplicationQuitting) + Log?.LogInfo("Login recovery host stopped: reason=application-quit"); + else if (!EstablishedGameSession && Volatile.Read(ref SessionRecoveryInProgress) == 0) + Log?.LogInfo("Login recovery host destroyed before game login; it will be recreated on IntroUI initialization"); + else + Log?.LogWarning("Login recovery host unexpectedly destroyed:" + SessionDiagnostics.Context()); } } @@ -155,6 +161,17 @@ internal static class SessionRecovery { return; } + try + { + if (request.result != UnityWebRequest.Result.Success || request.responseCode >= 400) + SessionDiagnostics.Record("event=game-http-failure path=" + requestUri.AbsolutePath + + " http_status=" + request.responseCode + " result=" + request.result + + " access_expired=" + (request.GetResponseHeader("X-BD2-Access-Expired") == "1") + + " session_expired=" + (request.GetResponseHeader("X-BD2-Session-Expired") == "1") + + " reconnect=" + (request.GetResponseHeader("X-BD2-Reconnect") == "1") + + " transport_failure=" + (request.GetResponseHeader("X-BD2-Transport-Failure") == "1")); + } + catch { /* Keep actual HTTP recovery independent of diagnostics. */ } if (request.result == UnityWebRequest.Result.Success && request.responseCode >= 200 && request.responseCode < 300) LastGameSuccess = Time.realtimeSinceStartup; else if (IsGameTransportFailure(request)) @@ -213,12 +230,12 @@ internal static class SessionRecovery EnsureRecoveryHost(); if (Interlocked.CompareExchange(ref SessionRecoveryInProgress, 1, 0) == 0) { - Log?.LogWarning("Starting automatic game-session recovery: " + reason); + Log?.LogWarning("Starting automatic game-session recovery: reason=" + reason + SessionDiagnostics.Context()); ShowRecoveryOverlay("正在重新连接服务器……\nReconnecting to server…"); } else { - Log?.LogWarning("Restarting automatic game-session recovery: " + reason); + Log?.LogWarning("Restarting automatic game-session recovery: reason=" + reason + SessionDiagnostics.Context()); } ScheduleRecoveryRestart(); return true; @@ -230,10 +247,23 @@ internal static class SessionRecovery } } - internal static bool ExponentialBackoffPrefix(object __0) + internal static bool ExponentialBackoffPrefix(object __0, object __1) { try { + try + { + if (__0 != null && IsConfiguredServerFailure(__0)) + { + const BindingFlags requestFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + object failed = __0.GetType().GetGameProperty("PacketData", requestFlags)?.GetValue(__0, null); + string path = failed?.GetType().GetGameProperty("SendPath", requestFlags)?.GetValue(failed, null) as string; + object observedRetries = failed?.GetType().GetGameProperty("RetryCount", requestFlags)?.GetValue(failed, null); + SessionDiagnostics.Record("event=game-request-backoff path=" + SessionDiagnostics.Path(path) + " retry_count=" + observedRetries + + " error_code=" + SessionDiagnostics.Code(__1) + " reason=" + SessionDiagnostics.ErrorReason(__1)); + } + } + catch { /* Diagnostics must not change backoff or recovery decisions. */ } try { const BindingFlags diagnosticFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; @@ -525,8 +555,9 @@ internal static class SessionRecovery internal static bool SuppressNetworkErrorDuringRecovery(object __0, object __1) { - Log?.LogWarning("Client network error: type=" + __0 + ", code=" + __1 + - ", recovering=" + (Volatile.Read(ref SessionRecoveryInProgress) != 0)); + SessionDiagnostics.Record("event=client-network-error error_code=" + SessionDiagnostics.Code(__0) + + " reason=" + SessionDiagnostics.ErrorReason(__0) + " service_code=" + SessionDiagnostics.Code(__1) + + " suppressed=" + (Volatile.Read(ref SessionRecoveryInProgress) != 0)); return Volatile.Read(ref SessionRecoveryInProgress) == 0; } diff --git a/plugins/README.md b/plugins/README.md index 5b1bded..18661bd 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -4,9 +4,10 @@ - `LocalIdentity/`:连接根目录 `versions.json` 所选客户端与本地服务器;构建产物为 `BD2LocalIdentity.dll`。 - `LoginUI/`:把客户端登录面板收敛为 Discord 与 Google 两个本服认证入口;构建产物为 `BD2LoginUI.dll`。 +- `CashShop/`:把现金商品改为服务端核算的付费钻石购买;充值商品遵守 `game.json` 的兑换配置,产物为 `BD2CashShop.dll`。 - `CaptureEnvironment/`:独立原版对照客户端抓包使用;构建产物为 `BD2CaptureEnvironment.dll`。 -`LocalIdentity` 与 `LoginUI` 用于本地服客户端;`CaptureEnvironment` 用于独立原版抓包环境。原版抓包环境不得安装 `BD2LocalIdentity.dll` 或 `BD2LoginUI.dll`。 +`LocalIdentity`、`LoginUI` 与 `CashShop` 用于本地服客户端;`CaptureEnvironment` 用于独立原版抓包环境。原版抓包环境不得安装 `BD2LocalIdentity.dll`、`BD2LoginUI.dll` 或 `BD2CashShop.dll`。 客户端、资源与插件版本都来自根目录 `versions.json`;MSBuild 在中间目录生成 C# 常量,源码不保存第二份版本值。 SDK 和共享运行时已内嵌当前游戏版本的同一份名字表。源码构建只需客户端目录,无需提供官方映射。`.NET 8 SDK` 构建工具;插件为 `netstandard2.1`,运行时为 `netstandard2.0`。构建自动生成可读引用、完整源码和内嵌源码 PDB,编译插件、执行 reobf 并验证真实 DLL 的引用。首次生成耗时数分钟,三个插件和 Debug/Release 共用本机缓存;IDE 转到定义可以查看方法体,`Game Sources` 文件夹提供全量浏览与搜索。IDE 设置见 [完整源码导航](GameSdk/README.md#完整源码导航)。 @@ -18,6 +19,7 @@ SDK 和共享运行时已内嵌当前游戏版本的同一份名字表。源码 ```powershell dotnet build .\plugins\LocalIdentity\LocalIdentity.csproj -c Release -p:GameDir="<本地服客户端目录>" dotnet build .\plugins\LoginUI\LoginUI.csproj -c Release -p:GameDir="<本地服客户端目录>" +dotnet build .\plugins\CashShop\CashShop.csproj -c Release -p:GameDir="<本地服客户端目录>" dotnet build .\plugins\CaptureEnvironment\CaptureEnvironment.csproj -c Release -p:GameDir="<原版客户端目录>" ``` diff --git a/schedules/cash_shop.bd2schedule b/schedules/cash_shop.bd2schedule new file mode 100644 index 0000000000000000000000000000000000000000..3e31f72b8762ff9976ffe8e002cf852f8e59ac56 GIT binary patch literal 3673 zcmb`~yK59d7y$6OTn^DfB!yTh5`&=3@y+bR?3EfpP_ghT1tG>4dZ4YPny5LA#z(Ct zQA8{vXeIc<#(zL8EaC%SjbLRFU*FEnnCzFiQ-<%@yV;$O`|Zr^H?zAN2lUQOTZAZy z^-F~~_HNzgyDxW7tc-gGKEJ6yAK&wJdS>QT`QBLX{n-b%pDq(ZG>#4pHlzlfDu6_) zMOde+QBqZag2)4q=}2W!8Ut3$`}E=dw};)PX4|~_e0K$Y@xQTzSn8w>P%~Q<0Ct%e zo=t+ntr#m?=cX;4zug|YBGj)~;1=<3IM|kdeWj(^P;8@#X>32nB2`y$Ey-{*eRKZa zQVVkJzs~Fr5&s&qzmNER_Sl`qj!z;XlZsr!{~4ddUB&TP!|Wd+elN4%Abz!j@6G>z zQOB}7uE3-dq2Paea$p(9WJPuhv>@_#bOH*?SHPZ57Q$Txi}_%6Ti z5dTqIAE$tCeSm&k-%QNwe<1$jKwm>~{9XG$68{Oa|B3kD+xq=dd&fs(Ze>zyfBTQO zZY$#1+Qp_zTV2O&Y1bb#!u*rgKe407h^v_WQ`UF+KihVEaQ%dZ^DDpaVj#~gZ~hDu z{|B=_Lj0f1{%PX>V)oAv|2MOLmiWT1r!I}=@-yuEjuOAb>^F(u#q6IWemTVNus)n8 zemAo}M*Ir1f5G~zC-MhwA@+ylXNU2*Nc;oL{y6auGW(Z^e~8(?Z2h+{@|PfdVS3#( zrTYn;zwY_}iuDKIFE?a`$#?C$YJGQq`Qie$EVMtXtF)$*TAa?kf93a=R{j)XONiqR F{{V|S_4oh) literal 0 HcmV?d00001 diff --git a/schedules/event_hubs.bd2schedule b/schedules/event_hubs.bd2schedule new file mode 100644 index 0000000000000000000000000000000000000000..6d3b7e112be3612c18002e7f6c619361d283b29f GIT binary patch literal 849 zcma)3JugH-6rJ_GB1@szUOg{&=Gk5ID)AK|C~QMSA`*=iIxM>qg+#F^RN^mCiH=0# zH*Ay&h0b3v_c@o{XC-bXIrpA(=g!QTu?f34KCM(r4Yez^@>0LOxvVeUPp;M~k8e}Q zqj&FWXY=7SowZ`jheN6kZc7<&lkk!Z9(e;*AHa64DA4T2&@3Bz$4%+_C~-)Zw&qI zThg2FeTFm@{^#2n?jU{%^83%BweoEqcSTE+Zx8WjIh>k?8i37 zDxcea)%m7LIr--G;;VPxeqJ3J|9YWNEeyBTog+&J+ZkhH=T4s*b0n7~!MW#%b?TZd zx?WN6&B38+;J$Q)@0NObNnL4CKk*Me->n*jU-BIx9aq$Z*T=cqik$P4L7VUUhfHl( z*2Y!F>iQ`Qz8gg2a7FW4h{oNpO>l>oYtrrWe6@x5yieS-DXw(?-5p&OL94;T+q~Zx zU1&BBf52M<`*u8l^LQR?;{5{gB6oflfV%5B+;wExlm}NOSEyz^zn=g^_XHm3Zus16 z;=Kk5MXqzbF<$HbaGkh&GjeYb_Ywwf$b(J)y-D1+Te8W+E#h{yW#ir^?!JuN2gJRc zl3$yC_K>*yQ{uI8XNlXLk^6|ahbNMsn@zlP#2qXoxIv8-ruv|2v!VXJB(AHoT3xq^ z7a8mZ7Y+5SnQ@4^<2kt#Ik}^q_!mU`_;?}w#^;ta9;1T!T{O5^#SCPZRRCY#GT5>eN5b$6xU|FmBg#P)fWu@9{I@4yU;0=N9Eb3Vpn| h2kv(8NZG;uV7=~s0WQA3Sl2M@cs;X;7r(FX{{k_}Yq|gc literal 0 HcmV?d00001 diff --git a/schedules/gacha.bd2schedule b/schedules/gacha.bd2schedule new file mode 100644 index 0000000000000000000000000000000000000000..857bff2b2df86c7c5e442650b7a9301854b1bb1d GIT binary patch literal 979 zcmZ>AF$#9}U|?WmSjx=6kg&}sJO2IyowIJ;->1zz{X0Y8mF}JWl1nwdd{V9ZWA<8r zfq@|>KRGc+*T}%g%+SEVRM(IlC}yN*Y^rByzz1RiB@8T$j14V~%}fk{0u0b}nBXK7oW6JK)#G*Ec^uO|E>{So+?7{{HiD^zQAK z->VZJfApu9*QUN50M=i>dbytFA@5}&bkl5S$?{ga+seYecv^~3DAKGq3SlrlS|NnB%`VSJS!d8K%46)}J*rw%mir#pKV5lyQ&;?ff7fO9X6XHXT<%EM3s1pu}IN`8}ljMkq6OM{F;h0FhAC8MSAwJhwPB>YCI^mQ^J)P4c zHgHD72F{Auz&Q~cI4@!Y7es8}qKFM#60w2HA~tYE#0IX4*uXUr8@MiF12;r$;HHQT z+!C>Y+afk_N5lqF5gTZU*uY&88}LhS1DV#?lyAO>4YWmUpd(@fT@f4TiP%72#0Kt( z*gzp-0|OBom=bZqw1^Y#i?|yeh%~bvig+!|h`!5L9AgXD$*0GcyA_ zKLf$Y&cP&wH;*Qb8@?z=m;cky7SBSb}P&IqwTc&NQ!eth*hFXz>bz1zp- z;oack>UGq9dp>