Add prototype gameplay loop with character/battlefield assets and server game APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-20 18:50:00 +08:00
co-authored by Cursor
parent 79369e4473
commit 6c64210331
833 changed files with 998079 additions and 1042 deletions
+35
View File
@@ -4,9 +4,30 @@ project(AIServer LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(GAMEPLAY_FILE "${CMAKE_CURRENT_SOURCE_DIR}/../Config/Gameplay/greenfield.json")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${GAMEPLAY_FILE}")
file(READ "${GAMEPLAY_FILE}" GAMEPLAY_JSON)
file(SHA256 "${GAMEPLAY_FILE}" GAMEPLAY_HASH)
string(SUBSTRING "${GAMEPLAY_HASH}" 0 12 GAMEPLAY_HASH_SHORT)
set(GAMEPLAY_VERSION "greenfield-1-${GAMEPLAY_HASH_SHORT}")
# MSVC caps a single string literal at 16380 bytes; emit adjacent chunks that concatenate back
# to the exact file bytes (splitting at a fixed byte offset never rewrites any character).
string(LENGTH "${GAMEPLAY_JSON}" GAMEPLAY_JSON_LENGTH)
set(GAMEPLAY_JSON_CHUNKS "")
set(GAMEPLAY_JSON_OFFSET 0)
while(GAMEPLAY_JSON_OFFSET LESS GAMEPLAY_JSON_LENGTH)
string(SUBSTRING "${GAMEPLAY_JSON}" ${GAMEPLAY_JSON_OFFSET} 8000 GAMEPLAY_JSON_PART)
string(APPEND GAMEPLAY_JSON_CHUNKS "R\"GAMECONFIG(${GAMEPLAY_JSON_PART})GAMECONFIG\"\n")
math(EXPR GAMEPLAY_JSON_OFFSET "${GAMEPLAY_JSON_OFFSET} + 8000")
endwhile()
configure_file(src/game_config.hpp.in generated/game_config_data.hpp @ONLY)
include_directories("${CMAKE_CURRENT_BINARY_DIR}/generated")
add_executable(aiserver
src/main.cpp
src/db.cpp
src/game.cpp
src/game_config.cpp
src/auth.cpp
src/http_ws_server.cpp
src/maintenance.cpp
@@ -41,3 +62,17 @@ endif()
add_custom_command(TARGET aiserver POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory $<TARGET_FILE_DIR:aiserver>/data
)
include(CTest)
if(BUILD_TESTING)
add_executable(game_tests tests/game_tests.cpp src/db.cpp src/game.cpp src/game_config.cpp third_party/sqlite3.c)
target_include_directories(game_tests PRIVATE src third_party)
target_compile_definitions(game_tests PRIVATE SQLITE_OMIT_LOAD_EXTENSION SQLITE_THREADSAFE=1 _CRT_SECURE_NO_WARNINGS)
target_compile_definitions(game_tests PRIVATE GAME_TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures")
if(MSVC)
target_compile_options(game_tests PRIVATE /W3 /utf-8)
elseif(NOT WIN32)
target_link_libraries(game_tests PRIVATE pthread dl)
endif()
add_test(NAME game_transactions COMMAND game_tests)
endif()
+38 -2
View File
@@ -1,6 +1,6 @@
# AIServer
C++ 登录 / WebSocket 网关(SQLite),协议兼容客户端 `HotUpdate.Net`。
C++ 登录、WebSocket 网关与 G1 教学玩法服务(SQLite),协议兼容客户端 `HotUpdate.Net` / `PrototypeApi`。
## 接口
@@ -10,6 +10,8 @@ C++ 登录 / WebSocket 网关(SQLite),协议兼容客户端 `HotUpdate.Net
| POST | `/login` | 同上 |
| GET | `/health` | 健康检查(含 `maintenance`) |
| GET | `/maintenance` | 维护开关状态 |
| GET | `/game/state` | Bearer token 鉴权;钱包、伙伴、阵容、未结算活动 |
| POST | `/game/command` | 唯一请求号 + 修订号;事务扣费/发奖、弹珠轨迹、教学战斗权威结果 |
| WS | `/ws?pid={pid}&token={token}` | 无 token → 401;支持 `hello`/`ping`;维护中下发 `maintenance` 后断开 |
本地默认:`http://127.0.0.1:8080`、`ws://127.0.0.1:8080/ws?...`
@@ -34,6 +36,8 @@ cd D:\UD\AI\AIGame\AIServer\build\Release
## 客户端联调
Boot 使用本机 `127.0.0.1:8080` 时,AIGame 编辑器会在进入 Play Mode 前检查并后台启动已构建的 AIServer(只监听本机)。也可在仓库根执行 `powershell -NoProfile -ExecutionPolicy Bypass -File Tools/start_local_server.ps1`,或用 Unity 菜单 **AIGame/Boot/Server/Start Local AIServer**。此行为仅限编辑器本地配置,生产地址和 Player 不会自动启动服务。
Unity 菜单或 Boot 配置:
- HttpBase: `http://127.0.0.1:8080`
@@ -41,4 +45,36 @@ Unity 菜单或 Boot 配置:
## 数据
SQLite 文件默认 `AIServer/data/aiserver.db`(用户表 + token 表)。
SQLite 文件默认 `AIServer/data/aiserver.db`(用户、token、`game_states`、`game_operations`)。旧数据库启动时追加玩法表,不导入 G0 PlayerPrefs 余额,不删除旧用户。更新可执行文件前需退出本地服务;重新启动后 `/health.gameProtocol` 应为1。玩法协议与错误码见 [Doc/NetProtocol.md](../Doc/NetProtocol.md)。
当前玩法配置源:`Config/Gameplay/greenfield.json`,包含首章前五关、五只伙伴与捕捉池。普通池五种各20%,连续4次重复后下一次保证未拥有伙伴,教学独立计数;集齐后暂停保底、重复转20碎片。CMake构建时嵌入配置并生成内容哈希,服务端每次返回当前配置,新战斗保存整份配置快照。旧schema=1/2在首次请求事务中迁移为3,保留余额、已有伙伴/碎片、关卡进度及未完成活动,旧三伙伴战斗继续用冻结配置结算;不代表完整P0已完成。
营地扩展(schema4阶段):旧schema3自动迁移为空装饰布局/库存,并保留全部既有玩法状态。新增 `camp.place/store/interact`;通过1-5开放三处装饰槽位,初次购买按配置扣金币,已有装饰移动/收回免费;通过1-3可与已拥有伙伴互动,无资源奖励。弹珠树只展示原自然恢复,不增加领取。迁移、交易与重放仍在SQLite同一事务中。入口/UI/协议见 `Doc/NetProtocol.md` 和 `Doc/Design/G0_Implementation.md`。
任务/签到扩展(schema5阶段):schema4保留全部既有状态并初始化每日与累计登录数据。`daily.claim`需携带 `item/day`,`signin.claim`携带 `item`;服务器UTC+8零点重置每日进度/领取标志,事件仅由实际结算推进。七日按累计登录日解锁(非连续日),漏登不重置、未领奖励保留、完成后不循环;所有领取同事务幂等。配置与奖励在 `greenfield.json.daily`,测试与实现记录见上述文档。
背包/补给扩展:最新实时存档schema6只新增三项`shopBought`日限购次数,旧钱包、日常、伙伴、营地和在途活动保留。`shop.buy`需携带`item/day`;免费5弹珠每日1次,100金币换20糖果每日3次(1-1开放),300金币换1捕捉券每日1次(1-3开放)。价格/奖励/限购来自`greenfield.json.shop`,扣款/到账/库存/回执同事务,跨日重试不重复发奖。背包只展示原字段,不维护第二份余额;旧冻结战斗可没有shop配置。完整验证见实现记录。
## 验证
邮件扩展(2026-10-18):当前快照为schema7。旧schema6只新增两封7天有效的初始邮件,附件必须显式领取,不改原钱包;`mail.read/claim/claimAll`支持已读、单封/最多20封批量领取、到期、原回执恢复与事务回滚。配置位于`greenfield.json.mail`,当前无运营投递接口。客户端滚动更新兼容schema6;完整说明见`Doc/NetProtocol.md`。原生4335项断言、99组C++/Unity战斗对照、HTTP并发/重启与独立账号实际邮件点击已通过。
## 宝箱扩展(2026-10-19)
当前快照为schema9。7→8只新增`chests:[0,0,0]`与`chestDailyClaimed`,迁移不发免费箱;8→9把伙伴/关卡扩到10/20,迁移不发伙伴或首通奖。`chest.claimDaily`每日把每日箱放入库存(与商店共用UTC+8日切),`chest.open`按服务端配置扣箱并发固定/自选奖励(含伙伴碎片),同一事务;1-1/1-3/1-5首通各掉一个箱。配置位于`greenfield.json.rewards`。原生4335项断言、HTTP 24并发重试与独立账号实际点击通过;完整说明见`Doc/NetProtocol.md`与`Doc/Design/G0_Implementation.md`。
```powershell
# 构建独立验证服务与测试程序。
cmake -S AIServer -B AIServer/build-g1 -G "Visual Studio 17 2022" -A x64
cmake --build AIServer/build-g1 --config Release
# 原生事务测试使用独立临时数据库,可注入服务端时钟和写入故障。
ctest --test-dir AIServer/build-g1 -C Release --output-on-failure
# HTTP 测试自行启动独立端口/数据库并在结束时关闭,不改开发账户。
python Tools/test_game_http.py
# 导出C++模拟对照,供Unity VerifyProgression.Domain验证。
AIServer/build-g1/Release/game_tests.exe D:/UD/AI/AIGame/Tools/output/progression_parity.json
```
上述验证命令从仓库根执行。Unity 的 `AgentScripts/VerifyPrototype.cs` 保留 G0 规则/Prefab 回归;`VerifyG1.cs` 用独立测试服务 `127.0.0.1:18081` 执行实际界面点击、丢失确认恢复和401续期测试。测试服务必须使用独立数据库,不能指向 `AIServer/data/aiserver.db`。
当前开发验证(2026-10-18):三路线/六槽布阵、五伙伴 T1/T2 技能与首领减速衰减已接入。服务端 `game_tests.exe` 通过 3505 项断言,HTTP 隔离回归通过;Unity `VerifyProgression.Domain` 通过 189 组 C++/Unity 对照(150 胜、39 负)及十三个 Prefab 字体/九宫格检查。技能名称和战术提示配置已修复为 UTF-8 中文。完整 10 伙伴/20 关、正式美术、宝箱/设置、自由营地和 Player/真机验收仍未完成。
+19
View File
@@ -17,6 +17,7 @@ Database::Database(const std::string& path) {
}
exec("PRAGMA journal_mode=WAL;");
exec("PRAGMA foreign_keys=ON;");
sqlite3_busy_timeout(db_, 5000);
const char* schema = R"SQL(
CREATE TABLE IF NOT EXISTS users (
pid INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -33,6 +34,17 @@ CREATE TABLE IF NOT EXISTS tokens (
FOREIGN KEY(pid) REFERENCES users(pid) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tokens_pid ON tokens(pid);
CREATE TABLE IF NOT EXISTS game_states (
pid INTEGER PRIMARY KEY REFERENCES users(pid) ON DELETE CASCADE,
state TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS game_operations (
pid INTEGER NOT NULL REFERENCES users(pid) ON DELETE CASCADE,
request_id TEXT NOT NULL,
request TEXT NOT NULL,
response TEXT NOT NULL,
PRIMARY KEY(pid, request_id)
);
)SQL";
if (!exec(schema)) {
sqlite3_close(db_);
@@ -45,6 +57,7 @@ Database::~Database() {
}
bool Database::exec(const char* sql) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
char* errmsg = nullptr;
if (sqlite3_exec(db_, sql, nullptr, nullptr, &errmsg) != SQLITE_OK) {
err_ = errmsg ? errmsg : "exec failed";
@@ -56,6 +69,7 @@ bool Database::exec(const char* sql) {
std::optional<int64_t> Database::create_user(const std::string& username, const std::string& password_hash,
const std::string& salt) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql =
"INSERT INTO users(username, password_hash, salt, created_at) VALUES(?,?,?,?);";
sqlite3_stmt* st = nullptr;
@@ -78,6 +92,7 @@ std::optional<int64_t> Database::create_user(const std::string& username, const
}
std::optional<User> Database::find_user_by_name(const std::string& username) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql = "SELECT pid, username, password_hash, salt FROM users WHERE username=? COLLATE NOCASE LIMIT 1;";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) {
@@ -99,6 +114,7 @@ std::optional<User> Database::find_user_by_name(const std::string& username) {
}
std::optional<User> Database::find_user_by_pid(int64_t pid) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql = "SELECT pid, username, password_hash, salt FROM users WHERE pid=? LIMIT 1;";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) {
@@ -120,6 +136,7 @@ std::optional<User> Database::find_user_by_pid(int64_t pid) {
}
bool Database::store_token(const std::string& token, int64_t pid, int64_t created_at, int64_t expires_at) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql = "INSERT OR REPLACE INTO tokens(token, pid, created_at, expires_at) VALUES(?,?,?,?);";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) {
@@ -140,6 +157,7 @@ bool Database::store_token(const std::string& token, int64_t pid, int64_t create
}
std::optional<int64_t> Database::pid_for_token(const std::string& token) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql = "SELECT pid, expires_at FROM tokens WHERE token=? LIMIT 1;";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) {
@@ -158,6 +176,7 @@ std::optional<int64_t> Database::pid_for_token(const std::string& token) {
}
void Database::purge_expired_tokens(int64_t now) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM tokens WHERE expires_at < ?;", -1, &st, nullptr) != SQLITE_OK)
return;
+9
View File
@@ -2,11 +2,15 @@
#include <cstdint>
#include <optional>
#include <string>
#include <mutex>
#include <functional>
struct sqlite3;
namespace db {
struct GameReply { int status; std::string body; };
struct User {
int64_t pid = 0;
std::string username;
@@ -35,10 +39,15 @@ class Database {
std::optional<int64_t> pid_for_token(const std::string& token);
void purge_expired_tokens(int64_t now);
// Empty command reads a snapshot. now is injectable for deterministic server tests only.
GameReply game_request(const std::string& token, const std::string& command = {}, int64_t now = 0,
const std::function<uint32_t(uint32_t)>& random = {});
private:
bool exec(const char* sql);
sqlite3* db_ = nullptr;
std::string err_;
std::recursive_mutex mutex_;
};
} // namespace db
+577
View File
@@ -0,0 +1,577 @@
#include "db.hpp"
#include "game_config.hpp"
#include "json.hpp"
#include "sqlite3.h"
#include <algorithm>
#include <cmath>
#include <cctype>
#include <ctime>
#include <stdexcept>
#include <vector>
#include <random>
#include <functional>
namespace {
using J = nlohmann::json;
const J& config() { return gameplay::config(); }
const J& economy() { return config().at("economy"); }
// Statements and transactions are scoped: a disk/constraint failure never publishes half a command.
struct Statement {
sqlite3_stmt* p = nullptr;
Statement(sqlite3* db, const char* sql) {
if (sqlite3_prepare_v2(db, sql, -1, &p, nullptr) != SQLITE_OK) throw std::runtime_error("prepare");
}
~Statement() { sqlite3_finalize(p); }
void number(int i, int64_t n) { sqlite3_bind_int64(p, i, n); }
void text(int i, const std::string& s) { sqlite3_bind_text(p, i, s.c_str(), -1, SQLITE_TRANSIENT); }
int step() {
int rc = sqlite3_step(p);
if (rc != SQLITE_ROW && rc != SQLITE_DONE) throw std::runtime_error("step");
return rc;
}
std::string column(int i) { return reinterpret_cast<const char*>(sqlite3_column_text(p, i)); }
};
struct Transaction {
sqlite3* db;
bool committed = false;
explicit Transaction(sqlite3* d) : db(d) {
if (sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr) != SQLITE_OK) throw std::runtime_error("begin");
}
void commit() {
if (sqlite3_exec(db, "COMMIT", nullptr, nullptr, nullptr) != SQLITE_OK) throw std::runtime_error("commit");
committed = true;
}
~Transaction() { if (!committed) sqlite3_exec(db, "ROLLBACK", nullptr, nullptr, nullptr); }
};
J zeros(int n) {J a=J::array();for(int i=0;i<n;i++)a.push_back(0);return a;}
J initial(int64_t now) {
const int pets=static_cast<int>(config()["pets"].size()),stages=static_cast<int>(config()["stages"].size());
J owned=zeros(pets);owned[0]=1;if(pets>1)owned[1]=1;
J formation=J::array();formation.push_back(0);if(pets>1)formation.push_back(1);while(static_cast<int>(formation.size())<6)formation.push_back(-1);
return J{{"schema",4},{"campLayout",{-1,-1,-1}},{"decorations",{0,0,0}},{"restPet",0},{"campInteractions",0},{"captureTutorialDone",false},{"captureCount",0},{"duplicateStreak",0},{"selectedStage",1},{"clearedStages",zeros(stages)},{"revision",0},{"coins",0},{"candy",0},{"balls",economy()["initialBalls"]},{"tickets",0},
{"level",1},{"shots",0},{"stages",owned},{"shards",zeros(pets)},
{"formation",formation},{"cleared",false},{"goalClaimed",false},
{"pendingShot",""},{"lastShot",""},{"pendingAim",0},{"lastCandy",0},{"lastCoins",0},
{"pendingBattle",""},{"lastBattle",""},{"ballRecoveryUnix",now}};
}
void migrate(J& s,int64_t now) {
if(s.at("schema")==1) {
s["schema"]=2;s["selectedStage"]=1;s["clearedStages"]={s.at("cleared").get<bool>()?1:0,0,0,0,0};
if(s.contains("battleData")) {s["battleData"]["stageId"]=1;s["battleData"]["reward"]={{"coins",80},{"candy",40},{"balls",3}};s["battleSnapshot"]["legacy"]=true;}
if(s.contains("lastBattleData")) {s["lastBattleData"]["stageId"]=1;s["lastBattleData"]["reward"]={{"coins",80},{"candy",40},{"balls",3}};}
}
if(s.at("schema")==2) {
if(s.at("stages").size()!=3||s.at("shards").size()!=3) throw std::runtime_error("invalid_old_save");
s["schema"]=3;s["captureTutorialDone"]=s["stages"][2].get<int>()>0;
s["captureCount"]=0;s["duplicateStreak"]=0;
// G2 introduced the two later chapter-one partners; the remaining roster is widened by 8->9.
for(int i=0;i<2;i++) {s["stages"].push_back(0);s["shards"].push_back(0);}
}
if(s.at("schema")==3) {s["schema"]=4;s["campLayout"]={-1,-1,-1};s["decorations"]={0,0,0};s["restPet"]=0;s["campInteractions"]=0;}
if(s.at("schema")==4) {s["schema"]=5;s["dailyDay"]=-1;s["dailyProgress"]={0,0,0};s["dailyClaimed"]={0,0,0};s["signinDays"]=0;s["signinClaimed"]={0,0,0,0,0,0,0};}
if(s.at("schema")==5) {s["schema"]=6;s["shopBought"]={0,0,0};}
if(s.at("schema")==6) {
s["schema"]=7;s["mails"]=J::array();
for(const auto& m:config()["mail"]["initial"]) s["mails"].push_back({{"mailId",m["mailId"]},{"title",m["title"]},{"body",m["body"]},{"reward",m["reward"]},{"sentAt",now},{"expiresAt",now+config()["mail"]["lifetimeSeconds"].get<int>()},{"read",false},{"claimed",false},{"expired",false}});
}
if(s.at("schema")==7) {s["schema"]=8;s["chests"]={0,0,0};s["chestDailyClaimed"]=0;}
if(s.at("schema")==8) {
// 8->9 only widens the roster and chapter list; no partner, stage clear or reward is granted.
const int pets=static_cast<int>(config()["pets"].size()),stages=static_cast<int>(config()["stages"].size());
if(static_cast<int>(s["stages"].size())>pets||static_cast<int>(s["shards"].size())>pets||static_cast<int>(s["clearedStages"].size())>stages) throw std::runtime_error("invalid_old_save");
while(static_cast<int>(s["stages"].size())<pets)s["stages"].push_back(0);
while(static_cast<int>(s["shards"].size())<pets)s["shards"].push_back(0);
while(static_cast<int>(s["clearedStages"].size())<stages)s["clearedStages"].push_back(0);
s["schema"]=9;
}
if(s.at("schema")!=9 || s.at("stages").size()!=config()["pets"].size() || s.at("shards").size()!=config()["pets"].size() || s.at("clearedStages").size()!=config()["stages"].size()) throw std::runtime_error("unsupported_save");
if(!s.at("mails").is_array()||s["mails"].size()>100) throw std::runtime_error("invalid_mail_save");
std::vector<std::string> ids;
for(const auto& m:s["mails"]) {
auto id=m.at("mailId").get<std::string>();
if(id.empty()||id.size()>64||std::find(ids.begin(),ids.end(),id)!=ids.end()||m.at("title").get<std::string>().empty()||m.at("body").get<std::string>().empty()||m.at("sentAt").get<int64_t>()<=0||m.at("expiresAt").get<int64_t>()<=m.at("sentAt").get<int64_t>()) throw std::runtime_error("invalid_mail_record");
ids.push_back(id);for(const char* k:{"read","claimed","expired"}) if(!m.at(k).is_boolean()) throw std::runtime_error("invalid_mail_status");
for(const char* k:{"coins","candy","balls","tickets"}) if(!m.at("reward").at(k).is_number_integer()||m["reward"][k].get<int>()<0||m["reward"][k].get<int>()>10000) throw std::runtime_error("invalid_mail_reward");
}
if(s.at("shopBought").size()!=3) throw std::runtime_error("invalid_shop_save");
for(int i=0;i<3;i++) if(s["shopBought"][i].get<int>()<0||s["shopBought"][i].get<int>()>config()["shop"]["goods"][i]["dailyLimit"].get<int>()) throw std::runtime_error("invalid_shop_count");
if(s.at("chests").size()!=3||s.at("chestDailyClaimed").get<int>()<0||s.at("chestDailyClaimed").get<int>()>1) throw std::runtime_error("invalid_chest_save");
for(int i=0;i<3;i++) if(s["chests"][i].get<int>()<0||s["chests"][i].get<int>()>100000) throw std::runtime_error("invalid_chest_count");
if(s.at("campLayout").size()!=3||s.at("decorations").size()!=3) throw std::runtime_error("invalid_camp_save");
bool used[3]={};
for(const auto& id:s["campLayout"]) {int i=id.get<int>();if(i < -1 || i>2 || (i>=0 && (used[i] || s["decorations"][i]!=1))) throw std::runtime_error("invalid_camp_layout");if(i>=0) used[i]=true;}
for(const auto& n:s["decorations"]) if(n!=0&&n!=1) throw std::runtime_error("invalid_camp_inventory");
int pet=s.at("restPet");if(pet<0||pet>=static_cast<int>(config()["pets"].size())||s["stages"][pet]==0||s.at("campInteractions").get<int>()<0) throw std::runtime_error("invalid_rest_pet");
if(s.at("dailyProgress").size()!=3||s.at("dailyClaimed").size()!=3||s.at("signinClaimed").size()!=7||s.at("dailyDay").get<int>()< -1||s.at("signinDays").get<int>()<0||s.at("signinDays").get<int>()>7) throw std::runtime_error("invalid_daily_save");
for(int i=0;i<3;i++) if(s["dailyProgress"][i].get<int>()<0||s["dailyProgress"][i].get<int>()>config()["daily"]["tasks"][i]["target"].get<int>()||(s["dailyClaimed"][i]!=0&&s["dailyClaimed"][i]!=1)) throw std::runtime_error("invalid_daily_progress");
for(int i=0;i<7;i++) if((s["signinClaimed"][i]!=0&&s["signinClaimed"][i]!=1)||(s["signinClaimed"][i]==1&&i>=s["signinDays"].get<int>())) throw std::runtime_error("invalid_signin_save");
}
void sync_calendar(J& s,int64_t now) {
int day=static_cast<int>((now+config()["daily"]["utcOffsetSeconds"].get<int>())/86400);
if(day>s["dailyDay"].get<int>()) {
s["dailyDay"]=day;s["dailyProgress"]={0,0,0};s["dailyClaimed"]={0,0,0};
s["shopBought"]={0,0,0};s["chestDailyClaimed"]=0;
s["signinDays"]=std::min(7,s["signinDays"].get<int>()+1);
}
}
void daily_event(J& s,int task) {s["dailyProgress"][task]=std::min(config()["daily"]["tasks"][task]["target"].get<int>(),s["dailyProgress"][task].get<int>()+1);}
void grant(J& s,const J& reward) {for(const char* key:{"coins","candy","balls","tickets"}) if(reward.contains(key)) s[key]=s[key].get<int>()+reward[key].get<int>();}
// Chest rewards may also carry a single partner's evolution shards.
void grant_chest(J& s,const J& reward) {
grant(s,reward);
int pet=reward.value("shardsPet",-1),shards=reward.value("shards",0);
if(shards>0&&pet>=0&&pet<static_cast<int>(config()["pets"].size())) s["shards"][pet]=s["shards"][pet].get<int>()+shards;
}
int first_clear_chest(int stageId) {
for(const auto& drop:config()["rewards"]["stageDrops"]) if(drop["stageId"].get<int>()==stageId) return drop["chest"].get<int>();
return -1;
}
void sync_mail(J& s,int64_t now) {for(auto& m:s["mails"]) if(now>=m["expiresAt"].get<int64_t>()) m["expired"]=true;}
bool attachment(const J& m) {for(const char* k:{"coins","candy","balls","tickets"}) if(m["reward"][k].get<int>()>0) return true;return false;}
J* find_mail(J& s,const std::string& id) {for(auto& m:s["mails"]) if(m["mailId"]==id) return &m;return nullptr;}
std::string claim_mail(J& s,J* m) {
if(!m) return "not_found";
if(m->at("claimed").get<bool>()) return "already_claimed";
if(m->at("expired").get<bool>()) return "expired";
if(!attachment(*m)) return "no_attachment";
grant(s,m->at("reward"));(*m)["claimed"]=true;(*m)["read"]=true;return "claimed";
}
int unlocked(const J& s) {
int id=1;while(id<static_cast<int>(config()["stages"].size())&&s["clearedStages"][id-1]==1) id++;return id;
}
void recover(J& s, int64_t now) {
int cap=economy()["ballCap"],seconds=economy()["recoverySeconds"];
int balls = s.at("balls"); int64_t anchor = s.at("ballRecoveryUnix");
if (balls >= cap || now - anchor < seconds) return;
int n = static_cast<int>(std::min<int64_t>((now-anchor)/seconds, cap-balls));
s["balls"] = balls+n;
s["ballRecoveryUnix"] = balls+n >= cap ? now : anchor+n*seconds;
}
struct V { float x,y; };
J pinball(int aim) {
const auto& board=config().at("pinball");
constexpr float dt=1.f/60;
V p{.5f,.95f}, v{std::sin(aim * .0174532924f)*.95f,-.35f};
J path=J::array(); path.push_back({{"x",p.x},{"y",p.y}});
for(int tick=0;tick<600;tick++) {
v.y-=board["gravity"].get<float>()*dt; p.x+=v.x*dt; p.y+=v.y*dt;
if(p.x<.025f) {p.x=.025f;v.x=std::abs(v.x)*board["wallBounce"].get<float>();}
if(p.x>.975f) {p.x=.975f;v.x=-std::abs(v.x)*board["wallBounce"].get<float>();}
for(int row=0;row<4;row++) for(int col=0;col<5;col++) {
V peg{.12f+col*.19f+(row%2==0?0:.025f), .72f-row*.16f};
V d{p.x-peg.x,p.y-peg.y}; float len=d.x*d.x+d.y*d.y;
float radius=board["pegRadius"];
if(len>=radius*radius) continue;
V n = len<.000001f ? V{.4f,1} : d;
float norm=std::sqrt(n.x*n.x+n.y*n.y); n.x/=norm;n.y/=norm;
float separation=board["pegSeparation"];p={peg.x+n.x*separation,peg.y+n.y*separation};
float dot=v.x*n.x+v.y*n.y;
if(dot<0) v={(v.x-2*dot*n.x)*board["pegBounce"].get<float>(),(v.y-2*dot*n.y)*board["pegBounce"].get<float>()};
if(n.y>.8f && std::abs(v.x)<.06f) v.x=(aim<0?-1:1)*board["bumperSpeed"].get<float>();
}
if(p.y>.98f) {p.y=.98f;v.y=-std::abs(v.y);}
path.push_back({{"x",p.x},{"y",p.y}});
if(p.y<=.035f) break;
}
int bin=std::clamp(static_cast<int>(p.x*5),0,4);
return J{{"path",path},{"bin",bin},{"candy",board["candy"][bin]},{"coins",board["coins"][bin]}};
}
int lane_position(int lane) {return lane==0?0:lane==1?-1:1;}
J battle_lanes(const J& s,int stageId) {
struct Hero {int pet,slot,tier,skills=0;float hp,max,attack,cd=0,skill=0,shield=0,shieldTime=0,damage=0,healing=0,absorbed=0;};
struct Enemy {int index,type,lane;float hp,z=11,cd=0,slowTime=0,slow=1;};
const auto& stage=gameplay::stage(stageId);const auto& pets=config()["pets"];const auto& types=config()["enemies"];
std::vector<Hero> heroes;std::vector<Enemy> enemies;
for(int slot=0;slot<stage["lanes"].get<int>()*2;slot++) {
int pet=s["formation"][slot];if(pet<0)continue;const auto& p=pets[pet];int tier=s["stages"][pet];
float growth=(1+economy()["levelGrowth"].get<float>()*(s["level"].get<int>()-1))*(tier==2?economy()["evolveGrowth"].get<float>():1);
float hp=p["hp"].get<float>()*growth;heroes.push_back({pet,slot,tier,0,hp,hp,p["attack"].get<float>()*growth});
}
int spawned=0,durability=stage["durability"],defeated=0;float time=0;bool won=false;int leaks[3]={};constexpr float dt=.05f;
auto z=[](const Hero& h){return h.slot%2==0?0.f:-2.f;};
auto damage=[&](float raw,int source,int target,float defense) {return stage["elementsEnabled"].get<bool>()?std::max(1.f,std::floor(raw*gameplay::element_multiplier(source,target)-defense)):raw;};
auto targets=[&](const Hero& h,float range) {
std::vector<size_t> ids;for(size_t i=0;i<enemies.size();i++)if(enemies[i].hp>0&&enemies[i].lane==h.slot/2&&enemies[i].z-z(h)<=range)ids.push_back(i);
std::sort(ids.begin(),ids.end(),[&](size_t a,size_t b){return enemies[a].z!=enemies[b].z?enemies[a].z<enemies[b].z:enemies[a].index<enemies[b].index;});return ids;
};
auto hit=[&](Hero& h,Enemy& e,float raw) {
float amount=damage(raw,pets[h.pet]["element"],stage["element"],types[e.type]["defense"]);h.damage+=std::min(e.hp,amount);e.hp-=amount;if(e.hp<=0)defeated++;
};
for(int tick=0;tick<2401;tick++) {
time+=dt;
for(auto& e:enemies){e.slowTime=std::max(0.f,e.slowTime-dt);if(e.slowTime<=0)e.slow=1;}
if(spawned<9&&time>=spawned/3*stage["waveGap"].get<float>()+spawned%3*stage["spawnGap"].get<float>()) {
int type=stage["enemyIds"][spawned];const auto& d=types[type];enemies.push_back({spawned,type,stage["enemyLanes"][spawned],(d["hp"].get<float>()+spawned*d["hpStep"].get<float>())*stage["hpScale"].get<float>()});spawned++;
}
for(auto& h:heroes) {
h.shieldTime=std::max(0.f,h.shieldTime-dt);if(h.shieldTime<=0)h.shield=0;if(h.hp<=0)continue;h.cd-=dt;h.skill-=dt;
const auto& p=pets[h.pet];const auto& a=p["ability"];
if(h.skill<=0) {
std::string kind=a["kind"];float value=a[h.tier==2?"t2":"t1"];bool cast=false;
if(kind=="shield") {h.shield=std::max(h.shield,value);h.shieldTime=a[h.tier==2?"t2Duration":"duration"];cast=true;}
else if(kind=="heal") {
Hero* target=nullptr;int radius=a[h.tier==2?"t2Radius":"radius"];
for(auto& other:heroes)if(other.hp>0&&other.hp<other.max&&std::abs(lane_position(other.slot/2)-lane_position(h.slot/2))<=radius&&std::abs(z(other)-z(h))<=a["range"].get<float>()&&(!target||other.hp/other.max<target->hp/target->max))target=&other;
if(target){float amount=std::min(value,target->max-target->hp);target->hp+=amount;h.healing+=amount;cast=true;}
} else {
auto ids=targets(h,a["range"]);if(!ids.empty()) {
if(kind=="slow") {auto& e=enemies[ids[0]];float factor=types[e.type]["boss"].get<bool>()?1-(1-value)*a["bossControlScale"].get<float>():value;e.slow=std::min(e.slow,factor);e.slowTime=std::max(e.slowTime,a[h.tier==2?"t2Duration":"duration"].get<float>());}
else for(size_t i=0;i<std::min(ids.size(),static_cast<size_t>(a[h.tier==2?"t2Targets":"targets"].get<int>()));i++)hit(h,enemies[ids[i]],h.attack*value);
cast=true;
}
}
if(cast){h.skill=a["cooldown"];h.skills++;}
}
auto ids=targets(h,p["range"]);if(!ids.empty()&&h.cd<=0){hit(h,enemies[ids[0]],h.attack);h.cd=p["cooldown"];}
}
for(auto& e:enemies) {
if(e.hp<=0)continue;const auto& d=types[e.type];Hero* blocker=nullptr;
for(auto& h:heroes)if(h.hp>0&&h.slot/2==e.lane&&(!blocker||h.slot<blocker->slot))blocker=&h;
float stop=blocker?z(*blocker)+1:-4;
if(e.z>stop)e.z=std::max(stop,e.z-d["speed"].get<float>()*e.slow*dt);
else if(blocker) {
e.cd-=dt;if(e.cd<=0){float amount=damage(d["attack"].get<float>()*stage["attackScale"].get<float>(),stage["element"],pets[blocker->pet]["element"],0);float absorbed=std::min(blocker->shield,amount);blocker->shield-=absorbed;blocker->absorbed+=absorbed;blocker->hp=std::max(0.f,blocker->hp-(amount-absorbed));e.cd=d["cooldown"];}
} else {durability-=d["leak"].get<int>();leaks[e.lane]++;e.hp=0;}
}
if(durability<=0||time>=stage["timeout"].get<float>())break;
if(spawned==9&&std::all_of(enemies.begin(),enemies.end(),[](const Enemy& e){return e.hp<=0;})){won=true;break;}
}
J stats=J::array();for(const auto& h:heroes)stats.push_back({{"pet",h.pet},{"slot",h.slot},{"damage",h.damage},{"healing",h.healing},{"absorbed",h.absorbed},{"skills",h.skills}});
return J{{"won",won},{"duration",time},{"durability",std::max(0,durability)},{"defeated",defeated},{"stageId",stageId},{"reward",stage["reward"]},{"configVersion",config()["version"]},{"stats",stats},{"leaks",{leaks[0],leaks[1],leaks[2]}}};
}
// Old frozen results are already saved; only new battle.begin uses the current rule version.
J battle(const J& s,int stageId) {
if(config().value("battleRules",0)==2)return battle_lanes(s,stageId);
struct Hero {int pet,slot;float hp,max,attack,cd=0,heal=0;};
struct Enemy {int index,type;float hp,z=11,cd=0;};
const auto& stage=gameplay::stage(stageId);const auto& pets=config().at("pets");const auto& types=config().at("enemies");
std::vector<Hero> heroes; std::vector<Enemy> enemies;
for(int slot=0;slot<2;slot++) {
int pet=s["formation"][slot];if(pet<0) continue;
const auto& p=pets[pet];float g=(1+economy()["levelGrowth"].get<float>()*(s["level"].get<int>()-1))*(s["stages"][pet]==2?economy()["evolveGrowth"].get<float>():1);
float hp=p["hp"].get<float>()*g;heroes.push_back({pet,slot,hp,hp,p["attack"].get<float>()*g});
}
int spawned=0,durability=stage["durability"],defeated=0;float time=0;bool won=false;
constexpr float dt=.05f;
auto damage=[&](float raw,int attackElement,int defenseElement,float defense) {
if(!stage["elementsEnabled"].get<bool>()) return raw;
return std::max(1.f,std::floor(raw*gameplay::element_multiplier(attackElement,defenseElement)-defense));
};
for(int tick=0;tick<2401;tick++) {
time+=dt;
if(spawned<9 && time>=spawned/3*stage["waveGap"].get<float>()+spawned%3*stage["spawnGap"].get<float>()) {
int type=stage["enemyIds"][spawned];const auto& e=types[type];
enemies.push_back({spawned,type,(e["hp"].get<float>()+spawned*e["hpStep"].get<float>())*stage["hpScale"].get<float>()});spawned++;
}
for(auto& h:heroes) {
if(h.hp<=0) continue;h.cd-=dt;h.heal-=dt;const auto& p=pets[h.pet];
if(p["heal"].get<float>()>0 && h.heal<=0) {
Hero* target=nullptr;
for(auto& other:heroes) if(other.hp>0 && other.hp<other.max && (!target || other.hp/other.max<target->hp/target->max)) target=&other;
if(target) {target->hp=std::min(target->max,target->hp+p["heal"].get<float>());h.heal=p["healCooldown"];}
}
Enemy* nearest=nullptr;float z=h.slot==0?0.f:-2.f;
for(auto& e:enemies) if(e.hp>0 && e.z-z<=p["range"].get<float>() && (!nearest||e.z<nearest->z)) nearest=&e;
if(nearest && h.cd<=0) {
nearest->hp-=damage(h.attack,p["element"],stage["element"],types[nearest->type]["defense"]);
h.cd=p["cooldown"];if(nearest->hp<=0) defeated++;
}
}
for(auto& e:enemies) {
if(e.hp<=0) continue;const auto& type=types[e.type];
Hero* blocker=nullptr;
for(auto& h:heroes) if(h.hp>0 && (!blocker||h.slot<blocker->slot)) blocker=&h;
float stop=blocker?(blocker->slot==0?1.f:-1.f):-4.f;
if(e.z>stop) e.z=std::max(stop,e.z-type["speed"].get<float>()*dt);
else if(blocker) {
e.cd-=dt;if(e.cd<=0) {blocker->hp=std::max(0.f,blocker->hp-damage(type["attack"].get<float>()*stage["attackScale"].get<float>(),stage["element"],pets[blocker->pet]["element"],0));e.cd=type["cooldown"];}
} else {durability-=type["leak"].get<int>();e.hp=0;}
}
if(durability<=0||time>=stage["timeout"].get<float>()) break;
if(spawned==9&&std::all_of(enemies.begin(),enemies.end(),[](const Enemy& e){return e.hp<=0;})) {won=true;break;}
}
return J{{"won",won},{"duration",time},{"durability",std::max(0,durability)},{"defeated",defeated},{"stageId",stageId},{"reward",stage["reward"]},{"configVersion",config()["version"]}};
}
bool identifier(const std::string& id) {
return id.size()>=16 && id.size()<=64 && std::all_of(id.begin(),id.end(),[](unsigned char c){return std::isalnum(c)||c=='-'||c=='_';});
}
int integer(const J& j,const char* field) {
if(!j.contains(field)||!j[field].is_number_integer()) throw std::invalid_argument("invalid_input");
int64_t n=j[field].get<int64_t>();
if(n < -1000000000LL || n > 1000000000LL) throw std::invalid_argument("invalid_input");
return static_cast<int>(n);
}
J capture_info(const J& s) {
const auto& pool=config().at("capture");int sum=0,missingSum=0;bool tutorial=!s.at("captureTutorialDone").get<bool>();
for(size_t i=0;i<pool["weights"].size();i++) {sum+=pool["weights"][i].get<int>();if(s["stages"][i]==0) missingSum+=pool["weights"][i].get<int>();}
bool pity=missingSum>0 && s["duplicateStreak"].get<int>()>=pool["duplicateLimit"].get<int>();
J entries=J::array();
for(size_t i=0;i<pool["weights"].size();i++) {
double base=pool["weights"][i].get<double>()/sum;
double chance=tutorial?(i==pool["tutorialPet"].get<size_t>()?1.:0.):pity?(s["stages"][i]==0?pool["weights"][i].get<double>()/missingSum:0.):base;
entries.push_back({{"pet",i},{"baseProbability",base},{"probability",chance}});
}
return J{{"tutorial",tutorial},{"completed",missingSum==0},{"pityReady",pity},{"count",s["captureCount"]},{"duplicateStreak",s["duplicateStreak"]},{"entries",entries}};
}
J capture(J& s,const std::function<uint32_t(uint32_t)>& random) {
const auto& pool=config().at("capture");auto info=capture_info(s);
bool tutorial=info["tutorial"],pity=!tutorial&&info["pityReady"].get<bool>();
int pet=pool["tutorialPet"];
if(!tutorial) {
uint32_t total=0;for(size_t i=0;i<pool["weights"].size();i++) if(!pity||s["stages"][i]==0) total+=pool["weights"][i].get<uint32_t>();
uint32_t roll;
if(random) roll=random(total);
else {std::random_device source;roll=std::uniform_int_distribution<uint32_t>(0,total-1)(source);}
if(roll>=total) throw std::runtime_error("invalid_random_source");
for(size_t i=0;i<pool["weights"].size();i++) if(!pity||s["stages"][i]==0) {
uint32_t weight=pool["weights"][i];if(roll<weight) {pet=static_cast<int>(i);break;}roll-=weight;
}
s["captureCount"]=s["captureCount"].get<int>()+1;
} else s["captureTutorialDone"]=true;
bool duplicate=s["stages"][pet].get<int>()>0;
s["tickets"]=s["tickets"].get<int>()-1;
if(duplicate) s["shards"][pet]=s["shards"][pet].get<int>()+economy()["duplicateShards"].get<int>();else s["stages"][pet]=1;
if(!tutorial) {
bool complete=std::all_of(s["stages"].begin(),s["stages"].end(),[](const J& value){return value.get<int>()>0;});
s["duplicateStreak"]=complete||!duplicate?0:std::min(pool["duplicateLimit"].get<int>(),s["duplicateStreak"].get<int>()+1);
}
return J{{"pet",pet},{"duplicate",duplicate},{"tutorial",tutorial},{"pity",pity},{"shards",duplicate?economy()["duplicateShards"].get<int>():0},{"poolVersion",config()["version"]}};
}
// Every expected refusal becomes a durable operation result, including stale revisions.
std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint32_t(uint32_t)>& random) {
const auto action=c.at("action").get<std::string>();
if(integer(c,"revision")!=s["revision"].get<int>()) return "stale_revision";
if(action=="shot.begin") {
int aim=integer(c,"aim");if(aim < -config()["pinball"]["maxAim"].get<int>() || aim > config()["pinball"]["maxAim"].get<int>()) return "invalid_aim";
if(s["pendingShot"]!=""||s["pendingBattle"]!="") return "pending_activity";
if(s["balls"].get<int>()<1) return "insufficient_balls";
int balls=s["balls"];s["balls"]=balls-1;
if(balls>=economy()["ballCap"].get<int>()) s["ballRecoveryUnix"]=now;
s["pendingShot"]=c["requestId"];s["pendingAim"]=aim;
s["shotData"]=pinball(aim);
s["shotReadyAt"]=now+static_cast<int64_t>(std::ceil((s["shotData"]["path"].size()-1)/60.0));
} else if(action=="shot.finish") {
if(!c.contains("activityId")||c["activityId"]!=s["pendingShot"]||s["pendingShot"]=="") return "activity_missing";
if(now<s["shotReadyAt"].get<int64_t>()) return "activity_not_ready";
const auto shot=s.at("shotData");
s["coins"]=s["coins"].get<int>()+shot["coins"].get<int>();s["candy"]=s["candy"].get<int>()+shot["candy"].get<int>();
int shots=s["shots"].get<int>()+1;s["shots"]=shots;
if(shots==1||shots%economy()["ticketEvery"].get<int>()==0) s["tickets"]=s["tickets"].get<int>()+1;
s["lastCandy"]=shot["candy"];s["lastCoins"]=shot["coins"];s["lastShot"]=s["pendingShot"];s["pendingShot"]="";
s.erase("shotData");s.erase("shotReadyAt");
daily_event(s,0);
} else if(action=="battle.begin") {
if(s["pendingShot"]!=""||s["pendingBattle"]!="") return "pending_activity";
int stageId=c.contains("stageId")?integer(c,"stageId"):s["selectedStage"].get<int>();
if(stageId==0) stageId=s["selectedStage"];
if(stageId<1||stageId>static_cast<int>(config()["stages"].size())) return "invalid_stage";
if(stageId>unlocked(s)) return "stage_locked";
bool deployed=false;for(int slot=0;slot<gameplay::stage(stageId)["lanes"].get<int>()*2;slot++)if(s["formation"][slot]!=-1)deployed=true;
if(!deployed)return "empty_formation";
s["selectedStage"]=stageId;
s["pendingBattle"]=c["requestId"];s["battleData"]=battle(s,stageId);
// The client supports at most 2x playback. A forged victory or elapsed time is never accepted.
s["battleReadyAt"]=now+static_cast<int64_t>(std::ceil(s["battleData"]["duration"].get<double>()/2));
s["battleSnapshot"]={{"level",s["level"]},{"stages",s["stages"]},{"formation",s["formation"]},{"stageId",stageId},{"config",config()},{"legacy",false}};
} else if(action=="battle.finish"||action=="battle.abandon") {
if(!c.contains("activityId")||c["activityId"]!=s["pendingBattle"]||s["pendingBattle"]=="") return "activity_missing";
bool abandon=action=="battle.abandon";
if(!abandon&&now<s["battleReadyAt"].get<int64_t>()) return "activity_not_ready";
result=s["battleData"];if(abandon) result["won"]=false;
int stageId=result["stageId"];bool first=result["won"].get<bool>()&&s["clearedStages"][stageId-1]==0;result["firstClear"]=first;
if(first) {
s["clearedStages"][stageId-1]=1;if(stageId==1) s["cleared"]=true;
for(const char* key:{"coins","candy","balls"}) s[key]=s[key].get<int>()+result["reward"][key].get<int>();
int chest=first_clear_chest(stageId);
if(chest>=0) {s["chests"][chest]=s["chests"][chest].get<int>()+1;result["chest"]=chest;}
}
result["id"]=s["pendingBattle"];s["lastBattleData"]=result;
s["lastBattle"]=s["pendingBattle"];s["pendingBattle"]="";
s.erase("battleData");s.erase("battleReadyAt");s.erase("battleSnapshot");
if(!abandon) daily_event(s,1);
} else {
if(s["pendingBattle"]!=""||s["pendingShot"]!="") return "pending_activity";
if(action=="stage.select") {
int id=integer(c,"stageId");if(id<1||id>static_cast<int>(config()["stages"].size())) return "invalid_stage";
if(id>unlocked(s)) return "stage_locked";s["selectedStage"]=id;
} else if(action=="camp.place"||action=="camp.store") {
const auto& camp=config().at("camp");
if(s["clearedStages"][camp["buildUnlockStage"].get<int>()-1]!=1) return "camp_build_locked";
int slot=integer(c,"slot");if(slot<0||slot>=camp["slots"].get<int>()) return "invalid_camp_slot";
if(action=="camp.store") {
if(s["campLayout"][slot]==-1) return "camp_slot_empty";
result={{"item",s["campLayout"][slot]},{"slot",slot},{"chargedCoins",0}};s["campLayout"][slot]=-1;
} else {
int item=integer(c,"item");if(item<0||item>=static_cast<int>(camp["decorations"].size())) return "invalid_decoration";
int target=s["campLayout"][slot];if(target>=0&&target!=item) return "camp_slot_occupied";
int cost=s["decorations"][item]==1?0:camp["decorations"][item]["coins"].get<int>();
if(s["coins"].get<int>()<cost) return "insufficient_coins";
s["coins"]=s["coins"].get<int>()-cost;s["decorations"][item]=1;
for(auto& old:s["campLayout"]) if(old==item) old=-1;
s["campLayout"][slot]=item;result={{"item",item},{"slot",slot},{"chargedCoins",cost}};
}
result["id"]=c["requestId"];
} else if(action=="camp.interact") {
if(s["clearedStages"][config()["camp"]["restUnlockStage"].get<int>()-1]!=1) return "camp_rest_locked";
int pet=integer(c,"pet");if(pet<0||pet>=static_cast<int>(config()["pets"].size())||s["stages"][pet]==0) return "invalid_pet";
s["restPet"]=pet;s["campInteractions"]=std::min(s["campInteractions"].get<int>(),999999999)+1;
result={{"id",c["requestId"]},{"pet",pet}};
daily_event(s,2);
} else if(action=="mail.read"||action=="mail.claim") {
auto id=c.at("mailId").get<std::string>();if(id.empty()||id.size()>64) return "invalid_input";
auto m=find_mail(s,id);if(!m) return "mail_not_found";
if(action=="mail.read") {(*m)["read"]=true;result={{"id",c["requestId"]},{"mailId",id}};}
else {
auto status=claim_mail(s,m);if(status!="claimed") return "mail_"+status;
result={{"id",c["requestId"]},{"mailId",id},{"reward",m->at("reward")},{"claimedCount",1}};
}
} else if(action=="mail.claimAll") {
const auto& ids=c.at("mailIds");if(!ids.is_array()||ids.empty()||ids.size()>20) return "invalid_input";
std::vector<std::string> unique;
for(const auto& value:ids) {auto id=value.get<std::string>();if(id.empty()||id.size()>64||std::find(unique.begin(),unique.end(),id)!=unique.end()) return "invalid_input";unique.push_back(id);}
J total={{"coins",0},{"candy",0},{"balls",0},{"tickets",0}},entries=J::array();int count=0;
for(const auto& id:unique) {
auto m=find_mail(s,id);auto status=claim_mail(s,m);entries.push_back({{"mailId",id},{"status",status}});
if(status=="claimed") {count++;for(const char* k:{"coins","candy","balls","tickets"}) total[k]=total[k].get<int>()+m->at("reward")[k].get<int>();}
}
result={{"id",c["requestId"]},{"reward",total},{"claimedCount",count},{"entries",entries}};
} else if(action=="shop.buy") {
if(integer(c,"day")!=s["dailyDay"].get<int>()) return "shop_expired";
int item=integer(c,"item");if(item<0||item>=3) return "invalid_goods";
const auto& goods=config()["shop"]["goods"][item];int unlock=goods["unlockStage"];
if(unlock>0&&s["clearedStages"][unlock-1]!=1) return "goods_locked";
if(s["shopBought"][item].get<int>()>=goods["dailyLimit"].get<int>()) return "shop_limit";
int cost=goods["coins"];if(s["coins"].get<int>()<cost) return "insufficient_coins";
s["coins"]=s["coins"].get<int>()-cost;grant(s,goods["reward"]);s["shopBought"][item]=s["shopBought"][item].get<int>()+1;
result={{"id",c["requestId"]},{"item",item},{"day",s["dailyDay"]},{"chargedCoins",cost},{"reward",goods["reward"]}};
} else if(action=="daily.claim") {
if(integer(c,"day")!=s["dailyDay"].get<int>()) return "daily_expired";
int item=integer(c,"item");if(item<0||item>=3) return "invalid_task";
if(s["dailyClaimed"][item]==1) return "reward_claimed";
if(s["dailyProgress"][item].get<int>()<config()["daily"]["tasks"][item]["target"].get<int>()) return "task_incomplete";
auto reward=config()["daily"]["tasks"][item]["reward"];grant(s,reward);s["dailyClaimed"][item]=1;
result={{"id",c["requestId"]},{"item",item},{"day",s["dailyDay"]},{"reward",reward}};
} else if(action=="signin.claim") {
int item=integer(c,"item");if(item<0||item>=7) return "invalid_signin";
if(item>=s["signinDays"].get<int>()) return "signin_locked";
if(s["signinClaimed"][item]==1) return "reward_claimed";
auto reward=config()["daily"]["signin"][item];grant(s,reward);s["signinClaimed"][item]=1;
result={{"id",c["requestId"]},{"item",item},{"reward",reward}};
} else if(action=="chest.claimDaily") {
const auto& rewards=config()["rewards"];int chest=rewards["dailyChest"];
if(s["chestDailyClaimed"].get<int>()==1) return "chest_claimed";
s["chestDailyClaimed"]=1;s["chests"][chest]=s["chests"][chest].get<int>()+1;
result={{"id",c["requestId"]},{"chest",chest},{"day",s["dailyDay"]},{"count",1}};
} else if(action=="chest.open") {
const auto& rewards=config()["rewards"];
int chest=integer(c,"chest");if(chest<0||chest>=static_cast<int>(rewards["chests"].size())) return "invalid_chest";
if(s["chests"][chest].get<int>()<1) return "chest_missing";
const auto& def=rewards["chests"][chest];std::string kind=def["kind"];
J reward=def["contents"];
if(kind=="choice") {
if(!c.contains("choice")) return "invalid_choice";
int choice=integer(c,"choice");if(choice<0||choice>=static_cast<int>(def["choices"].size())) return "invalid_choice";
reward=def["choices"][choice]["reward"];
}
s["chests"][chest]=s["chests"][chest].get<int>()-1;grant_chest(s,reward);
result={{"id",c["requestId"]},{"chest",chest},{"kind",kind},{"remaining",s["chests"][chest]},{"chestReward",reward}};
if(kind=="choice") result["choice"]=integer(c,"choice");
} else if(action=="capture") {
if(s["tickets"].get<int>()<1) return "insufficient_tickets";
result=capture(s,random);result["id"]=c["requestId"];s["lastCaptureData"]=result;
} else if(action=="train") {
int level=s["level"];if(level>=economy()["levelCap"].get<int>()) return "level_cap";
if(s["candy"].get<int>()<economy()["trainCost"].get<int>()*level) return "insufficient_candy";
s["candy"]=s["candy"].get<int>()-economy()["trainCost"].get<int>()*level;s["level"]=level+1;
} else if(action=="evolve") {
int pet=integer(c,"pet");if(pet<0||pet>=static_cast<int>(config()["pets"].size())) return "invalid_pet";
if(s["stages"][pet]!=1||s["level"].get<int>()<economy()["evolveLevel"].get<int>()||s["shards"][pet].get<int>()<economy()["evolveShards"].get<int>()||s["coins"].get<int>()<economy()["evolveCoins"].get<int>()) return "evolve_requirements";
s["shards"][pet]=s["shards"][pet].get<int>()-economy()["evolveShards"].get<int>();s["coins"]=s["coins"].get<int>()-economy()["evolveCoins"].get<int>();s["stages"][pet]=2;
} else if(action=="deploy") {
int pet=integer(c,"pet"),slot=integer(c,"slot");
if(pet<0||pet>=static_cast<int>(config()["pets"].size())||slot<0||slot>=gameplay::stage(s["selectedStage"])["lanes"].get<int>()*2||s["stages"][pet]==0) return "invalid_formation";
int old=-1;for(int i=0;i<6;i++) if(s["formation"][i]==pet) old=i;
if(old>=0) s["formation"][old]=s["formation"][slot];s["formation"][slot]=pet;
} else if(action=="undeploy") {
int slot=integer(c,"slot");if(slot<0||slot>=6||s["formation"][slot]==-1)return "invalid_formation";s["formation"][slot]=-1;
} else if(action=="autoDeploy") {
s["formation"]={-1,-1,-1,-1,-1,-1};int lanes=gameplay::stage(s["selectedStage"])["lanes"];
const auto& pets=config()["pets"];std::vector<int> used(pets.size(),0);
// Front line: closest-range owned partners first; back line: longest-range owned partners first.
auto fill=[&](int offset,bool front){
for(int lane=0;lane<lanes;lane++) {
int best=-1;float bestRange=front?1e9f:-1.f;
for(size_t pet=0;pet<pets.size();pet++) {
if(s["stages"][pet].get<int>()==0||used[pet])continue;
float range=pets[pet]["range"].get<float>();
if(front?(range<bestRange):(range>bestRange)){bestRange=range;best=static_cast<int>(pet);}
}
if(best>=0){s["formation"][lane*2+offset]=best;used[best]=1;}
}
};
fill(0,true);fill(1,false);
}
else if(action=="goal.claim") {
if(!s["cleared"].get<bool>()) return "goal_incomplete";if(s["goalClaimed"].get<bool>()) return "goal_claimed";
s["goalClaimed"]=true;s["balls"]=s["balls"].get<int>()+economy()["goalBalls"].get<int>();
result={{"id",c["requestId"]},{"reward",{{"coins",0},{"candy",0},{"balls",economy()["goalBalls"]}}}};
} else return "unknown_action";
}
return "";
}
J response(const J& s,int64_t now) {
J visible=s;for(const char* k:{"shotData","battleData","battleSnapshot","shotReadyAt","battleReadyAt","lastBattleData","lastCaptureData"}) visible.erase(k);
J r{{"ok",true},{"state",visible},{"serverTime",now},{"configVersion",config()["version"]},{"config",config()}};
r["capture"]=capture_info(s);
r["daily"]={{"day",s["dailyDay"]},{"resetAt",(s["dailyDay"].get<int64_t>()+1)*86400-config()["daily"]["utcOffsetSeconds"].get<int>()}};
r["rewards"]={{"dailyClaimed",s["chestDailyClaimed"]}};
if(s.contains("lastCaptureData")) r["lastCaptureResult"]=s["lastCaptureData"];
if(s.contains("lastBattleData")) r["lastBattleResult"]=s["lastBattleData"];
if(s["pendingShot"]!="") {r["shot"]=s["shotData"];r["shot"]["id"]=s["pendingShot"];r["shot"]["readyAt"]=s["shotReadyAt"];}
if(s["pendingBattle"]!="") {
r["battle"]={{"id",s["pendingBattle"]},{"readyAt",s["battleReadyAt"]},{"snapshot",s["battleSnapshot"]}};
}
return r;
}
}
db::GameReply db::Database::game_request(const std::string& token,const std::string& command,int64_t now,const std::function<uint32_t(uint32_t)>& random) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
auto pid=pid_for_token(token);if(!pid) return {401,"{\"ok\":false,\"error\":\"unauthorized\"}"};
if(now<=0) now=static_cast<int64_t>(std::time(nullptr));
J c;std::string id;
try {
if(!command.empty()) {
if(command.size()>8192) throw std::invalid_argument("size");
c=J::parse(command);id=c.at("requestId").get<std::string>();
if(!identifier(id)||!c.at("action").is_string()) throw std::invalid_argument("id");
integer(c,"revision");
}
} catch(...) {return {400,"{\"ok\":false,\"error\":\"invalid_input\"}"};}
try {
Transaction tx(db_);J s;
{Statement q(db_,"SELECT state FROM game_states WHERE pid=?");q.number(1,*pid);if(q.step()==SQLITE_ROW) s=J::parse(q.column(0));else s=initial(now);}
auto previous=s;migrate(s,now);recover(s,now);sync_calendar(s,now);sync_mail(s,now);
J result=J::object(),r;std::string error;
bool replayed=false;
if(!command.empty()) {
Statement q(db_,"SELECT request,response FROM game_operations WHERE pid=? AND request_id=?");q.number(1,*pid);q.text(2,id);
if(q.step()==SQLITE_ROW) {
if(q.column(0)!=c.dump()) return {409,"{\"ok\":false,\"error\":\"request_id_conflict\"}"};
auto old=J::parse(q.column(1));result=old["result"];error=old.value("error","");replayed=true;
} else {
auto before=s;
try {error=apply(s,c,now,result,random);} catch(const J::exception&) {error="invalid_input";} catch(const std::invalid_argument&) {error="invalid_input";}
if(!error.empty()) s=before;
}
}
if(s!=previous) s["revision"]=previous["revision"].get<int>()+1;
r=response(s,now);r["result"]=result;r["ok"]=error.empty();r["error"]=error;r["replayed"]=replayed;r["action"]=command.empty()?"":c["action"].get<std::string>();
{Statement q(db_,"INSERT INTO game_states(pid,state) VALUES(?,?) ON CONFLICT(pid) DO UPDATE SET state=excluded.state");q.number(1,*pid);q.text(2,s.dump());q.step();}
if(!command.empty()&&!replayed) {
Statement q(db_,"INSERT INTO game_operations(pid,request_id,request,response) VALUES(?,?,?,?)");
q.number(1,*pid);q.text(2,id);q.text(3,c.dump());q.text(4,r.dump());q.step();
}
tx.commit();return {200,r.dump()};
} catch(...) {return {500,"{\"ok\":false,\"error\":\"storage_unavailable\"}"};}
}
+99
View File
@@ -0,0 +1,99 @@
#include "game_config.hpp"
#include "game_config_data.hpp"
#include <stdexcept>
#include <algorithm>
#include <vector>
namespace gameplay {
const nlohmann::json& config() {
static const auto data=[] {
auto j=nlohmann::json::parse(kGameplayJson);
if(j.at("schema")!=1 || j.at("pets").size()!=10 || j.at("stages").size()!=20 || j.at("enemies").size()!=5)
throw std::runtime_error("Unsupported gameplay config");
if(j.at("battleRules")!=2) throw std::runtime_error("Unsupported battle rules");
for(int i=0;i<20;i++) {
const auto& s=j.at("stages").at(i);
if(s.at("id")!=i+1 || s.at("enemyIds").size()!=9 || s.at("hpScale").get<float>()<=0 || s.at("timeout").get<float>()<=0)
throw std::runtime_error("Invalid stage config");
for(const auto& id:s.at("enemyIds")) if(!id.is_number_integer()||id.get<int>()<0||id.get<int>()>=5) throw std::runtime_error("Invalid enemy id");
if(!s.at("chapter").is_string()||s["chapter"].get<std::string>().empty()) throw std::runtime_error("Invalid stage chapter");
int lanes=s.at("lanes");if(lanes<1||lanes>3||s.at("enemyLanes").size()!=9)throw std::runtime_error("Invalid lane config");
for(const auto& lane:s["enemyLanes"])if(!lane.is_number_integer()||lane.get<int>()<0||lane.get<int>()>=lanes)throw std::runtime_error("Invalid enemy lane");
}
for(const auto& p:j["pets"]) {
const auto& a=p.at("ability");std::string kind=a.at("kind");
if(kind!="shield"&&kind!="heal"&&kind!="burst"&&kind!="chain"&&kind!="slow")throw std::runtime_error("Invalid skill kind");
if(a.at("name").get<std::string>().empty()||a.at("target")!=(kind=="shield"?"self":kind=="heal"?"ally":"enemy"))throw std::runtime_error("Invalid skill target");
auto number=[&](const char* key,float lo,float hi){if(!a.at(key).is_number()||a[key].get<float>()<lo||a[key].get<float>()>hi)throw std::runtime_error("Invalid skill value");};
number("cooldown",.05f,120);number("range",0,12);number("duration",0,30);number("t2Duration",0,30);number("t1",.0001f,10000);number("t2",.0001f,10000);
for(const char* key:{"radius","t2Radius","targets","t2Targets"})if(!a.at(key).is_number_integer()||a[key].get<int>()<(std::string(key).find("argets")!=std::string::npos?1:0)||a[key].get<int>()>(std::string(key).find("argets")!=std::string::npos?3:2))throw std::runtime_error("Invalid skill capacity");
if(kind=="slow"&&(a["t1"].get<float>()>1||a["t2"].get<float>()>1))throw std::runtime_error("Invalid slow factor");
if(kind=="slow")number("bossControlScale",0,1);
}
const auto& pool=j.at("capture");
const auto& daily=j.at("daily");
if(daily.at("utcOffsetSeconds")!=28800||daily.at("tasks").size()!=3||daily.at("signin").size()!=7) throw std::runtime_error("Invalid daily config");
for(int i=0;i<3;i++) if(daily["tasks"][i].at("id")!=i||daily["tasks"][i].at("target")!=(i==0?3:1)) throw std::runtime_error("Invalid daily task");
auto validReward=[](const auto& r){for(const char* key:{"coins","candy","balls"}) if(!r.at(key).is_number_integer()||r.at(key).template get<int>()<0||r.at(key).template get<int>()>10000) throw std::runtime_error("Invalid daily reward");};
const auto& mail=j.at("mail");
if(!mail.at("lifetimeSeconds").is_number_integer()||mail["lifetimeSeconds"].get<int>()<1||mail["lifetimeSeconds"].get<int>()>31536000||!mail.at("initial").is_array()||mail["initial"].size()>100) throw std::runtime_error("Invalid mail config");
std::vector<std::string> mailIds;
for(const auto& m:mail["initial"]) {
auto id=m.at("mailId").get<std::string>();
if(id.empty()||id.size()>64||std::find(mailIds.begin(),mailIds.end(),id)!=mailIds.end()||m.at("title").get<std::string>().empty()||m["title"].get<std::string>().size()>240||m.at("body").get<std::string>().empty()||m["body"].get<std::string>().size()>4000) throw std::runtime_error("Invalid initial mail");
mailIds.push_back(id);validReward(m.at("reward"));
if(!m["reward"].at("tickets").is_number_integer()||m["reward"]["tickets"].get<int>()<0||m["reward"]["tickets"].get<int>()>10000) throw std::runtime_error("Invalid mail attachment");
}
for(const auto& t:daily["tasks"]) validReward(t.at("reward"));for(const auto& r:daily["signin"]) validReward(r);
const auto& shop=j.at("shop").at("goods");if(shop.size()!=3) throw std::runtime_error("Invalid shop config");
for(int i=0;i<3;i++) {
const auto& g=shop[i];if(g.at("id")!=i||g.at("coins").get<int>()<0||g.at("coins").get<int>()>100000||g.at("dailyLimit").get<int>()<1||g.at("dailyLimit").get<int>()>100||g.at("unlockStage").get<int>()<0||g.at("unlockStage").get<int>()>5) throw std::runtime_error("Invalid goods");
validReward(g.at("reward"));if(!g["reward"].at("tickets").is_number_integer()||g["reward"]["tickets"].get<int>()<0||g["reward"]["tickets"].get<int>()>10000) throw std::runtime_error("Invalid ticket reward");
}
const auto& rewards=j.at("rewards");
const auto& chests=rewards.at("chests");
if(!chests.is_array()||chests.size()!=3) throw std::runtime_error("Invalid chest config");
auto chestReward=[&](const auto& r){
validReward(r);
if(!r.at("tickets").is_number_integer()||r["tickets"].get<int>()<0||r["tickets"].get<int>()>10000) throw std::runtime_error("Invalid chest tickets");
if(!r.at("shardsPet").is_number_integer()||!r.at("shards").is_number_integer()) throw std::runtime_error("Invalid chest shards");
int pet=r["shardsPet"].get<int>(),shards=r["shards"].get<int>();
if(shards<0||shards>10000||(shards>0&&(pet<0||pet>=static_cast<int>(j["pets"].size())))||(shards==0&&pet!=-1)) throw std::runtime_error("Invalid chest shard reward");
};
for(int i=0;i<3;i++) {
const auto& c=chests[i];auto kind=c.at("kind").get<std::string>();
if(c.at("id")!=i||c.at("name").get<std::string>().empty()||kind!="fixed"&&kind!="choice"||!c.at("choices").is_array()) throw std::runtime_error("Invalid chest");
chestReward(c.at("contents"));
if(kind=="fixed"&&!c["choices"].empty()) throw std::runtime_error("Fixed chest cannot have choices");
if(kind=="choice"&&(c["choices"].size()<2||c["choices"].size()>j["pets"].size())) throw std::runtime_error("Invalid chest choices");
for(const auto& choice:c["choices"]) {if(choice.at("name").get<std::string>().empty()) throw std::runtime_error("Invalid chest choice");chestReward(choice.at("reward"));}
}
if(!rewards.at("dailyChest").is_number_integer()||rewards["dailyChest"].get<int>()<0||rewards["dailyChest"].get<int>()>=3) throw std::runtime_error("Invalid daily chest");
const auto& drops=rewards.at("stageDrops");if(!drops.is_array()||drops.size()>j["stages"].size()) throw std::runtime_error("Invalid chest drops");
std::vector<int> dropStages;
for(const auto& drop:drops) {
int stage=drop.at("stageId").get<int>(),chest=drop.at("chest").get<int>();
if(stage<1||stage>static_cast<int>(j["stages"].size())||chest<0||chest>=3||std::find(dropStages.begin(),dropStages.end(),stage)!=dropStages.end()) throw std::runtime_error("Invalid chest drop");
dropStages.push_back(stage);
}
const auto& camp=j.at("camp");
if(camp.at("slots")!=3||camp.at("decorations").size()!=3||camp.at("restUnlockStage")!=3||camp.at("buildUnlockStage")!=5) throw std::runtime_error("Invalid camp config");
for(int i=0;i<3;i++) if(camp["decorations"][i].at("id")!=i||camp["decorations"][i].at("coins").get<int>()<=0) throw std::runtime_error("Invalid decoration config");
if(pool.at("weights").size()!=j.at("pets").size()||pool.at("tutorialPet")!=2||pool.at("duplicateLimit").get<int>()<1) throw std::runtime_error("Invalid capture pool");
for(const auto& weight:pool.at("weights")) if(!weight.is_number_integer()||weight.get<int>()<1||weight.get<int>()>1000) throw std::runtime_error("Invalid capture weight");
j["version"]=kGameplayVersion;
return j;
}();
return data;
}
const nlohmann::json& stage(int id) {return config().at("stages").at(id-1);}
float element_multiplier(int attack,int defense) {
if(attack==0||defense==0) return 1;
const auto& e=config().at("elements");const auto& cycle=e.at("cycle");
for(size_t i=0;i<cycle.size();i++) {
if(cycle[i]==attack && cycle[(i+1)%cycle.size()]==defense) return e.at("advantage").get<float>();
if(cycle[i]==defense && cycle[(i+1)%cycle.size()]==attack) return e.at("disadvantage").get<float>();
}
return 1;
}
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include "json.hpp"
namespace gameplay {
const nlohmann::json& config();
const nlohmann::json& stage(int id);
float element_multiplier(int attack, int defense);
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
// Generated by CMake from the canonical JSON. Do not edit the generated header.
// The file is emitted as adjacent literal chunks so it can exceed MSVC's per-literal limit.
static constexpr const char* kGameplayJson =
@GAMEPLAY_JSON_CHUNKS@;
static constexpr const char* kGameplayVersion = "@GAMEPLAY_VERSION@";
+21 -1
View File
@@ -364,6 +364,10 @@ void HttpWsServer::handle_client(uintptr_t client_sock) {
auto cl_it = req.headers.find("content-length");
size_t content_length = 0;
if (cl_it != req.headers.end()) content_length = static_cast<size_t>(std::strtoull(cl_it->second.c_str(), nullptr, 10));
if (content_length > 64 * 1024) {
auto resp = http_response(413, "Content Too Large", "{\"error\":\"body_too_large\"}");
send_all(s, resp.data(), resp.size()); return;
}
while (req.body.size() < content_length) {
if (!recv_some(s, raw, 1024 * 1024)) return;
const auto pos = raw.find("\r\n\r\n");
@@ -418,6 +422,22 @@ void HttpWsServer::handle_client(uintptr_t client_sock) {
}
// HTTP API
if ((req.method == "GET" && req.path == "/game/state") ||
(req.method == "POST" && req.path == "/game/command")) {
const auto m = maintenance::load(maintenance_path_);
db::GameReply r;
if (m.enabled) r = {503, nlohmann::json{{"ok",false},{"error","maintenance"}}.dump()};
else {
const auto header = req.headers.find("authorization");
const auto value = header == req.headers.end() ? "" : header->second;
const auto token = value.rfind("Bearer ",0) == 0 ? value.substr(7) : "";
// An empty POST is malformed, never silently interpreted as a state read.
if (req.method == "POST" && req.body.empty()) r = {400,"{\"error\":\"invalid_input\"}"};
else r = db_.game_request(token, req.method == "POST" ? req.body : "");
}
auto resp = http_response(r.status, r.status == 200 ? "OK" : "Error", r.body);
send_all(s, resp.data(), resp.size()); return;
}
if (req.method == "POST" && (req.path == "/register" || req.path == "/login")) {
std::string username, password;
try {
@@ -444,7 +464,7 @@ void HttpWsServer::handle_client(uintptr_t client_sock) {
if (req.method == "GET" && req.path == "/health") {
const auto m = maintenance::load(maintenance_path_);
nlohmann::json body{{"ok", true}, {"service", "aiserver"}, {"maintenance", m.enabled}};
nlohmann::json body{{"ok", true}, {"service", "aiserver"}, {"maintenance", m.enabled}, {"gameProtocol", 1}};
if (m.enabled) body["message"] = m.message;
auto resp = http_response(200, "OK", body.dump());
send_all(s, resp.data(), resp.size());
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
#include "db.hpp"
#include "json.hpp"
#include "sqlite3.h"
#include "game_config.hpp"
#include <atomic>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <thread>
#include <vector>
using J=nlohmann::json;
static int assertions=0;
static void check(bool pass,const char* message) {
assertions++;if(!pass) throw std::runtime_error(message);
}
struct Fixture {
std::string path,token="test-token";
db::Database db;
int64_t now=2000000000;
int serial=0;
J state;
Fixture(const std::string& file):path(file),db(file) {
check(db.open_ok(),"database opens");
auto pid=db.create_user("tester","hash","salt");check(pid.has_value(),"user created");
check(db.store_token(token,*pid,1,4102444800LL),"token created");read();
}
J read() {
auto r=db.game_request(token,"",now);check(r.status==200,"read succeeds");auto j=J::parse(r.body);state=j["state"];return j;
}
J command(const std::string& action,J extra=J::object()) {
extra["action"]=action;extra["revision"]=state["revision"];
extra["requestId"]="operation-"+std::to_string(++serial)+"-unique-test";return extra;
}
J send(const J& c, const std::function<uint32_t(uint32_t)>& random = {}) {
auto r=db.game_request(token,c.dump(),now,random);check(r.status==200,"command HTTP result");
auto j=J::parse(r.body);state=j["state"];return j;
}
void sql(const std::string& query) {
sqlite3* conn=nullptr;check(sqlite3_open(path.c_str(),&conn)==SQLITE_OK,"fixture opens");
int rc=sqlite3_exec(conn,query.c_str(),nullptr,nullptr,nullptr);sqlite3_close(conn);check(rc==SQLITE_OK,"fixture sql");
}
void replace(J next) {
// Test-only fixture editing, never exposed by an HTTP route.
std::string data=next.dump(),escaped;
for(char c:data) {escaped+=c;if(c=='\'') escaped+='\'';}
sql("UPDATE game_states SET state='"+escaped+"' WHERE pid=1");read();
}
J stored() {
sqlite3* conn=nullptr;check(sqlite3_open(path.c_str(),&conn)==SQLITE_OK,"read fixture opens");sqlite3_stmt* q=nullptr;
check(sqlite3_prepare_v2(conn,"SELECT state FROM game_states WHERE pid=1",-1,&q,nullptr)==SQLITE_OK,"read fixture prepares");
check(sqlite3_step(q)==SQLITE_ROW,"read fixture row");auto value=J::parse(reinterpret_cast<const char*>(sqlite3_column_text(q,0)));
sqlite3_finalize(q);sqlite3_close(conn);return value;
}
};
static J owned(int pets,int value=1){J a=J::array();for(int i=0;i<pets;i++)a.push_back(value);return a;}
static J cleared(int stages,int value=1){J a=J::array();for(int i=0;i<stages;i++)a.push_back(value);return a;}
static J cleared_prefix(int stages,int count){J a=cleared(stages,0);for(int i=0;i<count;i++)a[i]=1;return a;}
static J shards_at(int pets,int pet,int value){J a=J::array();for(int i=0;i<pets;i++)a.push_back(i==pet?value:0);return a;}
// Captured before battleRules=2. Never regenerate historical pending results using new rules.
static J classic_case(int index) {
std::ifstream file(std::filesystem::path(GAME_TEST_FIXTURE_DIR)/"classic_battles.json");
return J::parse(file)["entries"].at(index);
}
static void freeze_classic(J& stored,int index) {
const auto entry=classic_case(index);
stored["battleSnapshot"]=entry["snapshot"];
stored["battleData"]=entry["result"];
}
int main(int argc,char** argv) {
J parity={{"entries",J::array()}};
auto root=std::filesystem::temp_directory_path()/ ("aigame-g1-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(root);
try {
{
Fixture f((root/"test.db").string());
check(f.state["balls"]==20&&f.state["coins"]==0,"fresh G1 wallet");
check(f.db.game_request("wrong").status==401,"invalid token");
check(f.db.game_request(f.token,"{}").status==400,"malformed command");
auto fail=f.command("train");check(!f.send(fail)["ok"].get<bool>(),"insufficient candy refused");
auto first=f.command("shot.begin",{{"aim",5}});auto shot=f.send(first);
check(shot["ok"]&&f.state["balls"]==19,"one ball charged");
check(shot["shot"]["path"].back()["y"].get<float>()<=.035f,"server trajectory lands");
auto replay=f.send(first);check(replay["replayed"]&&f.state["balls"]==19,"lost response replay no double debit");
auto conflict=first;conflict["aim"]=10;check(f.db.game_request(f.token,conflict.dump(),f.now).status==409,"id cannot change payload");
check(!f.send(f.command("shot.begin",{{"aim",0}}))["ok"].get<bool>(),"one pending activity");
check(f.send(f.command("shot.finish",{{"activityId",first["requestId"]}}))["error"]=="activity_not_ready","early settlement refused");
f.now=shot["shot"]["readyAt"].get<int64_t>();auto finish=f.command("shot.finish",{{"activityId",first["requestId"]},{"coins",999999}});
check(f.send(finish)["ok"],"settle server reward");
int candy=shot["shot"]["candy"],coins=shot["shot"]["coins"];
check(f.state["candy"]==candy&&f.state["coins"]==coins&&f.state["tickets"]==1,"ignores forged currency");
auto before=f.state;f.send(finish);check(f.state==before,"settlement replay stable");
check(f.send(f.command("capture"))["result"]["pet"]==2,"first capture new pet");
check(f.state["stages"][2]==1&&f.state["tickets"]==0,"capture persisted");
f.now+=600;f.read();check(f.state["balls"]==20,"server clock recovery");
f.now-=1200;f.read();check(f.state["balls"]==20,"clock rollback no reward");f.now+=1200;
// Invalid ids/types/slots never change wallet or formation.
before=f.state;check(!f.send(f.command("deploy",{{"pet",0},{"slot",99}}))["ok"].get<bool>(),"locked slot rejected");check(f.state==before,"invalid command atomic");
f.send(f.command("deploy",{{"pet",1},{"slot",0}}));check(f.state["formation"][0]==1&&f.state["formation"][1]==0,"formation swaps without duplicates");f.send(f.command("autoDeploy"));
auto start=f.command("battle.begin");auto b=f.send(start);check(b["ok"],"battle start");
check(!f.send(f.command("train"))["ok"].get<bool>(),"battle snapshot frozen against growth edits");
check(f.send(f.command("battle.finish",{{"activityId",start["requestId"]},{"won",true}}))["error"]=="activity_not_ready","forged early victory blocked");
f.now=b["battle"]["readyAt"].get<int64_t>();before=f.state;
auto end=f.command("battle.finish",{{"activityId",start["requestId"]},{"won",false}});auto result=f.send(end);
check(result["result"]["won"]&&result["result"]["firstClear"],"server decides victory");
check(f.state["coins"].get<int>()==before["coins"].get<int>()+80&&f.state["candy"].get<int>()==before["candy"].get<int>()+40&&f.state["balls"].get<int>()==before["balls"].get<int>()+3,"first clear exact reward");
before=f.state;f.send(end);check(f.state==before,"battle settlement idempotent");
auto claim=f.command("goal.claim");f.send(claim);int balls=f.state["balls"];f.send(claim);check(f.state["balls"]==balls,"goal replay no double reward");
check(!f.send(f.command("goal.claim"))["ok"].get<bool>(),"goal new id still once only");
// Replay a refused command after funds become available: preserve the refusal and latest state.
before=f.state;check(!f.send(fail)["ok"].get<bool>()&&f.state==before,"refusal is durable, snapshot remains current");
// Concurrent duplicates share one transaction. No global SQLite connection interleaving.
auto train=f.command("train");std::atomic<int> accepted{0};std::vector<std::thread> threads;
for(int i=0;i<16;i++) threads.emplace_back([&]{auto r=f.db.game_request(f.token,train.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"]) accepted++;});
for(auto& t:threads)t.join();f.read();check(accepted==16&&f.state["level"]==2,"concurrent replay one training cost");
// A ledger write error rolls back the wallet and pending shot together.
f.sql("CREATE TRIGGER fail_ledger BEFORE INSERT ON game_operations BEGIN SELECT RAISE(ABORT,'test'); END");
before=f.state;auto atomic=f.command("shot.begin",{{"aim",0}});
check(f.db.game_request(f.token,atomic.dump(),f.now).status==500,"storage failure reported");f.read();check(f.state==before,"rollback wallet and pending state");
f.sql("DROP TRIGGER fail_ledger");check(f.send(atomic)["ok"],"same id succeeds after disk repair");
auto snapshot=f.read();f.now=snapshot["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",atomic["requestId"]}}));
// Exercise every permitted aim using the actual server board.
for(int aim=-35;aim<=35;aim++) {
auto next=f.state;next["balls"]=30;next["ballRecoveryUnix"]=f.now;f.replace(next);
auto op=f.command("shot.begin",{{"aim",aim}});auto r=f.send(op);
auto path=r["shot"]["path"];check(path.size()>1&&path.back()["y"].get<float>()<=.035f,"all angles land");
check(r["shot"]["bin"]==std::clamp(static_cast<int>(path.back()["x"].get<float>()*5),0,4),"reward bin follows trajectory");
f.now=r["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",op["requestId"]}}));
}
auto next=f.state;next["level"]=5;next["coins"]=500;next["shards"]=shards_at(static_cast<int>(f.read()["config"]["pets"].size()),0,40);f.replace(next);
check(f.send(f.command("evolve",{{"pet",0}}))["ok"]&&f.state["stages"][0]==2&&f.state["coins"]==0,"evolution exact debit");
next=f.state;next["formation"]={2,-1,-1,-1,-1,-1};next["cleared"]=false;f.replace(next);
start=f.command("battle.begin");b=f.send(start);f.now=b["battle"]["readyAt"].get<int64_t>();before=f.state;
result=f.send(f.command("battle.finish",{{"activityId",start["requestId"]},{"won",true}}));
check(!result["result"]["won"].get<bool>()&&f.state["coins"]==before["coins"]&&!f.state["cleared"].get<bool>(),"forged win on losing formation earns nothing");
start=f.command("battle.begin");f.send(start);check(!f.send(f.command("battle.abandon",{{"activityId",start["requestId"]}}))["result"]["won"].get<bool>(),"abandon never wins");
// Persistence across a fresh SQLite connection, including the ledger.
db::Database reopened(f.path);auto r=J::parse(reopened.game_request(f.token,end.dump(),f.now).body);
check(r["replayed"]&&r["state"]==f.state,"process restart preserves operations and latest state");
}
{
Fixture f((root/"progression.db").string());
auto cfg=f.read()["config"];check(cfg["stages"].size()==20&&cfg["pets"].size()==10&&cfg["version"]==f.read()["configVersion"],"single versioned config");
check(gameplay::element_multiplier(1,2)==1.25f&&gameplay::element_multiplier(2,1)==.8f&&gameplay::element_multiplier(0,1)==1,"element ring");
auto before=f.state;
check(f.send(f.command("stage.select",{{"stageId",5}}))["error"]=="stage_locked"&&f.state==before,"locked selection cannot mutate state");
check(f.send(f.command("battle.begin",{{"stageId",2}}))["error"]=="stage_locked","forged locked battle rejected");
check(f.send(f.command("stage.select",{{"stageId",6}}))["error"]=="stage_locked","next chapter stage stays locked until prior clear");
check(f.send(f.command("stage.select",{{"stageId",21}}))["error"]=="invalid_stage","unknown stage rejected");
for(int id=1;id<=20;id++) {
auto next=f.state;next["level"]=cfg["stages"][id-1]["recommendedLevel"];next["stages"]=owned(static_cast<int>(cfg["pets"].size()));f.replace(next);
check(f.send(f.command("stage.select",{{"stageId",id}}))["ok"],"sequential stage unlocked");
check(f.send(f.command("autoDeploy"))["ok"],"recommended party covers unlocked lanes");
auto start=f.command("battle.begin");auto b=f.send(start);
check(b["battle"]["snapshot"]["stageId"]==id&&b["battle"]["snapshot"]["config"]==cfg,"battle freezes exact config and stage");
before=f.state;f.now=b["battle"]["readyAt"].get<int64_t>();
auto finish=f.command("battle.finish",{{"activityId",start["requestId"]}});auto result=f.send(finish);
check(result["result"]["won"],"configured stage wins at recommended level");
parity["entries"].push_back({{"snapshot",b["battle"]["snapshot"]},{"result",result["result"]}});
check(result["result"]["firstClear"]&&f.state["clearedStages"][id-1]==1,"per-stage clear recorded");
for(const char* key:{"coins","candy","balls"}) check(f.state[key].get<int>()==before[key].get<int>()+cfg["stages"][id-1]["reward"][key].get<int>(),"configured first-clear reward exact");
before=f.state;f.send(finish);check(f.state==before,"per-stage settlement replay once only");
// A fresh repeated battle still receives no first-clear reward.
start=f.command("battle.begin");b=f.send(start);f.now=b["battle"]["readyAt"].get<int64_t>();before=f.state;
result=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(result["result"]["won"]&&!result["result"]["firstClear"].get<bool>()&&f.state["coins"]==before["coins"]&&f.state["candy"]==before["candy"]&&f.state["balls"]==before["balls"],"repeat victory grants nothing");
}
db::Database reopened(f.path);check(J::parse(reopened.game_request(f.token,"",f.now).body)["state"]==f.state,"all stage progress survives reconnect");
for(int id:{1,5,10,15,20}) for(int level:{1,5,20}) for(int party=0;party<6;party++) {
auto next=f.state;next["level"]=level;next["stages"]=owned(static_cast<int>(cfg["pets"].size()));
next["formation"]=party==0?J({0,1,-1,-1,-1,-1}):party==1?J({0,2,-1,-1,-1,-1}):party==2?J({2,1,-1,-1,-1,-1}):party==3?J({2,-1,-1,-1,-1,-1}):party==4?J({4,3,-1,-1,-1,-1}):J({4,2,-1,-1,-1,-1});f.replace(next);
auto start=f.command("battle.begin",{{"stageId",id}});auto b=f.send(start);f.now=b["battle"]["readyAt"].get<int64_t>();
auto result=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
parity["entries"].push_back({{"snapshot",b["battle"]["snapshot"]},{"result",result["result"]}});
}
}
{
Fixture f((root/"lanes.db").string());auto cfg=f.read()["config"];
auto s=f.stored();s["stages"]=owned(static_cast<int>(cfg["pets"].size()));s["clearedStages"]=cleared(static_cast<int>(cfg["stages"].size()));f.replace(s);
auto before=f.state;
check(f.send(f.command("deploy",{{"pet",2},{"slot",2}}))["error"]=="invalid_formation"&&f.state==before,"single-lane stage rejects side deployment atomically");
f.send(f.command("stage.select",{{"stageId",3}}));
check(f.send(f.command("deploy",{{"pet",2},{"slot",2}}))["ok"],"second lane unlocked on stage3");
auto move=f.command("deploy",{{"pet",1},{"slot",2}});f.send(move);
check(f.state["formation"]==J({0,2,1,-1,-1,-1}),"cross-lane exchange preserves unique pets");
before=f.state;check(f.send(move)["replayed"]&&f.state==before,"cross-lane exchange replay never swaps back");
check(f.send(f.command("deploy",{{"pet",3},{"slot",4}}))["error"]=="invalid_formation","third lane remains locked on stage3");
auto remove=f.command("undeploy",{{"slot",2}});f.send(remove);before=f.state;
check(f.state["formation"][2]==-1&&f.send(remove)["replayed"]&&f.state==before,"undeploy replay once only");
check(f.send(f.command("undeploy",{{"slot",2}}))["error"]=="invalid_formation","empty slot removal refused");
f.sql("CREATE TRIGGER fail_lanes BEFORE INSERT ON game_operations BEGIN SELECT RAISE(ABORT,'test'); END");
auto atomic=f.command("deploy",{{"pet",4},{"slot",3}});before=f.state;
check(f.db.game_request(f.token,atomic.dump(),f.now).status==500,"deployment ledger failure reported");f.read();check(f.state==before,"deployment and ledger roll back together");
f.sql("DROP TRIGGER fail_lanes");check(f.send(atomic)["ok"],"deployment same id recovers after storage repair");
bool cast[10]={};float healing=0,absorbed=0;
for(int id:{1,3,5,10,15,20})for(int tier:{1,2})for(int level:{1,5,20})for(int layout=0;layout<3;layout++) {
s=f.stored();s["selectedStage"]=id;s["level"]=level;s["stages"]=owned(static_cast<int>(cfg["pets"].size()),tier);s["formation"]={-1,-1,-1,-1,-1,-1};f.replace(s);
f.send(f.command("autoDeploy"));
if(layout==1) {s=f.stored();std::swap(s["formation"][0],s["formation"][1]);f.replace(s);}
if(layout==2) {s=f.stored();s["formation"]={2,-1,-1,-1,-1,-1};f.replace(s);}
auto start=f.command("battle.begin");auto b=f.send(start);check(b["ok"],"lane matrix battle starts");
auto frozen=b["battle"]["snapshot"];before=f.state;
check(f.send(f.command("undeploy",{{"slot",0}}))["error"]=="pending_activity"&&f.state==before,"cannot edit frozen active party");
f.now=b["battle"]["readyAt"].get<int64_t>();auto finish=f.command("battle.finish",{{"activityId",start["requestId"]}});auto r=f.send(finish);
parity["entries"].push_back({{"snapshot",frozen},{"result",r["result"]}});
check(r["result"]["leaks"].size()==3,"each lane has a leak counter");
for(const auto& stat:r["result"]["stats"]) {int pet=stat["pet"];cast[pet]=cast[pet]||stat["skills"].get<int>()>0;healing+=stat["healing"].get<float>();absorbed+=stat["absorbed"].get<float>();}
if(layout==2&&id>=3)check(r["result"]["leaks"][1].get<int>()>0,"undefended side lane records breakthroughs");
before=f.state;check(f.send(finish)["replayed"]&&f.state==before,"lane settlement retry preserves wallet and stats");
}
for(int i=0;i<5;i++)check(cast[i],"each of the first five abilities casts in matrix");
// The five later partners never appear in the fixed matrices above; cast each one directly.
for(int pet=5;pet<static_cast<int>(cfg["pets"].size());pet++) {
s=f.stored();s["selectedStage"]=5;s["level"]=10;s["stages"]=owned(static_cast<int>(cfg["pets"].size()));s["formation"]={pet,-1,-1,-1,-1,-1};f.replace(s);
auto solo=f.command("battle.begin");auto b=f.send(solo);check(b["ok"],"later-partner battle starts");
f.now=b["battle"]["readyAt"].get<int64_t>();auto r=f.send(f.command("battle.finish",{{"activityId",solo["requestId"]}}));
int casts=0;for(const auto& stat:r["result"]["stats"]) if(stat["pet"].get<int>()==pet) casts=stat["skills"].get<int>();
check(casts>0,"later partner casts its ability");
parity["entries"].push_back({{"snapshot",b["battle"]["snapshot"]},{"result",r["result"]}});
}
// A focused low-level frontline/support run must expose the stateful effects in the receipt.
s=f.stored();s["selectedStage"]=5;s["level"]=1;s["stages"]=owned(static_cast<int>(cfg["pets"].size()));s["formation"]={0,2,-1,-1,-1,-1};f.replace(s);
auto effectStart=f.command("battle.begin");auto effect=f.send(effectStart);check(effect["ok"],"effect battle starts");f.now=effect["battle"]["readyAt"].get<int64_t>();auto effectResult=f.send(f.command("battle.finish",{{"activityId",effectStart["requestId"]}}));
float effectHealing=0,effectAbsorbed=0;for(const auto& stat:effectResult["result"]["stats"]) {effectHealing+=stat["healing"].get<float>();effectAbsorbed+=stat["absorbed"].get<float>();}
check(effectHealing>0||effectAbsorbed>0,"shield or healing has measurable combat effect");
}
{
Fixture f((root/"capture.db").string());auto view=f.read();
check(view["capture"]["tutorial"]&&view["capture"]["entries"][2]["probability"]==1.,"tutorial odds disclosed");
auto next=f.state;next["tickets"]=30;f.replace(next);
const int pets=static_cast<int>(f.read()["config"]["pets"].size());
int calls=0;auto rng=[&](uint32_t n){calls++;check(n>0,"random range positive");return 0u;};
auto tutorial=f.command("capture");auto r=f.send(tutorial,rng);
check(r["result"]["pet"]==2&&r["result"]["tutorial"]&&calls==0&&f.state["captureCount"]==0&&f.state["duplicateStreak"]==0,"tutorial independent from random and pity");
auto before=f.state;r=f.send(tutorial,rng);check(r["replayed"]&&f.state==before&&calls==0,"tutorial replay no second debit");
for(int i=0;i<4;i++) {
r=f.send(f.command("capture",{{"pet",4},{"seed",123}}),rng);
check(r["result"]["pet"]==0&&r["result"]["duplicate"]&&f.state["duplicateStreak"]==i+1,"duplicate streak advances, forged selection ignored");
}
check(f.state["shards"][0]==80&&f.state["captureCount"]==4,"duplicates grant exact shards");
int missing=pets-3;
view=f.read();check(view["capture"]["pityReady"]&&view["capture"]["entries"][0]["probability"]==0.&&view["capture"]["entries"][3]["probability"]==1./missing,"ready pity conditions disclosed");
r=f.send(f.command("capture"),[&](uint32_t n){check(n==static_cast<uint32_t>(missing),"pity samples only missing species");return 1u;});
check(r["result"]["pet"]==4&&r["result"]["pity"]&&!r["result"]["duplicate"].get<bool>()&&f.state["duplicateStreak"]==0&&f.state["stages"][4]==1,"pity draw guarantees the next missing species");
auto nearly=f.state;nearly["duplicateStreak"]=4;for(int pet=0;pet<pets;pet++)nearly["stages"][pet]=pet==3?0:1;f.replace(nearly);
r=f.send(f.command("capture"),[](uint32_t n){check(n==1,"last missing species range");return 0u;});
check(r["result"]["pet"]==3&&f.state["stages"][3]==1&&f.state["duplicateStreak"]==0,"last missing species guaranteed");
view=f.read();check(view["capture"]["completed"]&&!view["capture"]["pityReady"].get<bool>(),"complete pool pauses pity");
for(int i=0;i<pets;i++) {
check(view["capture"]["entries"][i]["baseProbability"]==1./pets&&view["capture"]["entries"][i]["probability"]==1./pets,"equal ordinary and effective odds for full pool");
r=f.send(f.command("capture"),[&](uint32_t n){check(n==static_cast<uint32_t>(pets),"ordinary pool range");return static_cast<uint32_t>(i);});
check(r["result"]["pet"]==i&&r["result"]["duplicate"]&&f.state["duplicateStreak"]==0,"all weighted buckets usable after complete");
}
// A lost response replay, including simultaneous retries, cannot redraw or debit twice.
auto draw=f.command("capture");std::atomic<int> rolls{0},ok{0};std::vector<std::thread> threads;int tickets=f.state["tickets"],shards=f.state["shards"][3];
for(int i=0;i<16;i++) threads.emplace_back([&]{auto reply=f.db.game_request(f.token,draw.dump(),f.now,[&](uint32_t){rolls++;return 3u;});if(reply.status==200&&J::parse(reply.body)["result"]["pet"]==3) ok++;});
for(auto& thread:threads) thread.join();f.read();check(ok==16&&rolls==1&&f.state["tickets"]==tickets-1&&f.state["shards"][3]==shards+20,"concurrent retry reuses one draw result");
f.sql("CREATE TRIGGER fail_capture BEFORE INSERT ON game_operations BEGIN SELECT RAISE(ABORT,'test'); END");
before=f.state;auto atomic=f.command("capture");
check(f.db.game_request(f.token,atomic.dump(),f.now,rng).status==500,"capture disk failure reported");f.read();check(f.state==before,"capture rollback includes pity, ticket and shards");
f.sql("DROP TRIGGER fail_capture");check(f.send(atomic,rng)["ok"],"capture retry after storage repair");
next=f.stored();next["tickets"]=0;f.replace(next);before=f.state;int previousCalls=calls;
check(f.send(f.command("capture"),rng)["error"]=="insufficient_tickets"&&f.state==before&&calls==previousCalls,"insufficient ticket never rolls or changes pity");
db::Database reopened(f.path);auto restored=J::parse(reopened.game_request(f.token,"",f.now).body);
check(restored["state"]==f.state&&restored["lastCaptureResult"]["id"]==atomic["requestId"],"capture state and receipt persist across restart");
}
{
Fixture f((root/"g2_migration.db").string());auto old=f.stored();
old["schema"]=2;old["stages"]={1,2,1};old["shards"]={12,25,30};old["formation"]={2,1,-1,-1,-1,-1};
old["clearedStages"]={1,1,0,0,0};old["selectedStage"]=2;old["level"]=5;old["coins"]=123;old["candy"]=456;old["tickets"]=7;
old.erase("captureTutorialDone");old.erase("captureCount");old.erase("duplicateStreak");f.replace(old);
check(f.state["schema"]==9&&f.state["stages"][0]==1&&f.state["stages"][1]==2&&f.state["stages"][2]==1&&f.state["stages"].size()==10&&f.state["shards"][0]==12&&f.state["shards"][1]==25&&f.state["shards"][2]==30&&f.state["shards"].size()==10&&f.state["formation"]==J({2,1,-1,-1,-1,-1}),"G2 migration preserves owned evolutions, shards and formation");
check(f.state["captureTutorialDone"]&&f.state["captureCount"]==0&&f.state["selectedStage"]==2&&f.state["clearedStages"][0]==1&&f.state["clearedStages"][1]==1&&f.state["clearedStages"].size()==20&&f.state["coins"]==123&&f.state["tickets"]==7,"G2 migration preserves progress and suppresses extra tutorial reward");
auto r=f.send(f.command("capture"),[](uint32_t n){check(n==10,"migrated ordinary pool size");return 3u;});
check(r["result"]["pet"]==3&&!r["result"]["tutorial"].get<bool>()&&f.state["captureCount"]==1,"migrated player enters ordinary pool");
check(f.send(f.command("deploy",{{"pet",3},{"slot",1}}))["ok"]&&f.state["formation"][1]==3,"new electric pet deployable");
check(f.send(f.command("deploy",{{"pet",4},{"slot",0}}))["error"]=="invalid_formation","unowned water pet cannot deploy");
auto next=f.stored();next["shards"][3]=40;next["coins"]=500;f.replace(next);
check(f.send(f.command("evolve",{{"pet",3}}))["ok"]&&f.state["stages"][3]==2&&f.state["coins"]==0&&f.state["shards"][3]==0,"new pet evolution debits once");
}
{
Fixture f((root/"g2_pending.db").string());auto start=f.command("battle.begin");auto b=f.send(start);auto old=f.stored();
old["schema"]=2;old["stages"].get_ref<J::array_t&>().resize(3);old["shards"].get_ref<J::array_t&>().resize(3);
old["battleSnapshot"]["stages"].get_ref<J::array_t&>().resize(3);
freeze_classic(old,1);
old["battleSnapshot"]["config"]["pets"].get_ref<J::array_t&>().resize(3);old["battleSnapshot"]["config"].erase("capture");
old["battleSnapshot"]["config"]["version"]="g2-frozen";old["battleData"]["configVersion"]="g2-frozen";
old.erase("captureTutorialDone");old.erase("captureCount");old.erase("duplicateStreak");f.replace(old);
auto migrated=f.read();check(migrated["state"]["stages"].size()==10&&migrated["battle"]["snapshot"]["stages"].size()==3&&migrated["battle"]["snapshot"]["config"]["pets"].size()==3&&migrated["battle"]["snapshot"]["config"]["version"]=="g2-frozen","G2 pending battle preserves exact three-pet config while roster expands");
f.now=b["battle"]["readyAt"].get<int64_t>();auto result=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(result["result"]["won"]&&result["result"]["firstClear"]&&f.state["coins"]==80,"G2 pending settlement preserves old reward");
parity["entries"].push_back({{"snapshot",migrated["battle"]["snapshot"]},{"result",result["result"]}});
}
{
Fixture f((root/"migration.db").string());
auto old=f.state;old["schema"]=1;old["stages"].get_ref<J::array_t&>().resize(3);old["shards"].get_ref<J::array_t&>().resize(3);old["cleared"]=true;old["goalClaimed"]=true;old["coins"]=777;old["candy"]=888;old.erase("selectedStage");old.erase("clearedStages");
f.replace(old);check(f.state["schema"]==9&&f.state["clearedStages"][0]==1&&f.state["clearedStages"].size()==20&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["goalClaimed"],"G1 completed save migrates without reward or reset");
auto next=f.state;next["cleared"]=false;next["clearedStages"]=cleared(static_cast<int>(f.read()["config"]["stages"].size()),0);f.replace(next);
auto start=f.command("battle.begin");auto b=f.send(start);old=f.stored();old["schema"]=1;old["stages"].get_ref<J::array_t&>().resize(3);old["shards"].get_ref<J::array_t&>().resize(3);old["battleSnapshot"]["stages"].get_ref<J::array_t&>().resize(3);old.erase("selectedStage");old.erase("clearedStages");
freeze_classic(old,2);
old["battleSnapshot"].erase("config");old["battleSnapshot"].erase("stageId");old["battleSnapshot"].erase("legacy");
old["battleData"].erase("stageId");old["battleData"].erase("reward");old["battleData"].erase("configVersion");f.replace(old);
auto migrated=f.read();check(migrated["battle"]["snapshot"]["legacy"]&&f.state["pendingBattle"]==start["requestId"],"in-flight G1 battle retained with legacy presentation");
f.now=b["battle"]["readyAt"].get<int64_t>();auto result=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
parity["entries"].push_back({{"snapshot",migrated["battle"]["snapshot"]},{"result",result["result"]}});
check(result["result"]["stageId"]==1&&result["result"]["firstClear"]&&f.state["coins"]==857,"legacy battle settles once at original reward");
next=f.state;next["schema"]=99;
std::string data=next.dump();f.sql("UPDATE game_states SET state='"+data+"' WHERE pid=1");
check(f.db.game_request(f.token,"",f.now).status==500,"unknown future save version rejected without reset");
}
{
Fixture f((root/"camp.db").string());auto cfg=f.read()["config"];
check(f.state["schema"]==9&&f.state["campLayout"]==J({-1,-1,-1})&&f.state["decorations"]==J({0,0,0})&&f.state["chests"]==J({0,0,0}),"fresh camp is empty, no free purchases");
auto before=f.state;
check(f.send(f.command("camp.place",{{"item",0},{"slot",0}}))["error"]=="camp_build_locked","construction gated by actual clear");
check(f.send(f.command("camp.interact",{{"pet",0}}))["error"]=="camp_rest_locked"&&f.state==before,"rest gated without rewards");
auto s=f.stored();s["clearedStages"]=cleared(static_cast<int>(cfg["stages"].size()));s["cleared"]=true;s["coins"]=1000;f.replace(s);
check(f.send(f.command("camp.place",{{"item",3},{"slot",0}}))["error"]=="invalid_decoration","invalid item rejected");
check(f.send(f.command("camp.place",{{"item",0},{"slot",3}}))["error"]=="invalid_camp_slot","invalid slot rejected");
check(f.send(f.command("camp.place",{{"item",0},{"slot","0"}}))["error"]=="invalid_input","string slot rejected");
auto purchase=f.command("camp.place",{{"item",0},{"slot",0},{"coins",0},{"chargedCoins",0}});auto bought=f.send(purchase);
check(bought["ok"]&&bought["result"]["chargedCoins"]==120&&f.state["coins"]==880&&f.state["campLayout"]==J({0,-1,-1})&&f.state["decorations"]==J({1,0,0}),"one confirmed purchase server price and placement");
before=f.state;check(f.send(purchase)["replayed"]&&f.state==before,"purchase lost acknowledgement replay once");
check(f.send(f.command("camp.place",{{"item",1},{"slot",0}}))["error"]=="camp_slot_occupied"&&f.state==before,"occupied placement neither charges nor buys");
auto moved=f.send(f.command("camp.place",{{"item",0},{"slot",2}}));
check(moved["result"]["chargedCoins"]==0&&f.state["campLayout"]==J({-1,-1,0})&&f.state["coins"]==880,"owned decoration moves free and unique");
f.send(f.command("camp.place",{{"item",0},{"slot",2}}));check(f.state["coins"]==880&&f.state["campLayout"]==J({-1,-1,0}),"same placement never repurchases");
f.send(f.command("camp.store",{{"slot",2}}));check(f.state["campLayout"]==J({-1,-1,-1})&&f.state["decorations"][0]==1&&f.state["coins"]==880,"store retains ownership without refund or fee");
check(f.send(f.command("camp.store",{{"slot",2}}))["error"]=="camp_slot_empty","empty store refused");
f.send(f.command("camp.place",{{"item",0},{"slot",1}}));check(f.state["coins"]==880,"warehouse placement free");
check(f.send(f.command("camp.interact",{{"pet",4}}))["error"]=="invalid_pet","unowned rest partner refused");
before=f.state;auto greet=f.command("camp.interact",{{"pet",1}});check(f.send(greet)["ok"]&&f.state["restPet"]==1&&f.state["campInteractions"]==1,"owned partner interaction persists");
f.send(greet);check(f.state["campInteractions"]==1&&f.state["coins"]==before["coins"]&&f.state["balls"]==before["balls"]&&f.state["tickets"]==before["tickets"]&&f.state["candy"]==before["candy"],"interaction replay has no resources or repeated event");
s=f.stored();s["coins"]=239;f.replace(s);before=f.state;
check(f.send(f.command("camp.place",{{"item",2},{"slot",0}}))["error"]=="insufficient_coins"&&f.state==before,"insufficient purchase entirely unchanged");
auto stale=f.command("camp.place",{{"item",1},{"slot",0}});stale["revision"]=0;check(f.send(stale)["error"]=="stale_revision","stale preview cannot buy");
auto pending=f.command("shot.begin",{{"aim",0}});auto shot=f.send(pending);before=f.state;
check(f.send(f.command("camp.place",{{"item",1},{"slot",0}}))["error"]=="pending_activity"&&f.state==before,"pending activity prevents camp edit");
f.now=shot["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",pending["requestId"]}}));
db::Database reopened(f.path);check(reopened.open_ok(),"camp reopens");auto persisted=J::parse(reopened.game_request(f.token,"",f.now).body);
check(persisted["state"]["campLayout"]==J({-1,0,-1})&&persisted["state"]["restPet"]==1&&persisted["state"]["campInteractions"]==1,"layout inventory and interaction survive connection restart");
}
{
Fixture f((root/"camp_retry.db").string());auto cfg=f.read()["config"];auto s=f.stored();s["coins"]=1000;s["clearedStages"]=cleared(static_cast<int>(cfg["stages"].size()));s["cleared"]=true;f.replace(s);
auto purchase=f.command("camp.place",{{"item",2},{"slot",2}});std::vector<std::thread> requests;std::atomic<int> success{0};
for(int i=0;i<16;i++) requests.emplace_back([&]{auto r=f.db.game_request(f.token,purchase.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"]) success++;});
for(auto& thread:requests) thread.join();f.read();check(success==16&&f.state["coins"]==760&&f.state["decorations"]==J({0,0,1})&&f.state["campLayout"]==J({-1,-1,2}),"16 purchase retries debit once");
auto buy=f.command("camp.place",{{"item",0},{"slot",0}});auto before=f.state;
f.sql("CREATE TRIGGER fail_camp BEFORE INSERT ON game_operations BEGIN SELECT RAISE(FAIL,'camp storage failure'); END");
check(f.db.game_request(f.token,buy.dump(),f.now).status==500,"camp ledger failure surfaced");f.read();check(f.state==before,"camp wallet ownership layout rollback together");
f.sql("DROP TRIGGER fail_camp");check(f.send(buy)["ok"]&&f.state["coins"]==640,"original purchase succeeds once after repair");
auto old=f.stored();old["schema"]=3;old.erase("campLayout");old.erase("decorations");old.erase("restPet");old.erase("campInteractions");
old["captureCount"]=12;old["duplicateStreak"]=3;old["tickets"]=9;old["shards"][0]=77;f.replace(old);
check(f.state["schema"]==9&&f.state["coins"]==640&&f.state["tickets"]==9&&f.state["shards"][0]==77&&f.state["stages"].size()==10&&f.state["captureCount"]==12&&f.state["duplicateStreak"]==3&&f.state["campLayout"]==J({-1,-1,-1}),"schema3 migration preserves collection wallet and initializes empty camp");
auto beforeRead=f.state;f.read();check(f.state==beforeRead,"camp reads grant no extra production");
f.now+=600;f.read();check(f.state["balls"]==21,"camp uses existing single recovery source");
}
{
Fixture f((root/"daily.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];int64_t midnight=(static_cast<int64_t>(day)+1)*86400-28800;
check(f.state["schema"]==9&&f.state["signinDays"]==1&&f.state["dailyProgress"]==J({0,0,0})&&f.state["stages"].size()==10,"first server day unlocks day1 without fabricated events");
auto before=f.state;f.read();check(f.state==before,"opening pages/reconnecting grants no task progress or login day");
check(f.send(f.command("daily.claim",{{"item",0},{"day",day},{"progress",999}}))["error"]=="task_incomplete","client cannot fabricate task progress");
check(f.send(f.command("signin.claim",{{"item",6},{"loginDays",7}}))["error"]=="signin_locked","client cannot unlock future sign-in");
check(f.send(f.command("daily.claim",{{"item",3},{"day",day}}))["error"]=="invalid_task","invalid task refused");
check(f.send(f.command("signin.claim",{{"item","0"}}))["error"]=="invalid_input","wrong sign-in type refused");
auto sign=f.command("signin.claim",{{"item",0},{"balls",999}});auto claimed=f.send(sign);
check(claimed["ok"]&&claimed["result"]["reward"]["balls"]==10&&f.state["balls"]==30&&f.state["signinClaimed"][0]==1,"first login reward uses config once");
before=f.state;f.send(sign);check(f.state==before,"sign-in lost ack replay stable");
check(f.send(f.command("signin.claim",{{"item",0}}))["error"]=="reward_claimed","new ID cannot reclaim sign-in");
auto s=f.stored();s["dailyProgress"][0]=2;f.replace(s);f.now=midnight-1;
auto shot=f.command("shot.begin",{{"aim",0}});auto launched=f.send(shot);check(f.state["dailyProgress"][0]==2,"launch start alone not a completed event");
f.now=launched["shot"]["readyAt"].get<int64_t>();auto finish=f.command("shot.finish",{{"activityId",shot["requestId"]}});f.send(finish);
check(f.state["dailyDay"]==day+1&&f.state["dailyProgress"]==J({1,0,0})&&f.state["signinDays"]==2&&f.state["signinClaimed"][0]==1,"cross-midnight shot counts only on settlement day; sign rewards retained");
before=f.state;f.send(finish);check(f.state==before,"shot replay cannot increment daily task twice");
check(f.send(f.command("daily.claim",{{"item",0},{"day",day}}))["error"]=="daily_expired","previous-day claim cannot consume new task");
for(int i=0;i<2;i++){auto op=f.command("shot.begin",{{"aim",0}});auto r=f.send(op);f.now=r["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",op["requestId"]}}));}
check(f.state["dailyProgress"][0]==3,"three confirmed shots make task claimable");
auto claim=f.command("daily.claim",{{"item",0},{"day",day+1}});int balls=f.state["balls"];std::vector<std::thread> retries;std::atomic<int> ok{0};
for(int i=0;i<16;i++) retries.emplace_back([&]{auto r=f.db.game_request(f.token,claim.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"]) ok++;});for(auto& t:retries)t.join();f.read();
check(ok==16&&f.state["balls"]==balls+5&&f.state["balls"].get<int>()>30&&f.state["dailyClaimed"][0]==1,"16 task retries award five once, rewards may exceed recovery cap");
check(f.send(f.command("daily.claim",{{"item",0},{"day",day+1}}))["error"]=="reward_claimed","new claim ID cannot repeat daily reward");
balls=f.state["balls"];f.now=midnight+86400;auto replay=f.send(claim);
check(replay["replayed"]&&replay["result"]["reward"]["balls"]==5&&f.state["balls"]==balls&&f.state["dailyClaimed"]==J({0,0,0})&&f.state["dailyProgress"]==J({0,0,0})&&f.state["signinDays"]==3,"next-day old request returns original receipt without regrant or new-day flags");
auto future=f.state;f.now-=2*86400;f.read();check(f.state==future,"server clock rollback cannot reset day or advance login");
f.now+=10*86400;f.read();check(f.state["signinDays"]==4&&f.state["signinClaimed"][0]==1,"missed calendar days unlock one login day, no reset or paid catch-up");
while(f.state["signinDays"].get<int>()<7){f.now+=86400;f.read();}
balls=f.state["balls"];int expected=0;for(int i=1;i<7;i++){expected+=gameplay::config()["daily"]["signin"][i]["balls"].get<int>();check(f.send(f.command("signin.claim",{{"item",i}}))["ok"],"earned unclaimed sign-in remains available");}
check(f.state["balls"]==balls+expected&&f.state["signinClaimed"]==J({1,1,1,1,1,1,1}),"seven rewards granted once per index");
f.now+=30*86400;f.read();check(f.state["signinDays"]==7&&f.state["signinClaimed"]==J({1,1,1,1,1,1,1}),"completed seven-day course never restarts");
s=f.stored();s["cleared"]=true;s["clearedStages"]=cleared_prefix(static_cast<int>(cfg["stages"].size()),3);s["stages"][2]=1;s["formation"]={2,-1,-1,-1,-1,-1};f.replace(s);
auto greet=f.command("camp.interact",{{"pet",0}});f.send(greet);f.send(greet);check(f.state["dailyProgress"][2]==1,"real interaction counts once");
auto begin=f.command("battle.begin");auto fight=f.send(begin);f.send(f.command("battle.abandon",{{"activityId",begin["requestId"]}}));check(f.state["dailyProgress"][1]==0,"abandoned battle not a daily completion");
begin=f.command("battle.begin");fight=f.send(begin);f.now=fight["battle"]["readyAt"].get<int64_t>();auto end=f.command("battle.finish",{{"activityId",begin["requestId"]}});auto ended=f.send(end);
check(!ended["result"]["won"].get<bool>()&&f.state["dailyProgress"][1]==1,"normally finished defeat also completes daily adventure");
before=f.state;f.send(end);check(f.state==before,"battle replay never repeats daily completion");
db::Database reopened(f.path);check(reopened.open_ok(),"daily database reopens");auto persisted=J::parse(reopened.game_request(f.token,"",f.now).body);
check(persisted["state"]["dailyProgress"]==f.state["dailyProgress"]&&persisted["state"]["signinClaimed"]==f.state["signinClaimed"],"daily and sign-in state survives reconnection");
}
{
Fixture f((root/"daily_atomic.db").string());auto s=f.stored();s["dailyProgress"][0]=3;f.replace(s);auto before=f.state;
auto claim=f.command("daily.claim",{{"item",0},{"day",f.state["dailyDay"]}});
f.sql("CREATE TRIGGER fail_daily BEFORE INSERT ON game_operations BEGIN SELECT RAISE(FAIL,'daily disk failure'); END");
check(f.db.game_request(f.token,claim.dump(),f.now).status==500,"daily ledger failure returned");f.read();check(f.state==before,"daily reward and claim flag rollback together");
f.sql("DROP TRIGGER fail_daily");check(f.send(claim)["ok"]&&f.state["balls"]==25&&f.state["dailyClaimed"][0]==1,"same request recovers once after disk repair");
auto sign=f.command("signin.claim",{{"item",0}});std::vector<std::thread> retries;std::atomic<int> ok{0};
for(int i=0;i<16;i++)retries.emplace_back([&]{auto r=f.db.game_request(f.token,sign.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"])ok++;});for(auto& t:retries)t.join();f.read();check(ok==16&&f.state["balls"]==35,"16 sign-in retries award ten once");
auto start=f.command("shot.begin",{{"aim",0}});auto launched=f.send(start);auto old=f.stored();old["schema"]=4;old["shots"]=100;old["coins"]=777;old["campInteractions"]=12;old["decorations"]={1,0,0};old["campLayout"]={-1,0,-1};
for(const char* key:{"dailyDay","dailyProgress","dailyClaimed","signinDays","signinClaimed"})old.erase(key);f.replace(old);
check(f.state["schema"]==9&&f.state["coins"]==777&&f.state["campLayout"]==J({-1,0,-1})&&f.state["campInteractions"]==12&&f.state["shots"]==100&&f.state["dailyProgress"]==J({0,0,0})&&f.state["signinDays"]==1&&f.state["signinClaimed"]==J({0,0,0,0,0,0,0})&&f.state["pendingShot"]==start["requestId"],"schema4 migration preserves wallet camp and pending shot; no retroactive daily progress or sign-in award");
f.now=launched["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",start["requestId"]}}));check(f.state["dailyProgress"][0]==1,"pre-migration pending shot counts once when actually settled");
}
{
Fixture f((root/"shop.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];
check(f.state["schema"]==9&&f.state["shopBought"]==J({0,0,0})&&f.state["balls"]==20&&f.state["chests"]==J({0,0,0}),"fresh shop stock has no automatic supply award");
auto before=f.state;f.read();check(f.state==before,"opening shop does not consume stock or grant inventory");
auto free=f.command("shop.buy",{{"item",0},{"day",day},{"balls",999}});auto r=f.send(free);
check(r["ok"]&&r["result"]["chargedCoins"]==0&&r["result"]["reward"]["balls"]==5&&f.state["balls"]==25&&f.state["shopBought"]==J({1,0,0}),"free supply grants configured five once");
before=f.state;f.send(free);check(f.state==before,"free supply lost-ack replay stable");
check(f.send(f.command("shop.buy",{{"item",0},{"day",day}}))["error"]=="shop_limit"&&f.state==before,"fresh request cannot repeat free supply");
check(f.send(f.command("shop.buy",{{"item",1},{"day",day}}))["error"]=="goods_locked","sugar requires actual stage1 clear");
check(f.send(f.command("shop.buy",{{"item",2},{"day",day},{"stageId",5}}))["error"]=="goods_locked","ticket cannot forge chapter gate");
check(f.send(f.command("shop.buy",{{"item",3},{"day",day}}))["error"]=="invalid_goods","unknown goods refused");
check(f.send(f.command("shop.buy",{{"item","0"},{"day",day}}))["error"]=="invalid_input","wrong goods type refused");
auto s=f.stored();s["coins"]=1000;s["cleared"]=true;s["clearedStages"]=cleared_prefix(static_cast<int>(cfg["stages"].size()),1);f.replace(s);
auto buy=f.command("shop.buy",{{"item",1},{"day",day},{"coins",0},{"reward",{{"candy",999}}}});r=f.send(buy);
check(r["ok"]&&r["result"]["chargedCoins"]==100&&f.state["coins"]==900&&f.state["candy"]==20&&f.state["shopBought"][1]==1,"gold goods use server price reward and stock");
before=f.state;f.send(buy);check(f.state==before,"gold purchase replay does not duplicate candy");
for(int i=0;i<2;i++) check(f.send(f.command("shop.buy",{{"item",1},{"day",day}}))["ok"],"remaining sugar stock purchasable");
before=f.state;check(f.state["coins"]==700&&f.state["candy"]==60&&f.send(f.command("shop.buy",{{"item",1},{"day",day}}))["error"]=="shop_limit"&&f.state==before,"three purchases exhaust daily stock exactly");
s=f.stored();s["clearedStages"]=cleared_prefix(static_cast<int>(cfg["stages"].size()),3);f.replace(s);r=f.send(f.command("shop.buy",{{"item",2},{"day",day}}));
check(r["ok"]&&f.state["coins"]==400&&f.state["tickets"]==1&&f.state["shopBought"][2]==1,"ticket purchase updates existing ticket wallet");
f.send(f.command("capture"));check(f.state["tickets"]==0&&f.state["stages"][2]==1,"bought ticket usable in existing capture, no second inventory");
f.send(f.command("train"));check(f.state["candy"]==30&&f.state["level"]==2,"bought candy usable by existing training");
check(f.state["dailyProgress"]==J({0,0,0}),"shop and inventory actions do not fabricate daily gameplay progress");
int balls=f.state["balls"],coins=f.state["coins"],candy=f.state["candy"];f.now=(static_cast<int64_t>(day)+1)*86400-28800;f.read();
check(f.state["shopBought"]==J({0,0,0}),"all shop limits refresh with the shared server day");
r=f.send(buy);check(r["replayed"]&&r["result"]["reward"]["candy"]==20&&f.state["coins"]==coins&&f.state["candy"]==candy&&f.state["shopBought"]==J({0,0,0}),"old purchase replay next day never spends new stock or grants twice");
check(f.send(f.command("shop.buy",{{"item",0},{"day",day}}))["error"]=="shop_expired","old-day new free claim rejected");
s=f.stored();s["balls"]=35;f.replace(s);f.send(f.command("shop.buy",{{"item",0},{"day",day+1}}));check(f.state["balls"]==40,"free reward may exceed natural recovery cap");
before=f.state;f.now-=86400;f.read();check(f.state==before,"clock rollback never refreshes stock");
db::Database reopened(f.path);check(reopened.open_ok(),"shop database reopens");r=J::parse(reopened.game_request(f.token,"",f.now).body);check(r["state"]["shopBought"]==f.state["shopBought"]&&r["state"]["tickets"]==0,"shop limits and shared inventory survive connection restart");
}
{
Fixture f((root/"shop_atomic.db").string());auto cfg=f.read()["config"];auto s=f.stored();s["coins"]=1000;s["cleared"]=true;s["clearedStages"]=cleared_prefix(static_cast<int>(cfg["stages"].size()),3);f.replace(s);int day=f.state["dailyDay"];
auto buy=f.command("shop.buy",{{"item",2},{"day",day}});std::vector<std::thread> retry;std::atomic<int> ok{0};
for(int i=0;i<16;i++)retry.emplace_back([&]{auto r=f.db.game_request(f.token,buy.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"])ok++;});for(auto& t:retry)t.join();f.read();
check(ok==16&&f.state["coins"]==700&&f.state["tickets"]==1&&f.state["shopBought"][2]==1,"16 concurrent ticket retries debit and grant once");
auto free=f.command("shop.buy",{{"item",0},{"day",day}});retry.clear();ok=0;for(int i=0;i<16;i++)retry.emplace_back([&]{auto r=f.db.game_request(f.token,free.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"])ok++;});for(auto& t:retry)t.join();f.read();check(ok==16&&f.state["balls"]==25&&f.state["shopBought"][0]==1,"16 concurrent free retries grant five once");
auto before=f.state;auto sugar=f.command("shop.buy",{{"item",1},{"day",day}});
f.sql("CREATE TRIGGER fail_shop BEFORE INSERT ON game_operations BEGIN SELECT RAISE(FAIL,'shop storage failure'); END");
check(f.db.game_request(f.token,sugar.dump(),f.now).status==500,"shop ledger failure returned");f.read();check(f.state==before,"shop coins reward and limit rollback together");
f.sql("DROP TRIGGER fail_shop");check(f.send(sugar)["ok"]&&f.state["coins"]==600&&f.state["candy"]==20,"same purchase recovers once after repair");
s=f.stored();s["coins"]=99;f.replace(s);before=f.state;check(f.send(f.command("shop.buy",{{"item",1},{"day",day}}))["error"]=="insufficient_coins"&&f.state==before,"insufficient balance neither grants nor consumes stock");
auto stale=f.command("shop.buy",{{"item",1},{"day",day}});stale["revision"]=0;check(f.send(stale)["error"]=="stale_revision","stale confirmation refused");
auto shot=f.command("shot.begin",{{"aim",0}});auto launched=f.send(shot);before=f.state;check(f.send(f.command("shop.buy",{{"item",1},{"day",day}}))["error"]=="pending_activity"&&f.state==before,"pending activity blocks purchase");
f.now=launched["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",shot["requestId"]}}));
auto old=f.stored();old["schema"]=5;old.erase("shopBought");old["dailyClaimed"]={1,0,0};old["dailyProgress"][0]=3;old["decorations"]={1,0,0};old["campLayout"]={-1,0,-1};int coins=f.state["coins"],tickets=f.state["tickets"];f.replace(old);
check(f.state["schema"]==9&&f.state["coins"]==coins&&f.state["tickets"]==tickets&&f.state["dailyClaimed"]==J({1,0,0})&&f.state["shopBought"]==J({0,0,0})&&f.state["campLayout"]==J({-1,0,-1}),"schema5 migration preserves wallets daily and decorations, initializes only shop limits");
}
{
Fixture f((root/"shop_pending.db").string());auto begin=f.command("battle.begin");auto fight=f.send(begin);auto old=f.stored();freeze_classic(old,3);old["schema"]=5;old.erase("shopBought");old["battleSnapshot"]["config"].erase("shop");f.replace(old);
auto migrated=f.read();check(!migrated["battle"]["snapshot"]["config"].contains("shop")&&f.state["pendingBattle"]==begin["requestId"],"in-flight schema5 battle keeps pre-shop frozen config");
f.now=fight["battle"]["readyAt"].get<int64_t>();auto end=f.send(f.command("battle.finish",{{"activityId",begin["requestId"]}}));
check(end["result"]["won"]&&f.state["coins"]==80&&f.state["dailyProgress"][1]==1,"pre-shop frozen battle settles original reward and daily event");
parity["entries"].push_back({{"snapshot",migrated["battle"]["snapshot"]},{"result",end["result"]}});
}
{
Fixture f((root/"mail_single.db").string());
check(f.state["schema"]==9&&f.state["mails"].size()==2&&f.state["balls"]==20&&f.state["candy"]==0&&f.state["chests"]==J({0,0,0}),"schema7 adds letters, no automatic wallet award");
auto id=f.state["mails"][0]["mailId"],notice=f.state["mails"][1]["mailId"];auto reward=f.state["mails"][0]["reward"];
auto before=f.state;auto read=f.command("mail.read",{{"mailId",id}});auto r=f.send(read);
check(r["ok"]&&f.state["mails"][0]["read"]&&!f.state["mails"][0]["claimed"]&&f.state["balls"]==before["balls"],"reading never grants attachment");
before=f.state;check(f.send(read)["replayed"]&&f.state==before,"read replay leaves state unchanged");
check(f.send(f.command("mail.claim",{{"mailId",notice}}))["error"]=="mail_no_attachment","notification cannot grant a reward");
check(f.send(f.command("mail.claim",{{"mailId","missing"}}))["error"]=="mail_not_found","unknown mail refused");
auto claim=f.command("mail.claim",{{"mailId",id},{"reward",{{"coins",999999}}}});r=f.send(claim);
check(r["ok"]&&r["result"]["claimedCount"]==1&&r["result"]["reward"]==reward&&f.state["balls"]==25&&f.state["candy"]==20&&f.state["coins"]==0,"attachment uses saved reward, ignores client reward");
before=f.state;check(f.send(claim)["replayed"]&&f.state==before,"lost claim response replays once");
check(f.send(f.command("mail.claim",{{"mailId",id}}))["error"]=="mail_already_claimed"&&f.state==before,"new request cannot claim same mail twice");
db::Database reopened(f.path);check(reopened.open_ok(),"mail database reopens");
auto persisted=J::parse(reopened.game_request(f.token,claim.dump(),f.now).body);
check(persisted["replayed"]&&persisted["state"]["mails"][0]["claimed"]&&persisted["result"]["reward"]==reward,"receipt and claim survive connection restart");
}
{
Fixture f((root/"mail_atomic.db").string());auto id=f.state["mails"][0]["mailId"];
auto claim=f.command("mail.claim",{{"mailId",id}});auto before=f.state;
f.sql("CREATE TRIGGER fail_mail BEFORE INSERT ON game_operations BEGIN SELECT RAISE(FAIL,'mail ledger failure'); END");
check(f.db.game_request(f.token,claim.dump(),f.now).status==500,"mail operation log failure returned");f.read();
check(f.state==before,"wallet read/claim flags rollback with operation log");f.sql("DROP TRIGGER fail_mail");
std::atomic<int> ok{0};std::vector<std::thread> retries;
for(int i=0;i<16;i++) retries.emplace_back([&]{auto r=f.db.game_request(f.token,claim.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"])ok++;});
for(auto& t:retries)t.join();f.read();
check(ok==16&&f.state["balls"]==25&&f.state["candy"]==20&&f.state["mails"][0]["claimed"],"16 concurrent mail retries grant once");
}
{
Fixture f((root/"mail_batch.db").string());auto s=f.stored();auto base=s["mails"][0];
auto expired=base;expired["mailId"]="expired";expired["expiresAt"]=f.now;expired["sentAt"]=f.now-20;
auto claimed=base;claimed["mailId"]="claimed";claimed["claimed"]=true;claimed["read"]=true;
auto extra=base;extra["mailId"]="extra";extra["reward"]={{"coins",7},{"candy",0},{"balls",0},{"tickets",1}};
s["mails"].push_back(expired);s["mails"].push_back(claimed);s["mails"].push_back(extra);f.replace(s);
auto id=s["mails"][0]["mailId"],notice=s["mails"][1]["mailId"];auto before=f.state;
check(f.send(f.command("mail.claimAll",{{"mailIds",{id,id}}}))["error"]=="invalid_input"&&f.state==before,"duplicate batch ids rejected before grants");
check(f.send(f.command("mail.claimAll",{{"mailIds",J::array()}}))["error"]=="invalid_input","empty batch rejected");
check(f.send(f.command("mail.claimAll",{{"mailIds",J::array({3})}}))["error"]=="invalid_input","wrong batch id type rejected");
J tooMany=J::array();for(int i=0;i<21;i++)tooMany.push_back("x"+std::to_string(i));
check(f.send(f.command("mail.claimAll",{{"mailIds",tooMany}}))["error"]=="invalid_input","batch capacity is twenty");
auto batch=f.command("mail.claimAll",{{"mailIds",{id,notice,"expired","claimed","missing","extra"}}});auto r=f.send(batch);
check(r["ok"]&&r["result"]["claimedCount"]==2&&r["result"]["entries"].size()==6&&f.state["balls"]==25&&f.state["candy"]==20&&f.state["coins"]==7&&f.state["tickets"]==1,"mixed batch grants valid attachments despite invalid entries");
auto e=r["result"]["entries"];check(e[1]["status"]=="no_attachment"&&e[2]["status"]=="expired"&&e[3]["status"]=="already_claimed"&&e[4]["status"]=="not_found","batch returns each skipped reason");
before=f.state;f.now-=10;f.read();check(f.state==before,"clock rollback cannot resurrect expired mail");f.now+=10;
check(f.send(batch)["replayed"]&&f.state==before,"batch replay preserves original reward and counts");
check(f.send(f.command("mail.claim",{{"mailId","expired"}}))["error"]=="mail_expired","expiry boundary prevents single claim");
}
{
Fixture f((root/"mail_batch_atomic.db").string());auto id=f.state["mails"][0]["mailId"];
auto batch=f.command("mail.claimAll",{{"mailIds",{id}}});auto before=f.state;
f.sql("CREATE TRIGGER fail_mail_batch BEFORE INSERT ON game_operations BEGIN SELECT RAISE(FAIL,'mail batch ledger failure'); END");
check(f.db.game_request(f.token,batch.dump(),f.now).status==500,"batch storage failure returned");f.read();check(f.state==before,"batch attachment and flags rollback together");
f.sql("DROP TRIGGER fail_mail_batch");check(f.send(batch)["ok"]&&f.state["balls"]==25,"batch retry after storage repair grants once");
}
{
Fixture f((root/"mail_migration.db").string());auto begin=f.command("battle.begin");auto fight=f.send(begin);auto old=f.stored();
freeze_classic(old,4);old["schema"]=6;old.erase("mails");old["coins"]=777;old["candy"]=888;old["signinClaimed"]={1,0,0,0,0,0,0};old["shopBought"]={1,0,0};old["battleSnapshot"]["config"].erase("mail");f.replace(old);
auto migrated=f.read();check(f.state["schema"]==9&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["balls"]==20&&f.state["mails"].size()==2&&f.state["shopBought"]==J({1,0,0})&&f.state["signinClaimed"]==J({1,0,0,0,0,0,0}),"schema6 migration adds mail only, preserves wallet and daily stock");
check(f.state["pendingBattle"]==begin["requestId"]&&!migrated["battle"]["snapshot"]["config"].contains("mail"),"migration preserves pending battle and pre-mail frozen config");
auto read=f.command("mail.read",{{"mailId",f.state["mails"][0]["mailId"]}});check(f.send(read)["error"]=="pending_activity","mail operations preserve pending activity exclusion");
f.now=fight["battle"]["readyAt"].get<int64_t>();auto end=f.send(f.command("battle.finish",{{"activityId",begin["requestId"]}}));
check(end["result"]["won"]&&f.state["coins"]==857&&f.state["candy"]==928,"pre-mail frozen battle settles original reward");
parity["entries"].push_back({{"snapshot",migrated["battle"]["snapshot"]},{"result",end["result"]}});
auto before=f.state;f.read();check(f.state==before,"reading schema7 does not reissue initial mail");
auto claim=f.command("mail.claim",{{"mailId",f.state["mails"][0]["mailId"]}});auto first=f.send(claim);f.now+=604801;
auto replay=f.send(claim);check(replay["replayed"]&&replay["result"]==first["result"]&&f.state["candy"]==948,"successful receipt survives mail expiry without new attachment");
}
{
Fixture f((root/"chest.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];
check(f.state["schema"]==9&&f.state["chests"]==J({0,0,0})&&f.state["chestDailyClaimed"]==0&&f.state["balls"]==20&&f.state["stages"].size()==10,"fresh chest inventory is empty and grants nothing");
auto before=f.state;f.read();check(f.state==before,"opening the chest page grants no chest");
check(f.send(f.command("chest.open",{{"chest",0}}))["error"]=="chest_missing"&&f.state==before,"opening a missing chest is refused");
check(f.send(f.command("chest.open",{{"chest",3}}))["error"]=="invalid_chest","unknown chest refused");
check(f.send(f.command("chest.open",{{"chest","0"}}))["error"]=="invalid_input","wrong chest type refused");
auto daily=f.command("chest.claimDaily",{{"chest",0}});auto r=f.send(daily);
check(r["ok"]&&r["result"]["chest"]==0&&r["result"]["count"]==1&&f.state["chests"]==J({1,0,0})&&f.state["chestDailyClaimed"]==1&&f.state["coins"]==0&&f.state["candy"]==0&&f.state["balls"]==20,"daily chest adds one box without awarding contents");
before=f.state;f.send(daily);check(f.state==before,"daily chest lost-ack replay adds nothing");
check(f.send(f.command("chest.claimDaily"))["error"]=="chest_claimed"&&f.state==before,"second daily claim refused");
auto open=f.command("chest.open",{{"chest",0},{"coins",999999},{"reward",{{"candy",999}}}});r=f.send(open);
check(r["ok"]&&r["result"]["kind"]=="fixed"&&r["result"]["remaining"]==0&&r["result"]["chestReward"]["coins"]==60&&r["result"]["chestReward"]["candy"]==30&&r["result"]["chestReward"]["balls"]==2,"fixed chest uses configured contents");
check(f.state["coins"]==60&&f.state["candy"]==30&&f.state["balls"]==22&&f.state["chests"]==J({0,0,0}),"fixed chest awards exact wallet once");
before=f.state;check(f.send(open)["replayed"]&&f.state==before,"lost chest response replays without a second award");
check(f.send(f.command("chest.open",{{"chest",0}}))["error"]=="chest_missing","new request cannot open the same box twice");
auto s=f.stored();s["chests"]={0,1,0};f.replace(s);
auto missing=f.send(f.command("chest.open",{{"chest",1}}));check(missing["error"]=="invalid_choice","choice chest requires a valid selection");
check(f.send(f.command("chest.open",{{"chest",1},{"choice",10}}))["error"]=="invalid_choice","out-of-range choice refused");
r=f.send(f.command("chest.open",{{"chest",1},{"choice",2}}));
check(r["ok"]&&r["result"]["choice"]==2&&r["result"]["chestReward"]["shardsPet"]==2&&r["result"]["chestReward"]["shards"]==10,"choice chest awards the selected shard bundle");
check(f.state["shards"][2]==10&&f.state["coins"]==60&&f.state["candy"]==30&&f.state["chests"]==J({0,0,0}),"choice chest touches only the selected partner shards");
// First clear drops the configured box once; repeated victories drop nothing.
s=f.stored();s["clearedStages"]=cleared(static_cast<int>(cfg["stages"].size()),0);s["stages"]=owned(static_cast<int>(cfg["pets"].size()));s["level"]=5;s["formation"]={0,1,-1,-1,-1,-1};f.replace(s);
auto start=f.command("battle.begin",{{"stageId",1}});auto b=f.send(start);f.now=b["battle"]["readyAt"].get<int64_t>();
auto end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(end["result"]["firstClear"]&&end["result"]["chest"]==0&&f.state["chests"][0]==1,"first clear drops the configured chest");
start=f.command("battle.begin",{{"stageId",1}});b=f.send(start);f.now=b["battle"]["readyAt"].get<int64_t>();
end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(end["result"]["won"]&&!end["result"]["firstClear"].get<bool>()&&!end["result"].contains("chest")&&f.state["chests"][0]==1,"repeat victory never drops another chest");
auto stale=f.command("chest.open",{{"chest",0}});stale["revision"]=0;before=f.state;
check(f.send(stale)["error"]=="stale_revision"&&f.state==before,"stale chest confirmation refused");
auto shot=f.command("shot.begin",{{"aim",0}});auto launched=f.send(shot);before=f.state;
check(f.send(f.command("chest.open",{{"chest",0}}))["error"]=="pending_activity"&&f.state==before,"pending activity blocks chest opening");
f.now=launched["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",shot["requestId"]}}));
// Daily chest availability follows the shared server day.
f.now=(static_cast<int64_t>(day)+1)*86400-28800;f.read();check(f.state["chestDailyClaimed"]==0,"daily chest refreshes with the server day");
check(f.send(f.command("chest.claimDaily"))["ok"]&&f.state["chests"][0]==2,"refreshed daily chest adds one box");
f.now-=86400;before=f.state;f.read();check(f.state==before,"clock rollback never refreshes the daily chest");
db::Database reopened(f.path);check(reopened.open_ok(),"chest database reopens");
auto persisted=J::parse(reopened.game_request(f.token,"",f.now).body);
check(persisted["state"]["chests"]==f.state["chests"]&&persisted["rewards"]["dailyClaimed"]==1,"chest inventory survives connection restart");
}
{
Fixture f((root/"chest_atomic.db").string());auto cfg=f.read()["config"];auto s=f.stored();s["chests"]={1,0,0};f.replace(s);
auto open=f.command("chest.open",{{"chest",0}});auto before=f.state;
f.sql("CREATE TRIGGER fail_chest BEFORE INSERT ON game_operations BEGIN SELECT RAISE(FAIL,'chest storage failure'); END");
check(f.db.game_request(f.token,open.dump(),f.now).status==500,"chest ledger failure returned");f.read();
check(f.state==before,"chest inventory and wallet roll back with the operation log");
f.sql("DROP TRIGGER fail_chest");
std::atomic<int> ok{0};std::vector<std::thread> retries;
for(int i=0;i<16;i++) retries.emplace_back([&]{auto r=f.db.game_request(f.token,open.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"])ok++;});
for(auto& t:retries)t.join();f.read();
check(ok==16&&f.state["coins"]==60&&f.state["chests"]==J({0,0,0}),"16 concurrent chest retries award exactly once");
auto daily=f.command("chest.claimDaily");retries.clear();ok=0;
for(int i=0;i<16;i++) retries.emplace_back([&]{auto r=f.db.game_request(f.token,daily.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"])ok++;});
for(auto& t:retries)t.join();f.read();
check(ok==16&&f.state["chests"]==J({1,0,0})&&f.state["chestDailyClaimed"]==1,"16 concurrent daily chest retries add one box");
auto old=f.stored();old["schema"]=7;old.erase("chests");old.erase("chestDailyClaimed");
old["coins"]=777;old["candy"]=888;old["mails"]=old["mails"];f.replace(old);
check(f.state["schema"]==9&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["chests"]==J({0,0,0})&&f.state["chestDailyClaimed"]==0&&f.state["mails"].size()==2&&f.state["stages"].size()==10&&f.state["clearedStages"].size()==20,"schema7 migration preserves wallet and mail, initializes empty chests without free rewards");
}
std::filesystem::remove_all(root);
if(argc>1) {std::ofstream output(argv[1],std::ios::binary);output<<parity.dump(2);check(output.good(),"parity vectors exported");}
std::cout<<"PASS "<<assertions<<" assertions: auth, replay, concurrency, rollback, trajectory, growth, battle, persistence\n";
return 0;
} catch(const std::exception& e) {std::cerr<<"FAIL: "<<e.what()<<" fixture="<<root<<"\n";return 1;}
}