Add prototype gameplay loop with character/battlefield assets and server game APIs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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/真机验收仍未完成。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\"}"};}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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@";
|
||||
@@ -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());
|
||||
|
||||
+2164
File diff suppressed because it is too large
Load Diff
@@ -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;}
|
||||
}
|
||||
Reference in New Issue
Block a user