Add rank, friends, fishing, and camp treasure with T2 studio models and skill presentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-21 23:01:31 +08:00
co-authored by Cursor
parent 89e0fa246f
commit 079313fb06
677 changed files with 395371 additions and 9692 deletions
+208 -18
View File
@@ -27,7 +27,7 @@ struct Fixture {
check(db.store_token(token,*pid,1,4102444800LL),"token created");read();
}
J read() {
auto r=db.game_request(token,"",now);if(r.status!=200) std::cerr<<"READ "<<r.status<<" "<<r.body<<"\n";check(r.status==200,"read succeeds");auto j=J::parse(r.body);state=j["state"];return j;
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"];
@@ -271,7 +271,7 @@ int main(int argc,char** argv) {
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"]==11&&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["schema"]==14&&f.state["stages"][0]==1&&f.state["stages"][1]==2&&f.state["stages"][2]==1&&f.state["stages"].size()==10&&f.state["shards"][0]==12&&f.state["shards"][1]==25&&f.state["shards"][2]==30&&f.state["shards"].size()==10&&f.state["formation"]==J({2,1,-1,-1,-1,-1}),"G2 migration preserves owned evolutions, shards and formation");
check(f.state["captureTutorialDone"]&&f.state["captureCount"]==0&&f.state["selectedStage"]==2&&f.state["clearedStages"][0]==1&&f.state["clearedStages"][1]==1&&f.state["clearedStages"].size()==20&&f.state["coins"]==123&&f.state["tickets"]==7,"G2 migration preserves progress and suppresses extra tutorial reward");
auto r=f.send(f.command("capture"),[](uint32_t n){check(n==10,"migrated ordinary pool size");return 3u;});
check(r["result"]["pet"]==3&&!r["result"]["tutorial"].get<bool>()&&f.state["captureCount"]==1,"migrated player enters ordinary pool");
@@ -296,7 +296,7 @@ int main(int argc,char** argv) {
{
Fixture f((root/"migration.db").string());
auto old=f.state;old["schema"]=1;old["stages"].get_ref<J::array_t&>().resize(3);old["shards"].get_ref<J::array_t&>().resize(3);old["cleared"]=true;old["goalClaimed"]=true;old["coins"]=777;old["candy"]=888;old.erase("selectedStage");old.erase("clearedStages");
f.replace(old);check(f.state["schema"]==11&&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");
f.replace(old);check(f.state["schema"]==14&&f.state["clearedStages"][0]==1&&f.state["clearedStages"].size()==20&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["goalClaimed"],"G1 completed save migrates without reward or reset");
auto next=f.state;next["cleared"]=false;next["clearedStages"]=cleared(static_cast<int>(f.read()["config"]["stages"].size()),0);f.replace(next);
auto start=f.command("battle.begin");auto b=f.send(start);old=f.stored();old["schema"]=1;old["stages"].get_ref<J::array_t&>().resize(3);old["shards"].get_ref<J::array_t&>().resize(3);old["battleSnapshot"]["stages"].get_ref<J::array_t&>().resize(3);old.erase("selectedStage");old.erase("clearedStages");
freeze_classic(old,2);
@@ -312,7 +312,7 @@ int main(int argc,char** argv) {
}
{
Fixture f((root/"camp.db").string());auto cfg=f.read()["config"];
check(f.state["schema"]==11&&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");
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");
@@ -353,13 +353,13 @@ int main(int argc,char** argv) {
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"]==11&&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");
check(f.state["schema"]==14&&f.state["coins"]==640&&f.state["tickets"]==9&&f.state["shards"][0]==77&&f.state["stages"].size()==10&&f.state["captureCount"]==12&&f.state["duplicateStreak"]==3&&f.state["campLayout"]==J({-1,-1,-1}),"schema3 migration preserves collection wallet and initializes empty camp");
auto beforeRead=f.state;f.read();check(f.state==beforeRead,"camp reads grant no extra production");
f.now+=600;f.read();check(f.state["balls"]==21,"camp uses existing single recovery source");
}
{
Fixture f((root/"daily.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];int64_t midnight=(static_cast<int64_t>(day)+1)*86400-28800;
check(f.state["schema"]==11&&f.state["signinDays"]==1&&f.state["dailyProgress"]==J({0,0,0})&&f.state["stages"].size()==10,"first server day unlocks day1 without fabricated events");
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");
@@ -408,12 +408,12 @@ int main(int argc,char** argv) {
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"]==11&&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");
check(f.state["schema"]==14&&f.state["coins"]==777&&f.state["campLayout"]==J({-1,0,-1})&&f.state["campInteractions"]==12&&f.state["shots"]==100&&f.state["dailyProgress"]==J({0,0,0})&&f.state["signinDays"]==1&&f.state["signinClaimed"]==J({0,0,0,0,0,0,0})&&f.state["pendingShot"]==start["requestId"],"schema4 migration preserves wallet camp and pending shot; no retroactive daily progress or sign-in award");
f.now=launched["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",start["requestId"]}}));check(f.state["dailyProgress"][0]==1,"pre-migration pending shot counts once when actually settled");
}
{
Fixture f((root/"shop.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];
check(f.state["schema"]==11&&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");
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");
@@ -457,7 +457,7 @@ int main(int argc,char** argv) {
auto shot=f.command("shot.begin",{{"aim",0}});auto launched=f.send(shot);before=f.state;check(f.send(f.command("shop.buy",{{"item",1},{"day",day}}))["error"]=="pending_activity"&&f.state==before,"pending activity blocks purchase");
f.now=launched["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",shot["requestId"]}}));
auto old=f.stored();old["schema"]=5;old.erase("shopBought");old["dailyClaimed"]={1,0,0};old["dailyProgress"][0]=3;old["decorations"]={1,0,0};old["campLayout"]={-1,0,-1};int coins=f.state["coins"],tickets=f.state["tickets"];f.replace(old);
check(f.state["schema"]==11&&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");
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);
@@ -468,7 +468,7 @@ int main(int argc,char** argv) {
}
{
Fixture f((root/"mail_single.db").string());
check(f.state["schema"]==11&&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");
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");
@@ -523,7 +523,7 @@ int main(int argc,char** argv) {
{
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"]==11&&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");
auto migrated=f.read();check(f.state["schema"]==14&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["balls"]==20&&f.state["mails"].size()==2&&f.state["shopBought"]==J({1,0,0})&&f.state["signinClaimed"]==J({1,0,0,0,0,0,0}),"schema6 migration adds mail only, preserves wallet and daily stock");
check(f.state["pendingBattle"]==begin["requestId"]&&!migrated["battle"]["snapshot"]["config"].contains("mail"),"migration preserves pending battle and pre-mail frozen config");
auto read=f.command("mail.read",{{"mailId",f.state["mails"][0]["mailId"]}});check(f.send(read)["error"]=="pending_activity","mail operations preserve pending activity exclusion");
f.now=fight["battle"]["readyAt"].get<int64_t>();auto end=f.send(f.command("battle.finish",{{"activityId",begin["requestId"]}}));
@@ -535,7 +535,7 @@ int main(int argc,char** argv) {
}
{
Fixture f((root/"chest.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];
check(f.state["schema"]==11&&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");
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");
@@ -593,15 +593,15 @@ int main(int argc,char** argv) {
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"]==11&&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");
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"]==11&&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}),"fresh save has five locked element trials and no badges");
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"]==11&&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");
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);
@@ -625,7 +625,7 @@ int main(int argc,char** argv) {
{
Fixture f((root/"rank.db").string());
int coins=f.state["coins"];
check(f.state["schema"]==11&&f.state["storyRankAt"]==0&&coins==20,"fresh account is absent from every board");
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");
@@ -640,7 +640,7 @@ int main(int argc,char** argv) {
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"]==11&&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,"schema10 migration stamps existing scores without paying rewards");
check(f.state["schema"]==14&&f.state["storyRankAt"]==f.now&&f.state["challengeRankAt"][0]==f.now&&f.state["challengeRankAt"][1]==0&&f.state["coins"]==640&&f.state["shards"][0]==0&&f.state["friendIds"].empty()&&f.state["giftSent"].empty(),"schema10 migration stamps existing scores without paying rewards");
auto paint=[&](int clears,int64_t at,int best,int64_t bestAt){auto s=f.stored();for(int i=0;i<static_cast<int>(s["clearedStages"].size());i++) s["clearedStages"][i]=i<clears?1:0;s["storyRankAt"]=at;s["challengeBestMs"][0]=best;s["challengeRankAt"][0]=best>0?bestAt:0;return s;};
auto write=[&](int64_t pid,J state){
std::string data=state.dump(),escaped;for(char c:data){escaped+=c;if(c=='\'') escaped+='\'';}
@@ -681,7 +681,197 @@ int main(int argc,char** argv) {
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"]==20,"the page keeps the top 20 and leaves an unranked player off the board");
check(board["ok"]&&board["rank"]["total"]==22&&board["rank"]["entries"].size()==20&&board["rank"]["entries"][0]["score"]==20&&board["rank"]["selfPlace"]==0&&f.state["coins"]==0&&f.state["balls"]==20,"the page keeps the top 20 and leaves an unranked player off the board");
}
{
Fixture f((root/"friends.db").string());
auto session=[&](const std::string& token){auto body=f.db.game_request(token,"",f.now);check(body.status==200,"friend read");return J::parse(body.body);};
auto act=[&](const std::string& token,const std::string& action,J extra){
auto view=session(token);extra["action"]=action;extra["revision"]=view["state"]["revision"];extra["requestId"]="friend-"+std::to_string(++f.serial)+"-unique-test";
auto body=f.db.game_request(token,extra.dump(),f.now);check(body.status==200,"friend command");auto reply=J::parse(body.body);
if(token==f.token) f.state=reply["state"];return reply;
};
auto person=[&](const std::string& name){
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"friend player");
check(f.db.store_token("friend-"+name,*pid,1,4102444800LL),"friend token");
auto opened=session("friend-"+name);check(opened["state"]["coins"]==0&&opened["state"]["friendIds"].empty(),"new player starts without friends or coins");
return *pid;
};
auto beginCmd=f.command("battle.begin",{{"stageId",1}});
auto begin=f.send(beginCmd);
check(begin["ok"]&&f.state["pendingBattle"]!="","battle started before the friend list");
auto during=f.send(f.command("friend.list"));
check(during["ok"]&&during["friends"]["friends"].empty()&&during["friends"]["sent"]==0&&f.state["pendingBattle"]!=""&&f.state["coins"]==0,"reading friends does not settle a battle or pay anyone");
check(f.send(f.command("battle.abandon",{{"activityId",beginCmd["requestId"]}}))["ok"],"abandon after the friend list");
check(!act(f.token,"friend.lookup",{{"target",1}})["ok"]&&f.state["coins"]==0,"a player cannot look up their own id");
check(act(f.token,"friend.lookup",{{"target",999999}})["error"]=="friend_missing","an unknown id is refused");
int64_t ally=person("ally");
auto found=act(f.token,"friend.lookup",{{"target",ally}});
check(found["ok"]&&found["friends"]["card"]["name"]=="ally"&&found["friends"]["card"]["relation"]=="none"&&found["friends"]["card"]["formation"].empty()&&found["friends"]["card"]["camp"].empty(),"lookup shows the name and hides the lineup");
check(act(f.token,"friend.visit",{{"target",ally}})["error"]=="friend_required","a camp visit requires friendship");
int allyRevision=session("friend-ally")["state"]["revision"].get<int>();
auto asked=act(f.token,"friend.request",{{"target",ally}});
auto allyView=session("friend-ally");
check(asked["ok"]&&f.state["friendOut"]==J::array({ally})&&allyView["state"]["friendIn"]==J::array({1})&&allyView["state"]["coins"]==0&&allyView["state"]["revision"]==allyRevision,"a request is recorded on both sides and does not pay or bump the other revision");
check(act(f.token,"friend.request",{{"target",ally}})["error"]=="friend_pending"&&f.state["friendOut"].size()==1,"the same request cannot be duplicated");
check(act(f.token,"friend.cancel",{{"target",ally}})["ok"]&&f.state["friendOut"].empty()&&session("friend-ally")["state"]["friendIn"].empty(),"cancel removes the request on both sides");
check(act("friend-ally","friend.request",{{"target",1}})["ok"],"the other player can request");
check(act(f.token,"friend.decline",{{"target",ally}})["ok"]&&f.state["friendIn"].empty()&&session("friend-ally")["state"]["friendOut"].empty(),"decline removes the incoming request");
check(act("friend-ally","friend.request",{{"target",1}})["ok"],"request again");
auto accepted=act(f.token,"friend.accept",{{"target",ally}});
check(accepted["ok"]&&f.state["friendIds"]==J::array({ally})&&f.state["friendIn"].empty()&&session("friend-ally")["state"]["friendIds"]==J::array({1})&&session("friend-ally")["state"]["friendOut"].empty(),"accept makes the friendship on both sides");
auto visit=act(f.token,"friend.visit",{{"target",ally}});
check(visit["ok"]&&visit["friends"]["card"]["formation"].size()==6&&visit["friends"]["card"]["camp"].size()==3&&!visit["friends"]["card"].contains("coins"),"a friend visit shows formation and camp only");
auto view=session(f.token);J gift={{"action","friend.gift"},{"revision",view["state"]["revision"]},{"requestId","friend-gift-once-unique"},{"target",ally}};
auto paid=J::parse(f.db.game_request(f.token,gift.dump(),f.now).body);f.state=paid["state"];
check(paid["ok"]&&f.state["coins"]==0&&f.state["giftSent"]==J::array({ally})&&session("friend-ally")["state"]["coins"]==20&&session("friend-ally")["state"]["giftFrom"]==J::array({1}),"the gift is paid once to the other player");
auto replayed=J::parse(f.db.game_request(f.token,gift.dump(),f.now).body);
check(replayed["replayed"]&&session("friend-ally")["state"]["coins"]==20,"replaying the gift does not pay again");
f.read();
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="gift_sent"&&session("friend-ally")["state"]["coins"]==20,"the same direction cannot be sent twice in one day");
check(act("friend-ally","friend.gift",{{"target",1}})["error"]=="gift_pair"&&f.read()["state"]["coins"]==0,"the pair cannot exchange a gift back on the same day");
check(act(f.token,"friend.remove",{{"target",ally}})["ok"]&&f.state["friendIds"].empty()&&session("friend-ally")["state"]["friendIds"].empty(),"delete removes both sides");
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="friend_required","a deleted friend cannot be gifted");
check(act(f.token,"friend.request",{{"target",ally}})["ok"],"request after delete");
auto mirrored=act("friend-ally","friend.request",{{"target",1}});
check(mirrored["ok"]&&mirrored["friends"]["card"]["relation"]=="friend"&&f.read()["state"]["friendIds"]==J::array({ally}),"a mutual request becomes a friendship");
check(act("friend-ally","friend.block",{{"target",1}})["ok"]&&session("friend-ally")["state"]["friendBlocked"]==J::array({1})&&f.read()["state"]["friendIds"].empty(),"block removes the friendship");
check(act(f.token,"friend.request",{{"target",ally}})["error"]=="friend_blocked","a blocked player cannot send a request");
check(act("friend-ally","friend.unblock",{{"target",1}})["ok"]&&session("friend-ally")["state"]["friendBlocked"].empty(),"unblock clears the block list");
f.now+=86400;
check(act(f.token,"friend.request",{{"target",ally}})["ok"]&&act("friend-ally","friend.accept",{{"target",1}})["ok"],"they can become friends again the next day");
check(act("friend-ally","friend.gift",{{"target",1}})["ok"]&&f.read()["state"]["coins"]==20&&session("friend-ally")["state"]["coins"]==20,"the other direction can send on a new day");
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="gift_pair"&&session("friend-ally")["state"]["coins"]==20,"that new day still allows only one direction");
int64_t hub=person("hub");
for(int i=0;i<6;i++) {
std::string name="giver"+std::to_string(i);int64_t giver=person(name);
check(act("friend-"+name,"friend.request",{{"target",hub}})["ok"]&&act("friend-hub","friend.accept",{{"target",giver}})["ok"],"receive-cap friendship");
auto sent=act("friend-"+name,"friend.gift",{{"target",hub}});
check(sent["ok"]==(i<5)&&session("friend-hub")["state"]["coins"]==(i<5?(i+1)*20:100),i<5?"a friend gift arrives":"the sixth gift is refused by the daily receive cap");
}
for(int i=0;i<6;i++) {
std::string name="taker"+std::to_string(i);int64_t taker=person(name);
check(act(f.token,"friend.request",{{"target",taker}})["ok"]&&act("friend-"+name,"friend.accept",{{"target",1}})["ok"],"send-cap friendship");
auto sent=act(f.token,"friend.gift",{{"target",taker}});
int takerCoins=session("friend-"+name)["state"]["coins"].get<int>();
check(sent["ok"]==(i<5)&&takerCoins==(i<5?20:0)&&f.read()["state"]["coins"]==20,i<5?"the sender can give five gifts":"the sixth send is refused and the sender's wallet stays unchanged");
}
int beforeFull=static_cast<int>(f.state["friendIds"].size());
for(int i=beforeFull;i<20;i++) {
std::string name="full"+std::to_string(i);int64_t other=person(name);
check(act(f.token,"friend.request",{{"target",other}})["ok"]&&act("friend-"+name,"friend.accept",{{"target",1}})["ok"],"fill the friend list");
}
int64_t extra=person("extra");
check(act(f.token,"friend.request",{{"target",extra}})["error"]=="friend_full"&&f.state["friendIds"].size()==20&&f.state["coins"]==20,"the friend cap refuses another request without changing the wallet");
auto migrated=f.state;int coins=migrated["coins"].get<int>();
migrated["schema"]=11;migrated.erase("friendIds");migrated.erase("friendIn");migrated.erase("friendOut");migrated.erase("friendBlocked");migrated.erase("giftSent");migrated.erase("giftFrom");
f.replace(migrated);
check(f.state["schema"]==14&&f.state["friendIds"].empty()&&f.state["giftSent"].empty()&&f.state["giftFrom"].empty()&&f.state["coins"]==coins,"schema11 migration adds empty friend lists and pays nothing");
}
{
Fixture f((root/"fish.db").string());
auto rng=[](uint32_t){return 0u;};
check(f.state["schema"]==14&&f.state["fishPlayed"]==0&&f.state["fishBest"]==0&&f.state["pendingFish"]==""&&f.state["coins"]==0&&f.state["balls"]==20,"fresh fishing counters are empty");
auto locked=f.send(f.command("fish.begin"),rng);
check(!locked["ok"]&&locked["error"]=="fish_locked"&&f.state["balls"]==20&&f.state["fishPlayed"]==0,"fishing stays locked before 1-1");
f.state["clearedStages"][0]=1;f.state["storyRankAt"]=f.now;f.replace(f.state);
auto battle=f.command("battle.begin");
check(f.send(battle)["ok"]&&f.send(f.command("fish.begin"),rng)["error"]=="pending_activity","a pending battle blocks fishing");
check(f.send(f.command("battle.abandon",{{"activityId",battle["requestId"]}}))["ok"]&&f.state["balls"]==20,"abandoning the battle spends no ball");
auto beginCmd=f.command("fish.begin");
auto began=f.send(beginCmd,rng);
check(began["ok"]&&f.state["balls"]==19&&f.state["fishPlayed"]==1&&f.state["pendingFish"]==beginCmd["requestId"]&&began["fish"]["spots"].size()==8,"begin spends one ball and freezes eight fish");
int expect=0;
for(int i=0;i<8;i++) {
check(began["fish"]["spots"][i]["at"]==400+i*2337&&began["fish"]["spots"][i]["window"]==900&&began["fish"]["spots"][i]["points"]==2,"the frozen pattern is fixed when the random source returns zero");
expect+=began["fish"]["spots"][i]["points"].get<int>();
}
auto cheat=f.send(f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"fishScore",99},{"tapFish",J::array({0})},{"taps",J::array({999999})}}));
check(!cheat["ok"]&&cheat["error"]=="invalid_input"&&f.state["pendingFish"]==beginCmd["requestId"]&&f.state["coins"]==0,"a forged score or tap does not pay");
auto future=f.send(f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"tapFish",J::array({0})},{"taps",J::array({5000})}}));
check(future["error"]=="invalid_fish_tap"&&f.state["coins"]==0&&f.state["pendingFish"]==beginCmd["requestId"],"a tap ahead of the server clock is refused");
f.now+=20;
J ids=J::array(),taps=J::array();
for(int i=0;i<8;i++) {ids.push_back(i);taps.push_back(began["fish"]["spots"][i]["at"]);}
auto fin=f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"tapFish",ids},{"taps",taps}});
auto done=f.send(fin);
check(done["ok"]&&done["result"]["fishScore"]==expect&&expect==16&&f.state["coins"]==30&&f.state["candy"]==5&&f.state["pendingFish"]==""&&f.state["fishBest"]==expect&&!f.state.contains("fishData"),"server scores the taps and pays the top tier once");
check(f.send(fin)["replayed"].get<bool>()&&f.state["coins"]==30&&f.state["candy"]==5,"replaying the catch does not pay again");
auto b2=f.command("fish.begin");
check(f.send(b2,rng)["ok"]&&f.state["balls"]==18,"second session spends another ball");
f.now+=20;
auto miss=f.send(f.command("fish.finish",{{"activityId",b2["requestId"]},{"tapFish",J::array()},{"taps",J::array()}}));
check(miss["ok"]&&miss["result"]["fishScore"]==0&&f.state["coins"]==30&&f.state["candy"]==5&&f.state["fishPlayed"]==2,"a miss closes the session without a reward");
auto b3=f.command("fish.begin");
check(f.send(b3,rng)["ok"],"third session");
check(f.send(f.command("fish.abandon",{{"activityId",b3["requestId"]}}))["ok"]&&f.state["pendingFish"]==""&&f.state["coins"]==30&&f.state["balls"]==17&&f.state["fishPlayed"]==3,"abandon keeps the spent ball and pays nothing");
auto b4=f.command("fish.begin");
check(f.send(b4,rng)["ok"],"fourth session");
check(f.send(f.command("battle.begin"))["error"]=="pending_activity","fishing blocks another activity");
f.now+=30;
check(f.send(f.command("fish.finish",{{"activityId",b4["requestId"]},{"tapFish",J::array()},{"taps",J::array()}}))["error"]=="fish_expired"&&f.state["pendingFish"]==b4["requestId"],"an expired session cannot be scored");
check(f.send(f.command("fish.abandon",{{"activityId",b4["requestId"]}}))["ok"]&&f.state["coins"]==30&&f.state["fishPlayed"]==4,"abandon after expiry still pays nothing");
auto b5=f.command("fish.begin");
check(f.send(b5,rng)["ok"]&&f.state["fishPlayed"]==5&&f.state["balls"]==15,"fifth session reaches the daily cap");
check(f.send(f.command("fish.abandon",{{"activityId",b5["requestId"]}}))["ok"],"fifth session can be abandoned");
check(f.send(f.command("fish.begin"),rng)["error"]=="fish_limit"&&f.state["balls"]==15&&f.state["fishPlayed"]==5,"the sixth session is refused and does not spend a ball");
int coins=f.state["coins"].get<int>();
f.now+=86400;f.read();
check(f.state["fishPlayed"]==0&&f.state["fishBest"]==expect&&f.state["coins"]==coins,"a new day restores attempts without resetting the best score or wallet");
auto old=f.state;old["schema"]=12;old.erase("fishPlayed");old.erase("fishBest");old.erase("pendingFish");old.erase("fishData");
f.replace(old);
check(f.state["schema"]==14&&f.state["fishPlayed"]==0&&f.state["fishBest"]==0&&f.state["pendingFish"]==""&&f.state["coins"]==coins,"schema12 migration adds empty fishing counters and pays nothing");
}
{
Fixture f((root/"treasure.db").string());
auto rng=[](uint32_t){return 0u;};
auto session=[&](const std::string& token){auto body=f.db.game_request(token,"",f.now);check(body.status==200,"treasure read");return J::parse(body.body);};
auto act=[&](const std::string& token,const std::string& action,J extra){
auto view=session(token);extra["action"]=action;extra["revision"]=view["state"]["revision"];extra["requestId"]="treasure-"+std::to_string(++f.serial)+"-unique-test";
auto body=f.db.game_request(token,extra.dump(),f.now,rng);check(body.status==200,"treasure command");auto reply=J::parse(body.body);
if(token==f.token) f.state=reply["state"];return reply;
};
auto person=[&](const std::string& name){
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"treasure player");
check(f.db.store_token("treasure-"+name,*pid,1,4102444800LL),"treasure token");
return *pid;
};
auto befriend=[&](int64_t pid,const std::string& name){
check(act(f.token,"friend.request",{{"target",pid}})["ok"]&&act("treasure-"+name,"friend.accept",{{"target",1}})["ok"],"treasure friendship");
};
check(f.state["schema"]==14&&f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==0&&f.state["candy"]==0,"fresh treasure counters are empty");
int64_t ally=person("ally");
check(act(f.token,"friend.treasure",{{"target",ally},{"index",99}})["error"]=="friend_required"&&f.state["coins"]==0&&session("treasure-ally")["state"]["coins"]==0,"treasure requires a friend and ignores a forged event");
befriend(ally,"ally");
check(act(f.token,"friend.treasure",{{"target",ally}})["error"]=="treasure_locked"&&f.state["coins"]==0&&session("treasure-ally")["state"]["coins"]==0,"treasure stays locked before 1-1");
f.state["clearedStages"][0]=1;f.state["storyRankAt"]=f.now;f.replace(f.state);
auto battle=f.command("battle.begin");
check(f.send(battle)["ok"],"a story battle can stay open");
auto first=f.command("friend.treasure",{{"target",ally},{"index",99}});
auto found=f.send(first,rng);
check(found["ok"]&&found["friends"]["treasure"]["index"]==0&&found["friends"]["treasure"]["reward"]["coins"]==10&&found["friends"]["treasure"]["reward"]["candy"]==0&&f.state["coins"]==10&&f.state["candy"]==0&&f.state["balls"]==20&&f.state["treasurePlayed"]==1&&f.state["treasureSeen"]==J::array({ally})&&f.state["pendingBattle"]!="","the server picks the event and pays the visitor during a battle");
auto allyAfter=session("treasure-ally")["state"];
check(allyAfter["coins"]==0&&allyAfter["candy"]==0&&allyAfter["balls"]==20&&allyAfter["campLayout"]==J::array({-1,-1,-1}),"the friend's wallet and camp stay unchanged");
check(f.send(f.command("battle.abandon",{{"activityId",battle["requestId"]}}))["ok"]&&f.state["pendingBattle"]=="","the battle can still be abandoned");
check(f.send(first)["replayed"].get<bool>()&&f.state["coins"]==10,"replaying the search does not pay again");
check(act(f.token,"friend.treasure",{{"target",ally}})["error"]=="treasure_seen"&&f.state["coins"]==10,"the same camp can be searched once each day");
int64_t second=person("second"),third=person("third"),fourth=person("fourth");
befriend(second,"second");befriend(third,"third");befriend(fourth,"fourth");
check(act(f.token,"friend.treasure",{{"target",second}})["ok"]&&f.state["coins"]==20&&f.state["treasurePlayed"]==2,"the second camp pays the same band");
check(act(f.token,"friend.treasure",{{"target",third}})["ok"]&&f.state["coins"]==30&&f.state["treasurePlayed"]==3,"the third camp reaches the daily cap");
check(act(f.token,"friend.treasure",{{"target",fourth}})["error"]=="treasure_limit"&&f.state["coins"]==30&&f.state["balls"]==20&&session("treasure-fourth")["state"]["coins"]==0,"the fourth search is refused and does not spend a ball");
int coins=f.state["coins"].get<int>();
f.now+=86400;f.read();
check(f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==coins,"a new day restores searches without changing the wallet");
check(act(f.token,"friend.treasure",{{"target",ally}})["ok"]&&f.state["coins"]==40&&f.state["treasurePlayed"]==1,"the same camp can be searched again the next day");
for(size_t i=0;i<f.state["clearedStages"].size();++i) f.state["clearedStages"][i]=1;
f.replace(f.state);
int64_t fifth=person("fifth");befriend(fifth,"fifth");
auto deep=act(f.token,"friend.treasure",{{"target",fifth}});
check(deep["ok"]&&deep["friends"]["treasure"]["reward"]["coins"]==30&&deep["friends"]["treasure"]["reward"]["candy"]==5&&f.state["coins"]==70&&f.state["candy"]==5&&session("treasure-fifth")["state"]["coins"]==0&&session("treasure-fifth")["state"]["candy"]==0,"ten clears pay the top band from the event pool");
auto old=f.state;old["schema"]=13;old.erase("treasurePlayed");old.erase("treasureSeen");
f.replace(old);
check(f.state["schema"]==14&&f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==70&&f.state["candy"]==5,"schema13 migration adds empty treasure counters and pays nothing");
}
std::filesystem::remove_all(root);
if(argc>1) {std::ofstream output(argv[1],std::ios::binary);output<<parity.dump(2);check(output.good(),"parity vectors exported");}