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:
+137
-16
@@ -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\"}"};}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}();
|
||||
|
||||
Reference in New Issue
Block a user