#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&&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()==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"]&&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(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"]==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()&&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"]==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(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"]==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(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"]==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(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();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"]==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();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(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"]==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();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 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"]==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();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(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"]==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(); 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()>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(); end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}})); check(end["result"]["won"]&&!end["result"]["firstClear"].get()&&!end["result"]["improved"].get()&&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(); 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(s["clearedStages"].size());i++) s["clearedStages"][i]=i0?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(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(); 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(); 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(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(); 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(); } 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()&&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(); 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()&&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(); 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;i1) {std::ofstream output(argv[1],std::ios::binary);output<