Add formal enemies, remaining formal partners and synthesized UI SFX

- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to
  buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting.
- Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn):
  Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type
  in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes.
- Formal partners for all ten pets in the arena and portraits, including new
  VineSprite, ThunderLeopard and TideWhale.
- Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands,
  configurable Unity run_script timeout, companion static stage.
- Also includes concurrent work from another tool in this working tree
  (FlameLion, enemy combat FX, challenge mode, home explorer, server updates).
- Resources registered in Doc/ResDesc_*.md; verified on isolated :18081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
udx
2026-10-21 12:23:55 +08:00
co-authored by Claude Opus 5.5
parent 6c64210331
commit 89e0fa246f
780 changed files with 7117051 additions and 15906 deletions
+137 -16
View File
@@ -7,6 +7,7 @@
#include <cctype>
#include <ctime>
#include <stdexcept>
#include <string>
#include <vector>
#include <random>
#include <functional>
@@ -84,7 +85,29 @@ void migrate(J& s,int64_t now) {
while(static_cast<int>(s["clearedStages"].size())<stages)s["clearedStages"].push_back(0);
s["schema"]=9;
}
if(s.at("schema")!=9 || s.at("stages").size()!=config()["pets"].size() || s.at("shards").size()!=config()["pets"].size() || s.at("clearedStages").size()!=config()["stages"].size()) throw std::runtime_error("unsupported_save");
if(s.at("schema")==9) {s["schema"]=10;s["challengeBadges"]=zeros(5);s["challengeBestMs"]=zeros(5);}
// 10->11 stamps when the current confirmed scores were reached. No wallet or reward changes.
if(s.at("schema")==10) {
int clears=0;
for(size_t i=0;i<s["clearedStages"].size();++i) if(s["clearedStages"][i].get<int>()==1) clears++;
if(clears>0) s["storyRankAt"]=now;
else s["storyRankAt"]=J(static_cast<int64_t>(0));
J at=zeros(5);
for(int i=0;i<5;i++) if(s.at("challengeBestMs").at(i).get<int>()>0) at[i]=now;
s["challengeRankAt"]=at;s["schema"]=11;
}
if(s.at("schema")!=11 || s.at("stages").size()!=config()["pets"].size() || s.at("shards").size()!=config()["pets"].size() || s.at("clearedStages").size()!=config()["stages"].size() || s.at("challengeBadges").size()!=5 || s.at("challengeBestMs").size()!=5) throw std::runtime_error("unsupported_save");
for(int i=0;i<5;i++) if((s["challengeBadges"][i]!=0&&s["challengeBadges"][i]!=1)||!s["challengeBestMs"][i].is_number_integer()||s["challengeBestMs"][i].get<int>()<0||s["challengeBestMs"][i].get<int>()>120000) throw std::runtime_error("invalid_challenge_save");
if(!s.at("storyRankAt").is_number_integer()||!s.at("challengeRankAt").is_array()||s["challengeRankAt"].size()!=5) throw std::runtime_error(std::string("rank_shape ")+s["storyRankAt"].dump()+" "+s["challengeRankAt"].dump());
int64_t storyAt=s["storyRankAt"].get<int64_t>();
if(storyAt<0||storyAt>4000000000LL) throw std::runtime_error(std::string("rank_range ")+std::to_string(storyAt));
int confirmed=0;for(size_t i=0;i<s["clearedStages"].size();++i) if(s["clearedStages"][i].get<int>()==1) confirmed++;
if(confirmed==0&&storyAt!=0) throw std::runtime_error(std::string("rank_empty ")+std::to_string(storyAt)+" confirmed "+std::to_string(confirmed));
for(int i=0;i<5;i++) {
if(!s["challengeRankAt"][i].is_number_integer()) throw std::runtime_error(std::string("rank_trial_type ")+s["challengeRankAt"].dump());
int64_t at=s["challengeRankAt"][i].get<int64_t>();
if(at<0||at>4000000000LL||(s["challengeBestMs"][i].get<int>()==0&&at!=0)) throw std::runtime_error(std::string("rank_trial ")+std::to_string(at)+" best "+s["challengeBestMs"].dump());
}
if(!s.at("mails").is_array()||s["mails"].size()>100) throw std::runtime_error("invalid_mail_save");
std::vector<std::string> ids;
for(const auto& m:s["mails"]) {
@@ -177,10 +200,10 @@ J pinball(int aim) {
return J{{"path",path},{"bin",bin},{"candy",board["candy"][bin]},{"coins",board["coins"][bin]}};
}
int lane_position(int lane) {return lane==0?0:lane==1?-1:1;}
J battle_lanes(const J& s,int stageId) {
J battle_lanes(const J& s,const J& stage) {
struct Hero {int pet,slot,tier,skills=0;float hp,max,attack,cd=0,skill=0,shield=0,shieldTime=0,damage=0,healing=0,absorbed=0;};
struct Enemy {int index,type,lane;float hp,z=11,cd=0,slowTime=0,slow=1;};
const auto& stage=gameplay::stage(stageId);const auto& pets=config()["pets"];const auto& types=config()["enemies"];
int stageId=stage.at("id").get<int>();const auto& pets=config()["pets"];const auto& types=config()["enemies"];
std::vector<Hero> heroes;std::vector<Enemy> enemies;
for(int slot=0;slot<stage["lanes"].get<int>()*2;slot++) {
int pet=s["formation"][slot];if(pet<0)continue;const auto& p=pets[pet];int tier=s["stages"][pet];
@@ -241,7 +264,7 @@ J battle_lanes(const J& s,int stageId) {
}
// Old frozen results are already saved; only new battle.begin uses the current rule version.
J battle(const J& s,int stageId) {
if(config().value("battleRules",0)==2)return battle_lanes(s,stageId);
if(config().value("battleRules",0)==2)return battle_lanes(s,gameplay::stage(stageId));
struct Hero {int pet,slot;float hp,max,attack,cd=0,heal=0;};
struct Enemy {int index,type;float hp,z=11,cd=0;};
const auto& stage=gameplay::stage(stageId);const auto& pets=config().at("pets");const auto& types=config().at("enemies");
@@ -373,22 +396,63 @@ std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint
// The client supports at most 2x playback. A forged victory or elapsed time is never accepted.
s["battleReadyAt"]=now+static_cast<int64_t>(std::ceil(s["battleData"]["duration"].get<double>()/2));
s["battleSnapshot"]={{"level",s["level"]},{"stages",s["stages"]},{"formation",s["formation"]},{"stageId",stageId},{"config",config()},{"legacy",false}};
} else if(action=="challenge.begin") {
if(s["pendingShot"]!=""||s["pendingBattle"]!="") return "pending_activity";
int id=integer(c,"stageId");
const auto& list=config().at("challenges");
if(id<1||id>static_cast<int>(list.size())) return "invalid_challenge";
if(s["clearedStages"][0]!=1) return "challenge_locked";
const auto& challenge=list[id-1];
int element=challenge["allyElement"].get<int>();
int slots=challenge["lanes"].get<int>()*2;
J formation=J::array();for(int i=0;i<6;i++) formation.push_back(-1);
int placed=0;
for(int pet=0;pet<static_cast<int>(config()["pets"].size())&&placed<slots;pet++)
if(s["stages"][pet].get<int>()>0&&config()["pets"][pet]["element"].get<int>()==element) formation[placed++]=pet;
if(placed==0) return "need_element";
J sim=s;sim["formation"]=formation;
J data=battle_lanes(sim,challenge);
data["stageId"]=0;data["challengeId"]=id;
data["reward"]={{"coins",0},{"candy",0},{"balls",0},{"shardsPet",challenge["shardsPet"]},{"shards",challenge["shards"]}};
s["pendingBattle"]=c["requestId"];s["battleData"]=data;
s["battleReadyAt"]=now+static_cast<int64_t>(std::ceil(data["duration"].get<double>()/2));
s["battleSnapshot"]={{"level",s["level"]},{"stages",s["stages"]},{"formation",formation},{"stageId",0},{"challengeId",id},{"config",config()},{"legacy",false}};
} else if(action=="battle.finish"||action=="battle.abandon") {
if(!c.contains("activityId")||c["activityId"]!=s["pendingBattle"]||s["pendingBattle"]=="") return "activity_missing";
bool abandon=action=="battle.abandon";
if(!abandon&&now<s["battleReadyAt"].get<int64_t>()) return "activity_not_ready";
result=s["battleData"];if(abandon) result["won"]=false;
int stageId=result["stageId"];bool first=result["won"].get<bool>()&&s["clearedStages"][stageId-1]==0;result["firstClear"]=first;
if(first) {
s["clearedStages"][stageId-1]=1;if(stageId==1) s["cleared"]=true;
for(const char* key:{"coins","candy","balls"}) s[key]=s[key].get<int>()+result["reward"][key].get<int>();
int chest=first_clear_chest(stageId);
if(chest>=0) {s["chests"][chest]=s["chests"][chest].get<int>()+1;result["chest"]=chest;}
bool challenge=result.value("challengeId",0)>0;
if(challenge) {
int id=result["challengeId"].get<int>();
if(id<1||id>static_cast<int>(s["challengeBadges"].size())) return "invalid_challenge";
bool won=result["won"].get<bool>();
bool first=won&&s["challengeBadges"][id-1]==0;result["firstClear"]=first;result["shards"]=0;
if(first) {
int pet=result["reward"]["shardsPet"].get<int>(),shards=result["reward"]["shards"].get<int>();
s["shards"][pet]=s["shards"][pet].get<int>()+shards;s["challengeBadges"][id-1]=1;
result["shards"]=shards;result["pet"]=pet;
}
bool improved=false;
if(won) {
int ms=static_cast<int>(std::llround(result["duration"].get<double>()*1000.0));if(ms<1) ms=1;
int best=s["challengeBestMs"][id-1].get<int>();
if(best==0||ms<best) {s["challengeBestMs"][id-1]=ms;s["challengeRankAt"][id-1]=now;improved=true;}
}
result["bestMs"]=s["challengeBestMs"][id-1];result["improved"]=improved;
} else {
int stageId=result["stageId"];bool first=result["won"].get<bool>()&&s["clearedStages"][stageId-1]==0;result["firstClear"]=first;
if(first) {
s["clearedStages"][stageId-1]=1;if(stageId==1) s["cleared"]=true;s["storyRankAt"]=now;
for(const char* key:{"coins","candy","balls"}) s[key]=s[key].get<int>()+result["reward"][key].get<int>();
int chest=first_clear_chest(stageId);
if(chest>=0) {s["chests"][chest]=s["chests"][chest].get<int>()+1;result["chest"]=chest;}
}
}
result["id"]=s["pendingBattle"];s["lastBattleData"]=result;
s["lastBattle"]=s["pendingBattle"];s["pendingBattle"]="";
s.erase("battleData");s.erase("battleReadyAt");s.erase("battleSnapshot");
if(!abandon) daily_event(s,1);
if(!abandon&&!challenge) daily_event(s,1);
} else {
if(s["pendingBattle"]!=""||s["pendingShot"]!="") return "pending_activity";
if(action=="stage.select") {
@@ -533,6 +597,57 @@ J response(const J& s,int64_t now) {
}
return r;
}
// Confirmed story clears and challenge records only. A client cannot submit a score.
bool rank_score(const J& s,int board,int& score,int64_t& at) {
score=0;at=0;
auto time_ok=[&](int64_t stamp){return stamp>=0&&stamp<=4000000000LL;};
if(board==0) {
if(!s.contains("clearedStages")||!s["clearedStages"].is_array()) return false;
int n=0;for(const auto& v:s["clearedStages"]) if(v.is_number_integer()&&v.get<int>()==1) n++;
if(n<1||n>100) return false;
score=n;
if(s.contains("storyRankAt")&&s["storyRankAt"].is_number_integer()) at=s["storyRankAt"].get<int64_t>();
return time_ok(at);
}
int index=board-1;
if(!s.contains("challengeBestMs")||!s["challengeBestMs"].is_array()||index>=static_cast<int>(s["challengeBestMs"].size())||!s["challengeBestMs"][index].is_number_integer()) return false;
score=s["challengeBestMs"][index].get<int>();
if(score<1||score>120000) return false;
if(s.contains("challengeRankAt")&&s["challengeRankAt"].is_array()&&index<static_cast<int>(s["challengeRankAt"].size())&&s["challengeRankAt"][index].is_number_integer()) at=s["challengeRankAt"][index].get<int64_t>();
return time_ok(at);
}
std::string make_rank(sqlite3* db,const J& self,int64_t selfPid,int board,J& out) {
if(board<0||board>5) return "invalid_board";
bool higher=board==0;
struct Row {int64_t pid;std::string name;int score;int64_t at;};
std::vector<Row> rows;
Statement q(db,"SELECT u.pid, u.username, g.state FROM game_states g JOIN users u ON u.pid=g.pid");
while(q.step()==SQLITE_ROW) {
try {
int64_t pid=std::stoll(q.column(0));
std::string name=q.column(1);
if(name.empty()||name.size()>64) continue;
J state=pid==selfPid?self:J::parse(q.column(2));
int score=0;int64_t at=0;
if(!rank_score(state,board,score,at)) continue;
rows.push_back({pid,std::move(name),score,at});
} catch(...) {}
}
std::sort(rows.begin(),rows.end(),[&](const Row& a,const Row& b){
if(a.score!=b.score) return higher?a.score>b.score:a.score<b.score;
bool knownA=a.at>0,knownB=b.at>0;
if(knownA!=knownB) return knownA;
if(knownA&&a.at!=b.at) return a.at<b.at;
return a.pid<b.pid;
});
int selfPlace=0,selfScore=0;int64_t selfAt=0;
for(int i=0;i<static_cast<int>(rows.size());i++) if(rows[i].pid==selfPid) {selfPlace=i+1;selfScore=rows[i].score;selfAt=rows[i].at;break;}
J entries=J::array();
int shown=std::min(20,static_cast<int>(rows.size()));
for(int i=0;i<shown;i++) entries.push_back({{"pid",rows[i].pid},{"name",rows[i].name},{"score",rows[i].score},{"at",rows[i].at},{"place",i+1}});
out={{"board",board},{"total",static_cast<int>(rows.size())},{"selfPlace",selfPlace},{"selfScore",selfScore},{"selfAt",selfAt},{"entries",entries}};
return "";
}
}
db::GameReply db::Database::game_request(const std::string& token,const std::string& command,int64_t now,const std::function<uint32_t(uint32_t)>& random) {
@@ -552,26 +667,32 @@ db::GameReply db::Database::game_request(const std::string& token,const std::str
Transaction tx(db_);J s;
{Statement q(db_,"SELECT state FROM game_states WHERE pid=?");q.number(1,*pid);if(q.step()==SQLITE_ROW) s=J::parse(q.column(0));else s=initial(now);}
auto previous=s;migrate(s,now);recover(s,now);sync_calendar(s,now);sync_mail(s,now);
J result=J::object(),r;std::string error;
bool replayed=false;
J result=J::object(),r,savedRank;std::string error;
bool replayed=false,keepRank=false;
if(!command.empty()) {
Statement q(db_,"SELECT request,response FROM game_operations WHERE pid=? AND request_id=?");q.number(1,*pid);q.text(2,id);
if(q.step()==SQLITE_ROW) {
if(q.column(0)!=c.dump()) return {409,"{\"ok\":false,\"error\":\"request_id_conflict\"}"};
auto old=J::parse(q.column(1));result=old["result"];error=old.value("error","");replayed=true;
if(old.contains("rank")) {savedRank=old["rank"];keepRank=true;}
} else {
auto before=s;
try {error=apply(s,c,now,result,random);} catch(const J::exception&) {error="invalid_input";} catch(const std::invalid_argument&) {error="invalid_input";}
if(!error.empty()) s=before;
try {
if(c.at("action")=="rank.list") {error=make_rank(db_,s,*pid,integer(c,"stageId"),savedRank);if(error.empty()) keepRank=true;}
else error=apply(s,c,now,result,random);
} catch(const J::exception&) {error="invalid_input";} catch(const std::invalid_argument&) {error="invalid_input";}
if(!error.empty()) {s=before;keepRank=false;}
}
}
if(s!=previous) s["revision"]=previous["revision"].get<int>()+1;
r=response(s,now);r["result"]=result;r["ok"]=error.empty();r["error"]=error;r["replayed"]=replayed;r["action"]=command.empty()?"":c["action"].get<std::string>();
if(keepRank) r["rank"]=savedRank;
{Statement q(db_,"INSERT INTO game_states(pid,state) VALUES(?,?) ON CONFLICT(pid) DO UPDATE SET state=excluded.state");q.number(1,*pid);q.text(2,s.dump());q.step();}
if(!command.empty()&&!replayed) {
Statement q(db_,"INSERT INTO game_operations(pid,request_id,request,response) VALUES(?,?,?,?)");
q.number(1,*pid);q.text(2,id);q.text(3,c.dump());q.text(4,r.dump());q.step();
}
tx.commit();return {200,r.dump()};
} catch(...) {return {500,"{\"ok\":false,\"error\":\"storage_unavailable\"}"};}
} catch(const std::exception& e) {return {500,std::string("{\"ok\":false,\"error\":\"")+e.what()+"\"}"};}
catch(...) {return {500,"{\"ok\":false,\"error\":\"storage_unavailable\"}"};}
}
+16
View File
@@ -81,6 +81,22 @@ const nlohmann::json& config() {
for(int i=0;i<3;i++) if(camp["decorations"][i].at("id")!=i||camp["decorations"][i].at("coins").get<int>()<=0) throw std::runtime_error("Invalid decoration config");
if(pool.at("weights").size()!=j.at("pets").size()||pool.at("tutorialPet")!=2||pool.at("duplicateLimit").get<int>()<1) throw std::runtime_error("Invalid capture pool");
for(const auto& weight:pool.at("weights")) if(!weight.is_number_integer()||weight.get<int>()<1||weight.get<int>()>1000) throw std::runtime_error("Invalid capture weight");
const auto& challenges=j.at("challenges");
if(!challenges.is_array()||challenges.size()!=5) throw std::runtime_error("Invalid challenge config");
for(int i=0;i<5;i++) {
const auto& challenge=challenges[i];
if(challenge.at("id")!=i+1||challenge.at("enemyIds").size()!=9||challenge.at("enemyLanes").size()!=9) throw std::runtime_error("Invalid challenge wave");
int lanes=challenge.at("lanes");if(lanes!=1) throw std::runtime_error("Invalid challenge lanes");
for(const auto& enemy:challenge["enemyIds"]) if(!enemy.is_number_integer()||enemy.get<int>()<0||enemy.get<int>()>=5) throw std::runtime_error("Invalid challenge enemy");
for(const auto& lane:challenge["enemyLanes"]) if(!lane.is_number_integer()||lane.get<int>()!=0) throw std::runtime_error("Invalid challenge lane");
if(challenge.at("hpScale").get<float>()<=0||challenge.at("timeout").get<float>()<=0||challenge.at("durability").get<int>()<1) throw std::runtime_error("Invalid challenge combat");
int ally=challenge.at("allyElement").get<int>(),enemyElement=challenge.at("element").get<int>();
if(ally<1||ally>5||enemyElement<1||enemyElement>5||ally==enemyElement) throw std::runtime_error("Invalid challenge element");
int pet=challenge.at("shardsPet").get<int>(),shards=challenge.at("shards").get<int>();
if(pet<0||pet>=static_cast<int>(j["pets"].size())||j["pets"][pet]["element"].get<int>()!=ally||shards<1||shards>100) throw std::runtime_error("Invalid challenge reward");
if(challenge.at("badge").get<std::string>().empty()||challenge.at("obtain").get<std::string>().empty()||challenge.at("code").get<std::string>().empty()) throw std::runtime_error("Invalid challenge text");
for(const char* key:{"coins","candy","balls"}) if(challenge.at("reward").at(key).get<int>()!=0) throw std::runtime_error("Challenge reward must stay on the badge");
}
j["version"]=kGameplayVersion;
return j;
}();
+103 -15
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);check(r.status==200,"read succeeds");auto j=J::parse(r.body);state=j["state"];return j;
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;
}
J command(const std::string& action,J extra=J::object()) {
extra["action"]=action;extra["revision"]=state["revision"];
@@ -159,13 +159,13 @@ int main(int argc,char** argv) {
auto finish=f.command("battle.finish",{{"activityId",start["requestId"]}});auto result=f.send(finish);
check(result["result"]["won"],"configured stage wins at recommended level");
parity["entries"].push_back({{"snapshot",b["battle"]["snapshot"]},{"result",result["result"]}});
check(result["result"]["firstClear"]&&f.state["clearedStages"][id-1]==1,"per-stage clear recorded");
check(result["result"]["firstClear"]&&f.state["clearedStages"][id-1]==1&&f.state["storyRankAt"]==f.now,"per-stage clear recorded at the confirmation time");
for(const char* key:{"coins","candy","balls"}) check(f.state[key].get<int>()==before[key].get<int>()+cfg["stages"][id-1]["reward"][key].get<int>(),"configured first-clear reward exact");
before=f.state;f.send(finish);check(f.state==before,"per-stage settlement replay once only");
// A fresh repeated battle still receives no first-clear reward.
start=f.command("battle.begin");b=f.send(start);f.now=b["battle"]["readyAt"].get<int64_t>();before=f.state;
result=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(result["result"]["won"]&&!result["result"]["firstClear"].get<bool>()&&f.state["coins"]==before["coins"]&&f.state["candy"]==before["candy"]&&f.state["balls"]==before["balls"],"repeat victory grants nothing");
check(result["result"]["won"]&&!result["result"]["firstClear"].get<bool>()&&f.state["coins"]==before["coins"]&&f.state["candy"]==before["candy"]&&f.state["balls"]==before["balls"]&&f.state["storyRankAt"]==before["storyRankAt"],"repeat victory grants nothing and keeps the rank time");
}
db::Database reopened(f.path);check(J::parse(reopened.game_request(f.token,"",f.now).body)["state"]==f.state,"all stage progress survives reconnect");
for(int id:{1,5,10,15,20}) for(int level:{1,5,20}) for(int party=0;party<6;party++) {
@@ -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"]==9&&f.state["stages"][0]==1&&f.state["stages"][1]==2&&f.state["stages"][2]==1&&f.state["stages"].size()==10&&f.state["shards"][0]==12&&f.state["shards"][1]==25&&f.state["shards"][2]==30&&f.state["shards"].size()==10&&f.state["formation"]==J({2,1,-1,-1,-1,-1}),"G2 migration preserves owned evolutions, shards and formation");
check(f.state["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["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"]==9&&f.state["clearedStages"][0]==1&&f.state["clearedStages"].size()==20&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["goalClaimed"],"G1 completed save migrates without reward or reset");
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");
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"]==9&&f.state["campLayout"]==J({-1,-1,-1})&&f.state["decorations"]==J({0,0,0})&&f.state["chests"]==J({0,0,0}),"fresh camp is empty, no free purchases");
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");
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"]==9&&f.state["coins"]==640&&f.state["tickets"]==9&&f.state["shards"][0]==77&&f.state["stages"].size()==10&&f.state["captureCount"]==12&&f.state["duplicateStreak"]==3&&f.state["campLayout"]==J({-1,-1,-1}),"schema3 migration preserves collection wallet and initializes empty camp");
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");
auto beforeRead=f.state;f.read();check(f.state==beforeRead,"camp reads grant no extra production");
f.now+=600;f.read();check(f.state["balls"]==21,"camp uses existing single recovery source");
}
{
Fixture f((root/"daily.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];int64_t midnight=(static_cast<int64_t>(day)+1)*86400-28800;
check(f.state["schema"]==9&&f.state["signinDays"]==1&&f.state["dailyProgress"]==J({0,0,0})&&f.state["stages"].size()==10,"first server day unlocks day1 without fabricated events");
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");
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"]==9&&f.state["coins"]==777&&f.state["campLayout"]==J({-1,0,-1})&&f.state["campInteractions"]==12&&f.state["shots"]==100&&f.state["dailyProgress"]==J({0,0,0})&&f.state["signinDays"]==1&&f.state["signinClaimed"]==J({0,0,0,0,0,0,0})&&f.state["pendingShot"]==start["requestId"],"schema4 migration preserves wallet camp and pending shot; no retroactive daily progress or sign-in award");
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");
f.now=launched["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",start["requestId"]}}));check(f.state["dailyProgress"][0]==1,"pre-migration pending shot counts once when actually settled");
}
{
Fixture f((root/"shop.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];
check(f.state["schema"]==9&&f.state["shopBought"]==J({0,0,0})&&f.state["balls"]==20&&f.state["chests"]==J({0,0,0}),"fresh shop stock has no automatic supply award");
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");
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"]==9&&f.state["coins"]==coins&&f.state["tickets"]==tickets&&f.state["dailyClaimed"]==J({1,0,0})&&f.state["shopBought"]==J({0,0,0})&&f.state["campLayout"]==J({-1,0,-1}),"schema5 migration preserves wallets daily and decorations, initializes only shop limits");
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");
}
{
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"]==9&&f.state["mails"].size()==2&&f.state["balls"]==20&&f.state["candy"]==0&&f.state["chests"]==J({0,0,0}),"schema7 adds letters, no automatic wallet award");
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");
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"]==9&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["balls"]==20&&f.state["mails"].size()==2&&f.state["shopBought"]==J({1,0,0})&&f.state["signinClaimed"]==J({1,0,0,0,0,0,0}),"schema6 migration adds mail only, preserves wallet and daily stock");
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");
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"]==9&&f.state["chests"]==J({0,0,0})&&f.state["chestDailyClaimed"]==0&&f.state["balls"]==20&&f.state["stages"].size()==10,"fresh chest inventory is empty and grants nothing");
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");
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,7 +593,95 @@ 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"]==9&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["chests"]==J({0,0,0})&&f.state["chestDailyClaimed"]==0&&f.state["mails"].size()==2&&f.state["stages"].size()==10&&f.state["clearedStages"].size()==20,"schema7 migration preserves wallet and mail, initializes empty chests without free rewards");
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");
}
{
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");
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");
s=f.stored();s["clearedStages"][0]=1;f.replace(s);before=f.state;auto story=before["formation"];
check(f.send(f.command("challenge.begin",{{"stageId",2}}))["error"]=="need_element"&&f.state==before,"grass trial explains itself by refusing until a grass partner is owned");
auto start=f.command("challenge.begin",{{"stageId",1}});auto began=f.send(start);
check(began["ok"]&&began["battle"]["snapshot"]["challengeId"]==1&&began["battle"]["snapshot"]["formation"]==J({1,-1,-1,-1,-1,-1})&&f.state["formation"]==story&&f.state["pendingBattle"]==start["requestId"],"fire trial deploys only the owned fire partner and leaves the story formation");
check(f.send(f.command("battle.begin"))["error"]=="pending_activity","a running trial blocks a story battle");
auto abandon=f.command("battle.abandon",{{"activityId",start["requestId"]}});
check(f.send(abandon)["result"]["won"]==false&&f.state["challengeBadges"][0]==0&&f.state["shards"][1]==0&&f.state["dailyProgress"][1]==0&&f.state["challengeRankAt"][0]==0,"leaving a trial grants nothing and does not count as a story adventure");
start=f.command("challenge.begin",{{"stageId",1}});began=f.send(start);f.now=began["battle"]["readyAt"].get<int64_t>();
auto end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(end["result"]["won"]&&end["result"]["firstClear"]&&end["result"]["shards"]==10&&end["result"]["pet"]==1&&f.state["shards"][1]==10&&f.state["challengeBadges"][0]==1&&f.state["challengeBestMs"][0].get<int>()>0&&f.state["challengeRankAt"][0]==f.now&&f.state["coins"]==640&&f.state["dailyProgress"][1]==0,"first fire clear pays directed shards once and records the time");
int best=f.state["challengeBestMs"][0];int64_t bestAt=f.state["challengeRankAt"][0];
start=f.command("challenge.begin",{{"stageId",1}});began=f.send(start);f.now=began["battle"]["readyAt"].get<int64_t>();
end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(end["result"]["won"]&&!end["result"]["firstClear"].get<bool>()&&!end["result"]["improved"].get<bool>()&&f.state["shards"][1]==10&&f.state["challengeBestMs"][0]==best&&f.state["challengeRankAt"][0]==bestAt,"a repeat clear keeps the badge, the shards and the record");
start=f.command("challenge.begin",{{"stageId",3}});began=f.send(start);
check(began["ok"]&&began["battle"]["snapshot"]["formation"][0]==0,"rock trial accepts the owned rock partner");
f.now=began["battle"]["readyAt"].get<int64_t>();
end=f.send(f.command("battle.finish",{{"activityId",start["requestId"]}}));
check(end["result"]["won"]&&end["result"]["pet"]==0&&f.state["shards"][0]==17,"rock trial is winnable at level 1 and adds shards to the existing seven");
}
{
Fixture f((root/"rank.db").string());
int coins=f.state["coins"];
check(f.state["schema"]==11&&f.state["storyRankAt"]==0&&coins==20,"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"]==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");
auto paint=[&](int clears,int64_t at,int best,int64_t bestAt){auto s=f.stored();for(int i=0;i<static_cast<int>(s["clearedStages"].size());i++) s["clearedStages"][i]=i<clears?1:0;s["storyRankAt"]=at;s["challengeBestMs"][0]=best;s["challengeRankAt"][0]=best>0?bestAt:0;return s;};
auto write=[&](int64_t pid,J state){
std::string data=state.dump(),escaped;for(char c:data){escaped+=c;if(c=='\'') escaped+='\'';}
f.sql("UPDATE game_states SET state='"+escaped+"' WHERE pid="+std::to_string(pid));
};
auto add=[&](const std::string& name,J state){
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"rank player");
check(f.db.store_token("token-"+name,*pid,1,4102444800LL),"rank token");
check(f.db.game_request("token-"+name,"",f.now).status==200,"rank player save");
write(*pid,state);return *pid;
};
write(1,paint(2,f.now-80,5000,f.now-10));
add("leader",paint(3,f.now,0,0));
add("early",paint(2,f.now-100,4000,f.now));
add("late",paint(2,f.now-50,5000,f.now-10));
add("untimed",paint(2,0,0,0));
add("absent",paint(0,0,0,0));
f.read();
auto story=f.send(f.command("rank.list",{{"stageId",0}}));
auto rows=story["rank"]["entries"];
check(story["ok"]&&story["rank"]["total"]==5&&story["rank"]["selfPlace"]==3&&rows.size()==5&&rows[0]["name"]=="leader"&&rows[1]["name"]=="early"&&rows[2]["name"]=="tester"&&rows[3]["name"]=="late"&&rows[4]["name"]=="untimed"&&f.state["coins"]==640,"story board orders confirmed clears, then time, then player id");
auto trial=f.send(f.command("rank.list",{{"stageId",1}}));rows=trial["rank"]["entries"];
check(trial["ok"]&&trial["rank"]["total"]==3&&trial["rank"]["selfPlace"]==2&&rows[0]["name"]=="early"&&rows[0]["score"]==4000&&rows[1]["name"]=="tester"&&rows[2]["name"]=="late","trial board orders the shorter confirmed time first");
f.sql("UPDATE game_states SET state='{' WHERE pid=(SELECT pid FROM users WHERE username='absent')");
auto kept=f.send(f.command("rank.list",{{"stageId",0}}));
check(kept["ok"]&&kept["rank"]["total"]==5,"a broken save is skipped and the confirmed board remains");
}
{
Fixture f((root/"rankcap.db").string());
for(int i=0;i<22;i++) {
std::string name="cap"+std::to_string(i),token="cap-"+name;
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"cap player");
check(f.db.store_token(token,*pid,1,4102444800LL),"cap token");
check(f.db.game_request(token,"",f.now).status==200,"cap save");
auto s=f.stored();int score=i<20?20-i:1;
s["clearedStages"]=cleared_prefix(static_cast<int>(s["clearedStages"].size()),score);s["storyRankAt"]=f.now-score*10-i;
std::string data=s.dump(),escaped;for(char c:data){escaped+=c;if(c=='\'') escaped+='\'';}
f.sql("UPDATE game_states SET state='"+escaped+"' WHERE pid="+std::to_string(*pid));
}
auto board=f.send(f.command("rank.list",{{"stageId",0}}));
check(board["ok"]&&board["rank"]["total"]==22&&board["rank"]["entries"].size()==20&&board["rank"]["entries"][0]["score"]==20&&board["rank"]["selfPlace"]==0&&f.state["coins"]==20,"the page keeps the top 20 and leaves an unranked player off the board");
}
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");}