Files

882 lines
93 KiB
C++

#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&&f.state["storyRankAt"]==f.now,"per-stage clear recorded at the confirmation time");
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"]&&f.state["storyRankAt"]==before["storyRankAt"],"repeat victory grants nothing and keeps the rank time");
}
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"]==14&&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"]==14&&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"]==14&&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"]==14&&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"]==14&&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"]==14&&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"]==14&&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"]==14&&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"]==14&&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"]==14&&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"]==14&&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"]==14&&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");
}
{
Fixture f((root/"challenge.db").string());auto cfg=f.read()["config"];
check(cfg["challenges"].size()==5&&f.state["schema"]==14&&f.state["challengeBadges"]==J({0,0,0,0,0})&&f.state["challengeBestMs"]==J({0,0,0,0,0})&&f.state["storyRankAt"]==0&&f.state["challengeRankAt"]==J({0,0,0,0,0})&&f.state["friendIds"].empty()&&f.state["giftSent"].empty()&&f.state["giftFrom"].empty(),"fresh save has five locked element trials and no badges or friends");
auto locked=f.command("challenge.begin",{{"stageId",1}});auto before=f.state;
check(f.send(locked)["error"]=="challenge_locked"&&f.state==before,"element trial stays closed before 1-1");
auto s=f.stored();s["schema"]=9;s.erase("challengeBadges");s.erase("challengeBestMs");s["shards"][0]=7;s["coins"]=640;f.replace(s);
check(f.state["schema"]==14&&f.state["challengeBadges"]==J({0,0,0,0,0})&&f.state["challengeBestMs"]==J({0,0,0,0,0})&&f.state["storyRankAt"]==0&&f.state["challengeRankAt"]==J({0,0,0,0,0})&&f.state["shards"][0]==7&&f.state["coins"]==640,"schema9 migration adds empty trials without shards or coins");
s=f.stored();s["clearedStages"][0]=1;f.replace(s);before=f.state;auto story=before["formation"];
check(f.send(f.command("challenge.begin",{{"stageId",2}}))["error"]=="need_element"&&f.state==before,"grass trial explains itself by refusing until a grass partner is owned");
auto start=f.command("challenge.begin",{{"stageId",1}});auto began=f.send(start);
check(began["ok"]&&began["battle"]["snapshot"]["challengeId"]==1&&began["battle"]["snapshot"]["formation"]==J({1,-1,-1,-1,-1,-1})&&f.state["formation"]==story&&f.state["pendingBattle"]==start["requestId"],"fire trial deploys only the owned fire partner and leaves the story formation");
check(f.send(f.command("battle.begin"))["error"]=="pending_activity","a running trial blocks a story battle");
auto abandon=f.command("battle.abandon",{{"activityId",start["requestId"]}});
check(f.send(abandon)["result"]["won"]==false&&f.state["challengeBadges"][0]==0&&f.state["shards"][1]==0&&f.state["dailyProgress"][1]==0&&f.state["challengeRankAt"][0]==0,"leaving a trial grants nothing and does not count as a story adventure");
start=f.command("challenge.begin",{{"stageId",1}});began=f.send(start);f.now=began["battle"]["readyAt"].get<int64_t>();
auto end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(end["result"]["won"]&&end["result"]["firstClear"]&&end["result"]["shards"]==10&&end["result"]["pet"]==1&&f.state["shards"][1]==10&&f.state["challengeBadges"][0]==1&&f.state["challengeBestMs"][0].get<int>()>0&&f.state["challengeRankAt"][0]==f.now&&f.state["coins"]==640&&f.state["dailyProgress"][1]==0,"first fire clear pays directed shards once and records the time");
int best=f.state["challengeBestMs"][0];int64_t bestAt=f.state["challengeRankAt"][0];
start=f.command("challenge.begin",{{"stageId",1}});began=f.send(start);f.now=began["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"]["improved"].get<bool>()&&f.state["shards"][1]==10&&f.state["challengeBestMs"][0]==best&&f.state["challengeRankAt"][0]==bestAt,"a repeat clear keeps the badge, the shards and the record");
start=f.command("challenge.begin",{{"stageId",3}});began=f.send(start);
check(began["ok"]&&began["battle"]["snapshot"]["formation"][0]==0,"rock trial accepts the owned rock partner");
f.now=began["battle"]["readyAt"].get<int64_t>();
end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(end["result"]["won"]&&end["result"]["pet"]==0&&f.state["shards"][0]==17,"rock trial is winnable at level 1 and adds shards to the existing seven");
}
{
Fixture f((root/"rank.db").string());
int coins=f.state["coins"];
check(f.state["schema"]==14&&f.state["storyRankAt"]==0&&f.state["balls"]==20&&coins==0,"fresh account is absent from every board");
auto before=f.state;
check(f.send(f.command("rank.list",{{"stageId",9}}))["error"]=="invalid_board"&&f.state==before,"unknown board changes nothing");
check(f.send(f.command("rank.list",{{"stageId",-1}}))["error"]=="invalid_board"&&f.state==before,"negative board changes nothing");
auto list=f.command("rank.list",{{"stageId",0}});
auto empty=f.send(list);
check(empty["ok"]&&empty["rank"]["total"]==0&&empty["rank"]["selfPlace"]==0&&empty["rank"]["entries"].empty()&&f.state["coins"]==coins&&f.state["revision"]==before["revision"],"an empty board is a read and does not move the wallet or revision");
auto replay=f.send(list);
check(replay["replayed"]&&replay["rank"]==empty["rank"],"the same rank request keeps the board it first confirmed");
auto start=f.command("battle.begin");check(f.send(start)["ok"],"battle starts before a rank read");
auto during=f.send(f.command("rank.list",{{"stageId",1}}));
check(during["ok"]&&during["rank"]["total"]==0&&f.state["pendingBattle"]==start["requestId"]&&f.state["coins"]==coins,"reading a board does not interrupt or settle a battle");
check(f.send(f.command("battle.abandon",{{"activityId",start["requestId"]}}))["ok"],"abandon after a rank read");
auto migrated=f.stored();migrated["schema"]=10;migrated.erase("storyRankAt");migrated.erase("challengeRankAt");
migrated["clearedStages"][0]=1;migrated["challengeBestMs"][0]=4321;migrated["coins"]=640;f.replace(migrated);
check(f.state["schema"]==14&&f.state["storyRankAt"]==f.now&&f.state["challengeRankAt"][0]==f.now&&f.state["challengeRankAt"][1]==0&&f.state["coins"]==640&&f.state["shards"][0]==0&&f.state["friendIds"].empty()&&f.state["giftSent"].empty(),"schema10 migration stamps existing scores without paying rewards");
auto paint=[&](int clears,int64_t at,int best,int64_t bestAt){auto s=f.stored();for(int i=0;i<static_cast<int>(s["clearedStages"].size());i++) s["clearedStages"][i]=i<clears?1:0;s["storyRankAt"]=at;s["challengeBestMs"][0]=best;s["challengeRankAt"][0]=best>0?bestAt:0;return s;};
auto write=[&](int64_t pid,J state){
std::string data=state.dump(),escaped;for(char c:data){escaped+=c;if(c=='\'') escaped+='\'';}
f.sql("UPDATE game_states SET state='"+escaped+"' WHERE pid="+std::to_string(pid));
};
auto add=[&](const std::string& name,J state){
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"rank player");
check(f.db.store_token("token-"+name,*pid,1,4102444800LL),"rank token");
check(f.db.game_request("token-"+name,"",f.now).status==200,"rank player save");
write(*pid,state);return *pid;
};
write(1,paint(2,f.now-80,5000,f.now-10));
add("leader",paint(3,f.now,0,0));
add("early",paint(2,f.now-100,4000,f.now));
add("late",paint(2,f.now-50,5000,f.now-10));
add("untimed",paint(2,0,0,0));
add("absent",paint(0,0,0,0));
f.read();
auto story=f.send(f.command("rank.list",{{"stageId",0}}));
auto rows=story["rank"]["entries"];
check(story["ok"]&&story["rank"]["total"]==5&&story["rank"]["selfPlace"]==3&&rows.size()==5&&rows[0]["name"]=="leader"&&rows[1]["name"]=="early"&&rows[2]["name"]=="tester"&&rows[3]["name"]=="late"&&rows[4]["name"]=="untimed"&&f.state["coins"]==640,"story board orders confirmed clears, then time, then player id");
auto trial=f.send(f.command("rank.list",{{"stageId",1}}));rows=trial["rank"]["entries"];
check(trial["ok"]&&trial["rank"]["total"]==3&&trial["rank"]["selfPlace"]==2&&rows[0]["name"]=="early"&&rows[0]["score"]==4000&&rows[1]["name"]=="tester"&&rows[2]["name"]=="late","trial board orders the shorter confirmed time first");
f.sql("UPDATE game_states SET state='{' WHERE pid=(SELECT pid FROM users WHERE username='absent')");
auto kept=f.send(f.command("rank.list",{{"stageId",0}}));
check(kept["ok"]&&kept["rank"]["total"]==5,"a broken save is skipped and the confirmed board remains");
}
{
Fixture f((root/"rankcap.db").string());
for(int i=0;i<22;i++) {
std::string name="cap"+std::to_string(i),token="cap-"+name;
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"cap player");
check(f.db.store_token(token,*pid,1,4102444800LL),"cap token");
check(f.db.game_request(token,"",f.now).status==200,"cap save");
auto s=f.stored();int score=i<20?20-i:1;
s["clearedStages"]=cleared_prefix(static_cast<int>(s["clearedStages"].size()),score);s["storyRankAt"]=f.now-score*10-i;
std::string data=s.dump(),escaped;for(char c:data){escaped+=c;if(c=='\'') escaped+='\'';}
f.sql("UPDATE game_states SET state='"+escaped+"' WHERE pid="+std::to_string(*pid));
}
auto board=f.send(f.command("rank.list",{{"stageId",0}}));
check(board["ok"]&&board["rank"]["total"]==22&&board["rank"]["entries"].size()==20&&board["rank"]["entries"][0]["score"]==20&&board["rank"]["selfPlace"]==0&&f.state["coins"]==0&&f.state["balls"]==20,"the page keeps the top 20 and leaves an unranked player off the board");
}
{
Fixture f((root/"friends.db").string());
auto session=[&](const std::string& token){auto body=f.db.game_request(token,"",f.now);check(body.status==200,"friend read");return J::parse(body.body);};
auto act=[&](const std::string& token,const std::string& action,J extra){
auto view=session(token);extra["action"]=action;extra["revision"]=view["state"]["revision"];extra["requestId"]="friend-"+std::to_string(++f.serial)+"-unique-test";
auto body=f.db.game_request(token,extra.dump(),f.now);check(body.status==200,"friend command");auto reply=J::parse(body.body);
if(token==f.token) f.state=reply["state"];return reply;
};
auto person=[&](const std::string& name){
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"friend player");
check(f.db.store_token("friend-"+name,*pid,1,4102444800LL),"friend token");
auto opened=session("friend-"+name);check(opened["state"]["coins"]==0&&opened["state"]["friendIds"].empty(),"new player starts without friends or coins");
return *pid;
};
auto beginCmd=f.command("battle.begin",{{"stageId",1}});
auto begin=f.send(beginCmd);
check(begin["ok"]&&f.state["pendingBattle"]!="","battle started before the friend list");
auto during=f.send(f.command("friend.list"));
check(during["ok"]&&during["friends"]["friends"].empty()&&during["friends"]["sent"]==0&&f.state["pendingBattle"]!=""&&f.state["coins"]==0,"reading friends does not settle a battle or pay anyone");
check(f.send(f.command("battle.abandon",{{"activityId",beginCmd["requestId"]}}))["ok"],"abandon after the friend list");
check(!act(f.token,"friend.lookup",{{"target",1}})["ok"]&&f.state["coins"]==0,"a player cannot look up their own id");
check(act(f.token,"friend.lookup",{{"target",999999}})["error"]=="friend_missing","an unknown id is refused");
int64_t ally=person("ally");
auto found=act(f.token,"friend.lookup",{{"target",ally}});
check(found["ok"]&&found["friends"]["card"]["name"]=="ally"&&found["friends"]["card"]["relation"]=="none"&&found["friends"]["card"]["formation"].empty()&&found["friends"]["card"]["camp"].empty(),"lookup shows the name and hides the lineup");
check(act(f.token,"friend.visit",{{"target",ally}})["error"]=="friend_required","a camp visit requires friendship");
int allyRevision=session("friend-ally")["state"]["revision"].get<int>();
auto asked=act(f.token,"friend.request",{{"target",ally}});
auto allyView=session("friend-ally");
check(asked["ok"]&&f.state["friendOut"]==J::array({ally})&&allyView["state"]["friendIn"]==J::array({1})&&allyView["state"]["coins"]==0&&allyView["state"]["revision"]==allyRevision,"a request is recorded on both sides and does not pay or bump the other revision");
check(act(f.token,"friend.request",{{"target",ally}})["error"]=="friend_pending"&&f.state["friendOut"].size()==1,"the same request cannot be duplicated");
check(act(f.token,"friend.cancel",{{"target",ally}})["ok"]&&f.state["friendOut"].empty()&&session("friend-ally")["state"]["friendIn"].empty(),"cancel removes the request on both sides");
check(act("friend-ally","friend.request",{{"target",1}})["ok"],"the other player can request");
check(act(f.token,"friend.decline",{{"target",ally}})["ok"]&&f.state["friendIn"].empty()&&session("friend-ally")["state"]["friendOut"].empty(),"decline removes the incoming request");
check(act("friend-ally","friend.request",{{"target",1}})["ok"],"request again");
auto accepted=act(f.token,"friend.accept",{{"target",ally}});
check(accepted["ok"]&&f.state["friendIds"]==J::array({ally})&&f.state["friendIn"].empty()&&session("friend-ally")["state"]["friendIds"]==J::array({1})&&session("friend-ally")["state"]["friendOut"].empty(),"accept makes the friendship on both sides");
auto visit=act(f.token,"friend.visit",{{"target",ally}});
check(visit["ok"]&&visit["friends"]["card"]["formation"].size()==6&&visit["friends"]["card"]["camp"].size()==3&&!visit["friends"]["card"].contains("coins"),"a friend visit shows formation and camp only");
auto view=session(f.token);J gift={{"action","friend.gift"},{"revision",view["state"]["revision"]},{"requestId","friend-gift-once-unique"},{"target",ally}};
auto paid=J::parse(f.db.game_request(f.token,gift.dump(),f.now).body);f.state=paid["state"];
check(paid["ok"]&&f.state["coins"]==0&&f.state["giftSent"]==J::array({ally})&&session("friend-ally")["state"]["coins"]==20&&session("friend-ally")["state"]["giftFrom"]==J::array({1}),"the gift is paid once to the other player");
auto replayed=J::parse(f.db.game_request(f.token,gift.dump(),f.now).body);
check(replayed["replayed"]&&session("friend-ally")["state"]["coins"]==20,"replaying the gift does not pay again");
f.read();
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="gift_sent"&&session("friend-ally")["state"]["coins"]==20,"the same direction cannot be sent twice in one day");
check(act("friend-ally","friend.gift",{{"target",1}})["error"]=="gift_pair"&&f.read()["state"]["coins"]==0,"the pair cannot exchange a gift back on the same day");
check(act(f.token,"friend.remove",{{"target",ally}})["ok"]&&f.state["friendIds"].empty()&&session("friend-ally")["state"]["friendIds"].empty(),"delete removes both sides");
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="friend_required","a deleted friend cannot be gifted");
check(act(f.token,"friend.request",{{"target",ally}})["ok"],"request after delete");
auto mirrored=act("friend-ally","friend.request",{{"target",1}});
check(mirrored["ok"]&&mirrored["friends"]["card"]["relation"]=="friend"&&f.read()["state"]["friendIds"]==J::array({ally}),"a mutual request becomes a friendship");
check(act("friend-ally","friend.block",{{"target",1}})["ok"]&&session("friend-ally")["state"]["friendBlocked"]==J::array({1})&&f.read()["state"]["friendIds"].empty(),"block removes the friendship");
check(act(f.token,"friend.request",{{"target",ally}})["error"]=="friend_blocked","a blocked player cannot send a request");
check(act("friend-ally","friend.unblock",{{"target",1}})["ok"]&&session("friend-ally")["state"]["friendBlocked"].empty(),"unblock clears the block list");
f.now+=86400;
check(act(f.token,"friend.request",{{"target",ally}})["ok"]&&act("friend-ally","friend.accept",{{"target",1}})["ok"],"they can become friends again the next day");
check(act("friend-ally","friend.gift",{{"target",1}})["ok"]&&f.read()["state"]["coins"]==20&&session("friend-ally")["state"]["coins"]==20,"the other direction can send on a new day");
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="gift_pair"&&session("friend-ally")["state"]["coins"]==20,"that new day still allows only one direction");
int64_t hub=person("hub");
for(int i=0;i<6;i++) {
std::string name="giver"+std::to_string(i);int64_t giver=person(name);
check(act("friend-"+name,"friend.request",{{"target",hub}})["ok"]&&act("friend-hub","friend.accept",{{"target",giver}})["ok"],"receive-cap friendship");
auto sent=act("friend-"+name,"friend.gift",{{"target",hub}});
check(sent["ok"]==(i<5)&&session("friend-hub")["state"]["coins"]==(i<5?(i+1)*20:100),i<5?"a friend gift arrives":"the sixth gift is refused by the daily receive cap");
}
for(int i=0;i<6;i++) {
std::string name="taker"+std::to_string(i);int64_t taker=person(name);
check(act(f.token,"friend.request",{{"target",taker}})["ok"]&&act("friend-"+name,"friend.accept",{{"target",1}})["ok"],"send-cap friendship");
auto sent=act(f.token,"friend.gift",{{"target",taker}});
int takerCoins=session("friend-"+name)["state"]["coins"].get<int>();
check(sent["ok"]==(i<5)&&takerCoins==(i<5?20:0)&&f.read()["state"]["coins"]==20,i<5?"the sender can give five gifts":"the sixth send is refused and the sender's wallet stays unchanged");
}
int beforeFull=static_cast<int>(f.state["friendIds"].size());
for(int i=beforeFull;i<20;i++) {
std::string name="full"+std::to_string(i);int64_t other=person(name);
check(act(f.token,"friend.request",{{"target",other}})["ok"]&&act("friend-"+name,"friend.accept",{{"target",1}})["ok"],"fill the friend list");
}
int64_t extra=person("extra");
check(act(f.token,"friend.request",{{"target",extra}})["error"]=="friend_full"&&f.state["friendIds"].size()==20&&f.state["coins"]==20,"the friend cap refuses another request without changing the wallet");
auto migrated=f.state;int coins=migrated["coins"].get<int>();
migrated["schema"]=11;migrated.erase("friendIds");migrated.erase("friendIn");migrated.erase("friendOut");migrated.erase("friendBlocked");migrated.erase("giftSent");migrated.erase("giftFrom");
f.replace(migrated);
check(f.state["schema"]==14&&f.state["friendIds"].empty()&&f.state["giftSent"].empty()&&f.state["giftFrom"].empty()&&f.state["coins"]==coins,"schema11 migration adds empty friend lists and pays nothing");
}
{
Fixture f((root/"fish.db").string());
auto rng=[](uint32_t){return 0u;};
check(f.state["schema"]==14&&f.state["fishPlayed"]==0&&f.state["fishBest"]==0&&f.state["pendingFish"]==""&&f.state["coins"]==0&&f.state["balls"]==20,"fresh fishing counters are empty");
auto locked=f.send(f.command("fish.begin"),rng);
check(!locked["ok"]&&locked["error"]=="fish_locked"&&f.state["balls"]==20&&f.state["fishPlayed"]==0,"fishing stays locked before 1-1");
f.state["clearedStages"][0]=1;f.state["storyRankAt"]=f.now;f.replace(f.state);
auto battle=f.command("battle.begin");
check(f.send(battle)["ok"]&&f.send(f.command("fish.begin"),rng)["error"]=="pending_activity","a pending battle blocks fishing");
check(f.send(f.command("battle.abandon",{{"activityId",battle["requestId"]}}))["ok"]&&f.state["balls"]==20,"abandoning the battle spends no ball");
auto beginCmd=f.command("fish.begin");
auto began=f.send(beginCmd,rng);
check(began["ok"]&&f.state["balls"]==19&&f.state["fishPlayed"]==1&&f.state["pendingFish"]==beginCmd["requestId"]&&began["fish"]["spots"].size()==8,"begin spends one ball and freezes eight fish");
int expect=0;
for(int i=0;i<8;i++) {
check(began["fish"]["spots"][i]["at"]==400+i*2337&&began["fish"]["spots"][i]["window"]==900&&began["fish"]["spots"][i]["points"]==2,"the frozen pattern is fixed when the random source returns zero");
expect+=began["fish"]["spots"][i]["points"].get<int>();
}
auto cheat=f.send(f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"fishScore",99},{"tapFish",J::array({0})},{"taps",J::array({999999})}}));
check(!cheat["ok"]&&cheat["error"]=="invalid_input"&&f.state["pendingFish"]==beginCmd["requestId"]&&f.state["coins"]==0,"a forged score or tap does not pay");
auto future=f.send(f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"tapFish",J::array({0})},{"taps",J::array({5000})}}));
check(future["error"]=="invalid_fish_tap"&&f.state["coins"]==0&&f.state["pendingFish"]==beginCmd["requestId"],"a tap ahead of the server clock is refused");
f.now+=20;
J ids=J::array(),taps=J::array();
for(int i=0;i<8;i++) {ids.push_back(i);taps.push_back(began["fish"]["spots"][i]["at"]);}
auto fin=f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"tapFish",ids},{"taps",taps}});
auto done=f.send(fin);
check(done["ok"]&&done["result"]["fishScore"]==expect&&expect==16&&f.state["coins"]==30&&f.state["candy"]==5&&f.state["pendingFish"]==""&&f.state["fishBest"]==expect&&!f.state.contains("fishData"),"server scores the taps and pays the top tier once");
check(f.send(fin)["replayed"].get<bool>()&&f.state["coins"]==30&&f.state["candy"]==5,"replaying the catch does not pay again");
auto b2=f.command("fish.begin");
check(f.send(b2,rng)["ok"]&&f.state["balls"]==18,"second session spends another ball");
f.now+=20;
auto miss=f.send(f.command("fish.finish",{{"activityId",b2["requestId"]},{"tapFish",J::array()},{"taps",J::array()}}));
check(miss["ok"]&&miss["result"]["fishScore"]==0&&f.state["coins"]==30&&f.state["candy"]==5&&f.state["fishPlayed"]==2,"a miss closes the session without a reward");
auto b3=f.command("fish.begin");
check(f.send(b3,rng)["ok"],"third session");
check(f.send(f.command("fish.abandon",{{"activityId",b3["requestId"]}}))["ok"]&&f.state["pendingFish"]==""&&f.state["coins"]==30&&f.state["balls"]==17&&f.state["fishPlayed"]==3,"abandon keeps the spent ball and pays nothing");
auto b4=f.command("fish.begin");
check(f.send(b4,rng)["ok"],"fourth session");
check(f.send(f.command("battle.begin"))["error"]=="pending_activity","fishing blocks another activity");
f.now+=30;
check(f.send(f.command("fish.finish",{{"activityId",b4["requestId"]},{"tapFish",J::array()},{"taps",J::array()}}))["error"]=="fish_expired"&&f.state["pendingFish"]==b4["requestId"],"an expired session cannot be scored");
check(f.send(f.command("fish.abandon",{{"activityId",b4["requestId"]}}))["ok"]&&f.state["coins"]==30&&f.state["fishPlayed"]==4,"abandon after expiry still pays nothing");
auto b5=f.command("fish.begin");
check(f.send(b5,rng)["ok"]&&f.state["fishPlayed"]==5&&f.state["balls"]==15,"fifth session reaches the daily cap");
check(f.send(f.command("fish.abandon",{{"activityId",b5["requestId"]}}))["ok"],"fifth session can be abandoned");
check(f.send(f.command("fish.begin"),rng)["error"]=="fish_limit"&&f.state["balls"]==15&&f.state["fishPlayed"]==5,"the sixth session is refused and does not spend a ball");
int coins=f.state["coins"].get<int>();
f.now+=86400;f.read();
check(f.state["fishPlayed"]==0&&f.state["fishBest"]==expect&&f.state["coins"]==coins,"a new day restores attempts without resetting the best score or wallet");
auto old=f.state;old["schema"]=12;old.erase("fishPlayed");old.erase("fishBest");old.erase("pendingFish");old.erase("fishData");
f.replace(old);
check(f.state["schema"]==14&&f.state["fishPlayed"]==0&&f.state["fishBest"]==0&&f.state["pendingFish"]==""&&f.state["coins"]==coins,"schema12 migration adds empty fishing counters and pays nothing");
}
{
Fixture f((root/"treasure.db").string());
auto rng=[](uint32_t){return 0u;};
auto session=[&](const std::string& token){auto body=f.db.game_request(token,"",f.now);check(body.status==200,"treasure read");return J::parse(body.body);};
auto act=[&](const std::string& token,const std::string& action,J extra){
auto view=session(token);extra["action"]=action;extra["revision"]=view["state"]["revision"];extra["requestId"]="treasure-"+std::to_string(++f.serial)+"-unique-test";
auto body=f.db.game_request(token,extra.dump(),f.now,rng);check(body.status==200,"treasure command");auto reply=J::parse(body.body);
if(token==f.token) f.state=reply["state"];return reply;
};
auto person=[&](const std::string& name){
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"treasure player");
check(f.db.store_token("treasure-"+name,*pid,1,4102444800LL),"treasure token");
return *pid;
};
auto befriend=[&](int64_t pid,const std::string& name){
check(act(f.token,"friend.request",{{"target",pid}})["ok"]&&act("treasure-"+name,"friend.accept",{{"target",1}})["ok"],"treasure friendship");
};
check(f.state["schema"]==14&&f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==0&&f.state["candy"]==0,"fresh treasure counters are empty");
int64_t ally=person("ally");
check(act(f.token,"friend.treasure",{{"target",ally},{"index",99}})["error"]=="friend_required"&&f.state["coins"]==0&&session("treasure-ally")["state"]["coins"]==0,"treasure requires a friend and ignores a forged event");
befriend(ally,"ally");
check(act(f.token,"friend.treasure",{{"target",ally}})["error"]=="treasure_locked"&&f.state["coins"]==0&&session("treasure-ally")["state"]["coins"]==0,"treasure stays locked before 1-1");
f.state["clearedStages"][0]=1;f.state["storyRankAt"]=f.now;f.replace(f.state);
auto battle=f.command("battle.begin");
check(f.send(battle)["ok"],"a story battle can stay open");
auto first=f.command("friend.treasure",{{"target",ally},{"index",99}});
auto found=f.send(first,rng);
check(found["ok"]&&found["friends"]["treasure"]["index"]==0&&found["friends"]["treasure"]["reward"]["coins"]==10&&found["friends"]["treasure"]["reward"]["candy"]==0&&f.state["coins"]==10&&f.state["candy"]==0&&f.state["balls"]==20&&f.state["treasurePlayed"]==1&&f.state["treasureSeen"]==J::array({ally})&&f.state["pendingBattle"]!="","the server picks the event and pays the visitor during a battle");
auto allyAfter=session("treasure-ally")["state"];
check(allyAfter["coins"]==0&&allyAfter["candy"]==0&&allyAfter["balls"]==20&&allyAfter["campLayout"]==J::array({-1,-1,-1}),"the friend's wallet and camp stay unchanged");
check(f.send(f.command("battle.abandon",{{"activityId",battle["requestId"]}}))["ok"]&&f.state["pendingBattle"]=="","the battle can still be abandoned");
check(f.send(first)["replayed"].get<bool>()&&f.state["coins"]==10,"replaying the search does not pay again");
check(act(f.token,"friend.treasure",{{"target",ally}})["error"]=="treasure_seen"&&f.state["coins"]==10,"the same camp can be searched once each day");
int64_t second=person("second"),third=person("third"),fourth=person("fourth");
befriend(second,"second");befriend(third,"third");befriend(fourth,"fourth");
check(act(f.token,"friend.treasure",{{"target",second}})["ok"]&&f.state["coins"]==20&&f.state["treasurePlayed"]==2,"the second camp pays the same band");
check(act(f.token,"friend.treasure",{{"target",third}})["ok"]&&f.state["coins"]==30&&f.state["treasurePlayed"]==3,"the third camp reaches the daily cap");
check(act(f.token,"friend.treasure",{{"target",fourth}})["error"]=="treasure_limit"&&f.state["coins"]==30&&f.state["balls"]==20&&session("treasure-fourth")["state"]["coins"]==0,"the fourth search is refused and does not spend a ball");
int coins=f.state["coins"].get<int>();
f.now+=86400;f.read();
check(f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==coins,"a new day restores searches without changing the wallet");
check(act(f.token,"friend.treasure",{{"target",ally}})["ok"]&&f.state["coins"]==40&&f.state["treasurePlayed"]==1,"the same camp can be searched again the next day");
for(size_t i=0;i<f.state["clearedStages"].size();++i) f.state["clearedStages"][i]=1;
f.replace(f.state);
int64_t fifth=person("fifth");befriend(fifth,"fifth");
auto deep=act(f.token,"friend.treasure",{{"target",fifth}});
check(deep["ok"]&&deep["friends"]["treasure"]["reward"]["coins"]==30&&deep["friends"]["treasure"]["reward"]["candy"]==5&&f.state["coins"]==70&&f.state["candy"]==5&&session("treasure-fifth")["state"]["coins"]==0&&session("treasure-fifth")["state"]["candy"]==0,"ten clears pay the top band from the event pool");
auto old=f.state;old["schema"]=13;old.erase("treasurePlayed");old.erase("treasureSeen");
f.replace(old);
check(f.state["schema"]==14&&f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==70&&f.state["candy"]==5,"schema13 migration adds empty treasure counters and pays nothing");
}
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;}
}