#include "db.hpp" #include "json.hpp" #include "sqlite3.h" #include "game_config.hpp" #include #include #include #include #include #include #include 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& 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(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(),"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()<=.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(),"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();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(),"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(),"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();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()==before["coins"].get()+80&&f.state["candy"].get()==before["candy"].get()+40&&f.state["balls"].get()==before["balls"].get()+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(),"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()&&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 accepted{0};std::vector 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();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()<=.035f,"all angles land"); check(r["shot"]["bin"]==std::clamp(static_cast(path.back()["x"].get()*5),0,4),"reward bin follows trajectory"); f.now=r["shot"]["readyAt"].get();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(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();before=f.state; result=f.send(f.command("battle.finish",{{"activityId",start["requestId"]},{"won",true}})); check(!result["result"]["won"].get()&&f.state["coins"]==before["coins"]&&!f.state["cleared"].get(),"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(),"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(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(); 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()==before[key].get()+cfg["stages"][id-1]["reward"][key].get(),"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();before=f.state; result=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}})); check(result["result"]["won"]&&!result["result"]["firstClear"].get()&&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(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(); 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(cfg["pets"].size()));s["clearedStages"]=cleared(static_cast(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(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();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()>0;healing+=stat["healing"].get();absorbed+=stat["absorbed"].get();} if(layout==2&&id>=3)check(r["result"]["leaks"][1].get()>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(cfg["pets"].size());pet++) { s=f.stored();s["selectedStage"]=5;s["level"]=10;s["stages"]=owned(static_cast(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();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()==pet) casts=stat["skills"].get(); 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(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();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();effectAbsorbed+=stat["absorbed"].get();} 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(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(missing),"pity samples only missing species");return 1u;}); check(r["result"]["pet"]==4&&r["result"]["pity"]&&!r["result"]["duplicate"].get()&&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(),"complete pool pauses pity"); for(int i=0;i(pets),"ordinary pool range");return static_cast(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 rolls{0},ok{0};std::vector 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()&&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().resize(3);old["shards"].get_ref().resize(3); old["battleSnapshot"]["stages"].get_ref().resize(3); freeze_classic(old,1); old["battleSnapshot"]["config"]["pets"].get_ref().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();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().resize(3);old["shards"].get_ref().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(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().resize(3);old["shards"].get_ref().resize(3);old["battleSnapshot"]["stages"].get_ref().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();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(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();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(cfg["stages"].size()));s["cleared"]=true;f.replace(s); auto purchase=f.command("camp.place",{{"item",2},{"slot",2}});std::vector requests;std::atomic 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(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();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();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 retries;std::atomic 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()>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()<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();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(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();auto end=f.command("battle.finish",{{"activityId",begin["requestId"]}});auto ended=f.send(end); check(!ended["result"]["won"].get()&&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 retries;std::atomic 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();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(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(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(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(cfg["stages"].size()),3);f.replace(s);int day=f.state["dailyDay"]; auto buy=f.command("shop.buy",{{"item",2},{"day",day}});std::vector retry;std::atomic 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();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();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 ok{0};std::vector 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();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(cfg["stages"].size()),0);s["stages"]=owned(static_cast(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(); 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(); end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}})); check(end["result"]["won"]&&!end["result"]["firstClear"].get()&&!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();f.send(f.command("shot.finish",{{"activityId",shot["requestId"]}})); // Daily chest availability follows the shared server day. f.now=(static_cast(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 ok{0};std::vector 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<