1050 lines
69 KiB
C++
1050 lines
69 KiB
C++
#include "db.hpp"
|
|
#include "game_config.hpp"
|
|
#include "combat_runtime.hpp"
|
|
#include "crypto.hpp"
|
|
#include "json.hpp"
|
|
#include "sqlite3.h"
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cctype>
|
|
#include <ctime>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <vector>
|
|
#include <random>
|
|
#include <functional>
|
|
|
|
namespace {
|
|
bool identifier(const std::string& id);
|
|
using J = nlohmann::json;
|
|
const J& config() { return gameplay::config(); }
|
|
const J& economy() { return config().at("economy"); }
|
|
// Statements and transactions are scoped: a disk/constraint failure never publishes half a command.
|
|
struct Statement {
|
|
sqlite3_stmt* p = nullptr;
|
|
Statement(sqlite3* db, const char* sql) {
|
|
if (sqlite3_prepare_v2(db, sql, -1, &p, nullptr) != SQLITE_OK) throw std::runtime_error("prepare");
|
|
}
|
|
~Statement() { sqlite3_finalize(p); }
|
|
void number(int i, int64_t n) { sqlite3_bind_int64(p, i, n); }
|
|
void text(int i, const std::string& s) { sqlite3_bind_text(p, i, s.c_str(), -1, SQLITE_TRANSIENT); }
|
|
int step() {
|
|
int rc = sqlite3_step(p);
|
|
if (rc != SQLITE_ROW && rc != SQLITE_DONE) throw std::runtime_error("step");
|
|
return rc;
|
|
}
|
|
std::string column(int i) { return reinterpret_cast<const char*>(sqlite3_column_text(p, i)); }
|
|
};
|
|
struct Transaction {
|
|
sqlite3* db;
|
|
bool committed = false;
|
|
explicit Transaction(sqlite3* d) : db(d) {
|
|
if (sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr) != SQLITE_OK) throw std::runtime_error("begin");
|
|
}
|
|
void commit() {
|
|
if (sqlite3_exec(db, "COMMIT", nullptr, nullptr, nullptr) != SQLITE_OK) throw std::runtime_error("commit");
|
|
committed = true;
|
|
}
|
|
~Transaction() { if (!committed) sqlite3_exec(db, "ROLLBACK", nullptr, nullptr, nullptr); }
|
|
};
|
|
J zeros(int n) {J a=J::array();for(int i=0;i<n;i++)a.push_back(0);return a;}
|
|
bool has_id(const J& list,int64_t id) {
|
|
if(!list.is_array()) return false;
|
|
for(size_t i=0;i<list.size();++i) if(list[i].is_number_integer()&&list[i].get<int64_t>()==id) return true;
|
|
return false;
|
|
}
|
|
void erase_id(J& list,int64_t id) {
|
|
J next=J::array();
|
|
for(size_t i=0;i<list.size();++i) if(!list[i].is_number_integer()||list[i].get<int64_t>()!=id) next.push_back(list[i]);
|
|
list=next;
|
|
}
|
|
void add_id(J& list,int64_t id) {if(!has_id(list,id)) list.push_back(id);}
|
|
J initial(int64_t now) {
|
|
const int pets=static_cast<int>(config()["pets"].size()),stages=static_cast<int>(config()["stages"].size());
|
|
J owned=zeros(pets);owned[0]=1;if(pets>1)owned[1]=1;
|
|
J formation=J::array();formation.push_back(0);if(pets>1)formation.push_back(1);while(static_cast<int>(formation.size())<6)formation.push_back(-1);
|
|
return J{{"schema",4},{"campLayout",{-1,-1,-1}},{"decorations",{0,0,0}},{"restPet",0},{"campInteractions",0},{"captureTutorialDone",false},{"captureCount",0},{"duplicateStreak",0},{"selectedStage",1},{"clearedStages",zeros(stages)},{"revision",0},{"coins",0},{"candy",0},{"balls",economy()["initialBalls"]},{"tickets",0},
|
|
{"level",1},{"shots",0},{"stages",owned},{"shards",zeros(pets)},
|
|
{"formation",formation},{"cleared",false},{"goalClaimed",false},
|
|
{"pendingShot",""},{"lastShot",""},{"pendingAim",0},{"lastCandy",0},{"lastCoins",0},
|
|
{"pendingBattle",""},{"lastBattle",""},{"ballRecoveryUnix",now}};
|
|
}
|
|
void migrate(J& s,int64_t now) {
|
|
if(s.at("schema")==1) {
|
|
s["schema"]=2;s["selectedStage"]=1;s["clearedStages"]={s.at("cleared").get<bool>()?1:0,0,0,0,0};
|
|
if(s.contains("battleData")) {s["battleData"]["stageId"]=1;s["battleData"]["reward"]={{"coins",80},{"candy",40},{"balls",3}};s["battleSnapshot"]["legacy"]=true;}
|
|
if(s.contains("lastBattleData")) {s["lastBattleData"]["stageId"]=1;s["lastBattleData"]["reward"]={{"coins",80},{"candy",40},{"balls",3}};}
|
|
}
|
|
if(s.at("schema")==2) {
|
|
if(s.at("stages").size()!=3||s.at("shards").size()!=3) throw std::runtime_error("invalid_old_save");
|
|
s["schema"]=3;s["captureTutorialDone"]=s["stages"][2].get<int>()>0;
|
|
s["captureCount"]=0;s["duplicateStreak"]=0;
|
|
// G2 introduced the two later chapter-one partners; the remaining roster is widened by 8->9.
|
|
for(int i=0;i<2;i++) {s["stages"].push_back(0);s["shards"].push_back(0);}
|
|
}
|
|
if(s.at("schema")==3) {s["schema"]=4;s["campLayout"]={-1,-1,-1};s["decorations"]={0,0,0};s["restPet"]=0;s["campInteractions"]=0;}
|
|
if(s.at("schema")==4) {s["schema"]=5;s["dailyDay"]=-1;s["dailyProgress"]={0,0,0};s["dailyClaimed"]={0,0,0};s["signinDays"]=0;s["signinClaimed"]={0,0,0,0,0,0,0};}
|
|
if(s.at("schema")==5) {s["schema"]=6;s["shopBought"]={0,0,0};}
|
|
if(s.at("schema")==6) {
|
|
s["schema"]=7;s["mails"]=J::array();
|
|
for(const auto& m:config()["mail"]["initial"]) s["mails"].push_back({{"mailId",m["mailId"]},{"title",m["title"]},{"body",m["body"]},{"reward",m["reward"]},{"sentAt",now},{"expiresAt",now+config()["mail"]["lifetimeSeconds"].get<int>()},{"read",false},{"claimed",false},{"expired",false}});
|
|
}
|
|
if(s.at("schema")==7) {s["schema"]=8;s["chests"]={0,0,0};s["chestDailyClaimed"]=0;}
|
|
if(s.at("schema")==8) {
|
|
// 8->9 only widens the roster and chapter list; no partner, stage clear or reward is granted.
|
|
const int pets=static_cast<int>(config()["pets"].size()),stages=static_cast<int>(config()["stages"].size());
|
|
if(static_cast<int>(s["stages"].size())>pets||static_cast<int>(s["shards"].size())>pets||static_cast<int>(s["clearedStages"].size())>stages) throw std::runtime_error("invalid_old_save");
|
|
while(static_cast<int>(s["stages"].size())<pets)s["stages"].push_back(0);
|
|
while(static_cast<int>(s["shards"].size())<pets)s["shards"].push_back(0);
|
|
while(static_cast<int>(s["clearedStages"].size())<stages)s["clearedStages"].push_back(0);
|
|
s["schema"]=9;
|
|
}
|
|
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;
|
|
}
|
|
// 11->12 adds empty friend lists and today's gift counters. No currency or item is granted.
|
|
if(s.at("schema")==11) {
|
|
s["friendIds"]=J::array();s["friendIn"]=J::array();s["friendOut"]=J::array();s["friendBlocked"]=J::array();
|
|
s["giftSent"]=J::array();s["giftFrom"]=J::array();s["schema"]=12;
|
|
}
|
|
// 12->13 adds today's fishing attempts and the best confirmed score. No currency is granted.
|
|
if(s.at("schema")==12) {
|
|
s["fishPlayed"]=0;s["fishBest"]=0;s["pendingFish"]="";s["schema"]=13;
|
|
}
|
|
// 13->14 adds today's treasure attempts. No currency is granted.
|
|
if(s.at("schema")==13) {
|
|
s["treasurePlayed"]=0;s["treasureSeen"]=J::array();s["schema"]=14;
|
|
}
|
|
if(s.at("schema")!=14 || 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["storyRankAt"].get<int64_t>()<0||!s.at("challengeRankAt").is_array()||s["challengeRankAt"].size()!=5) throw std::runtime_error("invalid_rank_save");
|
|
for(int i=0;i<5;i++) if(!s["challengeRankAt"][i].is_number_integer()||s["challengeRankAt"][i].get<int64_t>()<0) throw std::runtime_error("invalid_rank_save");
|
|
const auto& friends=config().at("friends");
|
|
auto friendList=[&](const char* key,int cap){
|
|
const auto& list=s.at(key);
|
|
if(!list.is_array()||static_cast<int>(list.size())>cap) throw std::runtime_error("invalid_friend_save");
|
|
std::vector<int64_t> seen;
|
|
for(size_t i=0;i<list.size();++i) {
|
|
if(!list[i].is_number_integer()) throw std::runtime_error("invalid_friend_save");
|
|
int64_t id=list[i].get<int64_t>();
|
|
if(id<=0||std::find(seen.begin(),seen.end(),id)!=seen.end()) throw std::runtime_error("invalid_friend_save");
|
|
seen.push_back(id);
|
|
}
|
|
};
|
|
friendList("friendIds",friends.at("limit").get<int>());friendList("friendIn",friends.at("requestLimit").get<int>());friendList("friendOut",friends.at("requestLimit").get<int>());friendList("friendBlocked",friends.at("blockLimit").get<int>());
|
|
friendList("giftSent",friends.at("sendLimit").get<int>());friendList("giftFrom",friends.at("receiveLimit").get<int>());
|
|
auto clash=[&](const char* a,const char* b){for(size_t i=0;i<s[a].size();++i) if(has_id(s[b],s[a][i].get<int64_t>())) throw std::runtime_error("invalid_friend_save");};
|
|
clash("friendIds","friendIn");clash("friendIds","friendOut");clash("friendIds","friendBlocked");
|
|
clash("friendIn","friendOut");clash("friendIn","friendBlocked");clash("friendOut","friendBlocked");
|
|
clash("giftSent","giftFrom");
|
|
const auto& fishing=config().at("fishing");
|
|
if(!s.contains("fishPlayed")||!s["fishPlayed"].is_number_integer()||s["fishPlayed"].get<int>()<0||s["fishPlayed"].get<int>()>fishing.at("dailyLimit").get<int>()) throw std::runtime_error("invalid_fish_save");
|
|
if(!s.contains("fishBest")||!s["fishBest"].is_number_integer()||s["fishBest"].get<int>()<0||s["fishBest"].get<int>()>fishing.at("fishCount").get<int>()*2) throw std::runtime_error("invalid_fish_save");
|
|
if(!s.contains("pendingFish")||!s["pendingFish"].is_string()||s["pendingFish"].get<std::string>().size()>64) throw std::runtime_error("invalid_fish_save");
|
|
if(s["pendingFish"]=="") { if(s.contains("fishData")) throw std::runtime_error("invalid_fish_save"); }
|
|
else {
|
|
if(!identifier(s["pendingFish"])) throw std::runtime_error("invalid_fish_save");
|
|
const auto& data=s.at("fishData");
|
|
if(!data.is_object()||!data.at("startedAt").is_number_integer()||data["startedAt"].get<int64_t>()<=0) throw std::runtime_error("invalid_fish_save");
|
|
const auto& spots=data.at("fish");
|
|
int durationMs=fishing.at("duration").get<int>()*1000, window=fishing.at("windowMs").get<int>();
|
|
if(!spots.is_array()||spots.size()!=fishing.at("fishCount").get<size_t>()) throw std::runtime_error("invalid_fish_save");
|
|
for(const auto& spot:spots) {
|
|
int at=spot.at("at").get<int>(),w=spot.at("window").get<int>(),points=spot.at("points").get<int>();
|
|
if(at<0||at>durationMs-window||w!=window||(points!=1&&points!=2)) throw std::runtime_error("invalid_fish_save");
|
|
}
|
|
}
|
|
const auto& treasure=config().at("treasure");
|
|
if(!s.contains("treasurePlayed")||!s["treasurePlayed"].is_number_integer()||s["treasurePlayed"].get<int>()<0||s["treasurePlayed"].get<int>()>treasure.at("dailyLimit").get<int>()) throw std::runtime_error("invalid_treasure_save");
|
|
friendList("treasureSeen",treasure.at("dailyLimit").get<int>());
|
|
if(static_cast<int>(s["treasureSeen"].size())!=s["treasurePlayed"].get<int>()) throw std::runtime_error("invalid_treasure_save");
|
|
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"]) {
|
|
auto id=m.at("mailId").get<std::string>();
|
|
if(id.empty()||id.size()>64||std::find(ids.begin(),ids.end(),id)!=ids.end()||m.at("title").get<std::string>().empty()||m.at("body").get<std::string>().empty()||m.at("sentAt").get<int64_t>()<=0||m.at("expiresAt").get<int64_t>()<=m.at("sentAt").get<int64_t>()) throw std::runtime_error("invalid_mail_record");
|
|
ids.push_back(id);for(const char* k:{"read","claimed","expired"}) if(!m.at(k).is_boolean()) throw std::runtime_error("invalid_mail_status");
|
|
for(const char* k:{"coins","candy","balls","tickets"}) if(!m.at("reward").at(k).is_number_integer()||m["reward"][k].get<int>()<0||m["reward"][k].get<int>()>10000) throw std::runtime_error("invalid_mail_reward");
|
|
}
|
|
if(s.at("shopBought").size()!=3) throw std::runtime_error("invalid_shop_save");
|
|
for(int i=0;i<3;i++) if(s["shopBought"][i].get<int>()<0||s["shopBought"][i].get<int>()>config()["shop"]["goods"][i]["dailyLimit"].get<int>()) throw std::runtime_error("invalid_shop_count");
|
|
if(s.at("chests").size()!=3||s.at("chestDailyClaimed").get<int>()<0||s.at("chestDailyClaimed").get<int>()>1) throw std::runtime_error("invalid_chest_save");
|
|
for(int i=0;i<3;i++) if(s["chests"][i].get<int>()<0||s["chests"][i].get<int>()>100000) throw std::runtime_error("invalid_chest_count");
|
|
if(s.at("campLayout").size()!=3||s.at("decorations").size()!=3) throw std::runtime_error("invalid_camp_save");
|
|
bool used[3]={};
|
|
for(const auto& id:s["campLayout"]) {int i=id.get<int>();if(i < -1 || i>2 || (i>=0 && (used[i] || s["decorations"][i]!=1))) throw std::runtime_error("invalid_camp_layout");if(i>=0) used[i]=true;}
|
|
for(const auto& n:s["decorations"]) if(n!=0&&n!=1) throw std::runtime_error("invalid_camp_inventory");
|
|
int pet=s.at("restPet");if(pet<0||pet>=static_cast<int>(config()["pets"].size())||s["stages"][pet]==0||s.at("campInteractions").get<int>()<0) throw std::runtime_error("invalid_rest_pet");
|
|
if(s.at("dailyProgress").size()!=3||s.at("dailyClaimed").size()!=3||s.at("signinClaimed").size()!=7||s.at("dailyDay").get<int>()< -1||s.at("signinDays").get<int>()<0||s.at("signinDays").get<int>()>7) throw std::runtime_error("invalid_daily_save");
|
|
for(int i=0;i<3;i++) if(s["dailyProgress"][i].get<int>()<0||s["dailyProgress"][i].get<int>()>config()["daily"]["tasks"][i]["target"].get<int>()||(s["dailyClaimed"][i]!=0&&s["dailyClaimed"][i]!=1)) throw std::runtime_error("invalid_daily_progress");
|
|
for(int i=0;i<7;i++) if((s["signinClaimed"][i]!=0&&s["signinClaimed"][i]!=1)||(s["signinClaimed"][i]==1&&i>=s["signinDays"].get<int>())) throw std::runtime_error("invalid_signin_save");
|
|
}
|
|
void sync_calendar(J& s,int64_t now) {
|
|
int day=static_cast<int>((now+config()["daily"]["utcOffsetSeconds"].get<int>())/86400);
|
|
if(day>s["dailyDay"].get<int>()) {
|
|
s["dailyDay"]=day;s["dailyProgress"]={0,0,0};s["dailyClaimed"]={0,0,0};
|
|
s["shopBought"]={0,0,0};s["chestDailyClaimed"]=0;
|
|
s["signinDays"]=std::min(7,s["signinDays"].get<int>()+1);
|
|
if(s.contains("giftSent")) {s["giftSent"]=J::array();s["giftFrom"]=J::array();}
|
|
if(s.contains("fishPlayed")) s["fishPlayed"]=0;
|
|
if(s.contains("treasurePlayed")) {s["treasurePlayed"]=0;s["treasureSeen"]=J::array();}
|
|
}
|
|
}
|
|
void daily_event(J& s,int task) {s["dailyProgress"][task]=std::min(config()["daily"]["tasks"][task]["target"].get<int>(),s["dailyProgress"][task].get<int>()+1);}
|
|
void grant(J& s,const J& reward) {for(const char* key:{"coins","candy","balls","tickets"}) if(reward.contains(key)) s[key]=s[key].get<int>()+reward[key].get<int>();}
|
|
// Chest rewards may also carry a single partner's evolution shards.
|
|
void grant_chest(J& s,const J& reward) {
|
|
grant(s,reward);
|
|
int pet=reward.value("shardsPet",-1),shards=reward.value("shards",0);
|
|
if(shards>0&&pet>=0&&pet<static_cast<int>(config()["pets"].size())) s["shards"][pet]=s["shards"][pet].get<int>()+shards;
|
|
}
|
|
int first_clear_chest(int stageId) {
|
|
for(const auto& drop:config()["rewards"]["stageDrops"]) if(drop["stageId"].get<int>()==stageId) return drop["chest"].get<int>();
|
|
return -1;
|
|
}
|
|
void sync_mail(J& s,int64_t now) {for(auto& m:s["mails"]) if(now>=m["expiresAt"].get<int64_t>()) m["expired"]=true;}
|
|
bool attachment(const J& m) {for(const char* k:{"coins","candy","balls","tickets"}) if(m["reward"][k].get<int>()>0) return true;return false;}
|
|
J* find_mail(J& s,const std::string& id) {for(auto& m:s["mails"]) if(m["mailId"]==id) return &m;return nullptr;}
|
|
std::string claim_mail(J& s,J* m) {
|
|
if(!m) return "not_found";
|
|
if(m->at("claimed").get<bool>()) return "already_claimed";
|
|
if(m->at("expired").get<bool>()) return "expired";
|
|
if(!attachment(*m)) return "no_attachment";
|
|
grant(s,m->at("reward"));(*m)["claimed"]=true;(*m)["read"]=true;return "claimed";
|
|
}
|
|
int unlocked(const J& s) {
|
|
int id=1;while(id<static_cast<int>(config()["stages"].size())&&s["clearedStages"][id-1]==1) id++;return id;
|
|
}
|
|
void recover(J& s, int64_t now) {
|
|
int cap=economy()["ballCap"],seconds=economy()["recoverySeconds"];
|
|
int balls = s.at("balls"); int64_t anchor = s.at("ballRecoveryUnix");
|
|
if (balls >= cap || now - anchor < seconds) return;
|
|
int n = static_cast<int>(std::min<int64_t>((now-anchor)/seconds, cap-balls));
|
|
s["balls"] = balls+n;
|
|
s["ballRecoveryUnix"] = balls+n >= cap ? now : anchor+n*seconds;
|
|
}
|
|
struct V { float x,y; };
|
|
J pinball(int aim) {
|
|
const auto& board=config().at("pinball");
|
|
constexpr float dt=1.f/60;
|
|
V p{.5f,.95f}, v{std::sin(aim * .0174532924f)*.95f,-.35f};
|
|
J path=J::array(); path.push_back({{"x",p.x},{"y",p.y}});
|
|
for(int tick=0;tick<600;tick++) {
|
|
v.y-=board["gravity"].get<float>()*dt; p.x+=v.x*dt; p.y+=v.y*dt;
|
|
if(p.x<.025f) {p.x=.025f;v.x=std::abs(v.x)*board["wallBounce"].get<float>();}
|
|
if(p.x>.975f) {p.x=.975f;v.x=-std::abs(v.x)*board["wallBounce"].get<float>();}
|
|
for(int row=0;row<4;row++) for(int col=0;col<5;col++) {
|
|
V peg{.12f+col*.19f+(row%2==0?0:.025f), .72f-row*.16f};
|
|
V d{p.x-peg.x,p.y-peg.y}; float len=d.x*d.x+d.y*d.y;
|
|
float radius=board["pegRadius"];
|
|
if(len>=radius*radius) continue;
|
|
V n = len<.000001f ? V{.4f,1} : d;
|
|
float norm=std::sqrt(n.x*n.x+n.y*n.y); n.x/=norm;n.y/=norm;
|
|
float separation=board["pegSeparation"];p={peg.x+n.x*separation,peg.y+n.y*separation};
|
|
float dot=v.x*n.x+v.y*n.y;
|
|
if(dot<0) v={(v.x-2*dot*n.x)*board["pegBounce"].get<float>(),(v.y-2*dot*n.y)*board["pegBounce"].get<float>()};
|
|
if(n.y>.8f && std::abs(v.x)<.06f) v.x=(aim<0?-1:1)*board["bumperSpeed"].get<float>();
|
|
}
|
|
if(p.y>.98f) {p.y=.98f;v.y=-std::abs(v.y);}
|
|
path.push_back({{"x",p.x},{"y",p.y}});
|
|
if(p.y<=.035f) break;
|
|
}
|
|
int bin=std::clamp(static_cast<int>(p.x*5),0,4);
|
|
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,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;};
|
|
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];
|
|
float growth=(1+economy()["levelGrowth"].get<float>()*(s["level"].get<int>()-1))*(tier==2?economy()["evolveGrowth"].get<float>():1);
|
|
float hp=p["hp"].get<float>()*growth;heroes.push_back({pet,slot,tier,0,hp,hp,p["attack"].get<float>()*growth});
|
|
}
|
|
int spawned=0,durability=stage["durability"],defeated=0;float time=0;bool won=false;int leaks[3]={};constexpr float dt=.05f;
|
|
auto z=[](const Hero& h){return h.slot%2==0?0.f:-2.f;};
|
|
auto damage=[&](float raw,int source,int target,float defense) {return stage["elementsEnabled"].get<bool>()?std::max(1.f,std::floor(raw*gameplay::element_multiplier(source,target)-defense)):raw;};
|
|
auto targets=[&](const Hero& h,float range) {
|
|
std::vector<size_t> ids;for(size_t i=0;i<enemies.size();i++)if(enemies[i].hp>0&&enemies[i].lane==h.slot/2&&enemies[i].z-z(h)<=range)ids.push_back(i);
|
|
std::sort(ids.begin(),ids.end(),[&](size_t a,size_t b){return enemies[a].z!=enemies[b].z?enemies[a].z<enemies[b].z:enemies[a].index<enemies[b].index;});return ids;
|
|
};
|
|
auto hit=[&](Hero& h,Enemy& e,float raw) {
|
|
float amount=damage(raw,pets[h.pet]["element"],stage["element"],types[e.type]["defense"]);h.damage+=std::min(e.hp,amount);e.hp-=amount;if(e.hp<=0)defeated++;
|
|
};
|
|
for(int tick=0;tick<2401;tick++) {
|
|
time+=dt;
|
|
for(auto& e:enemies){e.slowTime=std::max(0.f,e.slowTime-dt);if(e.slowTime<=0)e.slow=1;}
|
|
if(spawned<9&&time>=spawned/3*stage["waveGap"].get<float>()+spawned%3*stage["spawnGap"].get<float>()) {
|
|
int type=stage["enemyIds"][spawned];const auto& d=types[type];enemies.push_back({spawned,type,stage["enemyLanes"][spawned],(d["hp"].get<float>()+spawned*d["hpStep"].get<float>())*stage["hpScale"].get<float>()});spawned++;
|
|
}
|
|
for(auto& h:heroes) {
|
|
h.shieldTime=std::max(0.f,h.shieldTime-dt);if(h.shieldTime<=0)h.shield=0;if(h.hp<=0)continue;h.cd-=dt;h.skill-=dt;
|
|
const auto& p=pets[h.pet];const auto& a=p["ability"];
|
|
if(h.skill<=0) {
|
|
std::string kind=a["kind"];float value=a[h.tier==2?"t2":"t1"];bool cast=false;
|
|
if(kind=="shield") {h.shield=std::max(h.shield,value);h.shieldTime=a[h.tier==2?"t2Duration":"duration"];cast=true;}
|
|
else if(kind=="heal") {
|
|
Hero* target=nullptr;int radius=a[h.tier==2?"t2Radius":"radius"];
|
|
for(auto& other:heroes)if(other.hp>0&&other.hp<other.max&&std::abs(lane_position(other.slot/2)-lane_position(h.slot/2))<=radius&&std::abs(z(other)-z(h))<=a["range"].get<float>()&&(!target||other.hp/other.max<target->hp/target->max))target=&other;
|
|
if(target){float amount=std::min(value,target->max-target->hp);target->hp+=amount;h.healing+=amount;cast=true;}
|
|
} else {
|
|
auto ids=targets(h,a["range"]);if(!ids.empty()) {
|
|
if(kind=="slow") {auto& e=enemies[ids[0]];float factor=types[e.type]["boss"].get<bool>()?1-(1-value)*a["bossControlScale"].get<float>():value;e.slow=std::min(e.slow,factor);e.slowTime=std::max(e.slowTime,a[h.tier==2?"t2Duration":"duration"].get<float>());}
|
|
else for(size_t i=0;i<std::min(ids.size(),static_cast<size_t>(a[h.tier==2?"t2Targets":"targets"].get<int>()));i++)hit(h,enemies[ids[i]],h.attack*value);
|
|
cast=true;
|
|
}
|
|
}
|
|
if(cast){h.skill=a["cooldown"];h.skills++;}
|
|
}
|
|
auto ids=targets(h,p["range"]);if(!ids.empty()&&h.cd<=0){hit(h,enemies[ids[0]],h.attack);h.cd=p["cooldown"];}
|
|
}
|
|
for(auto& e:enemies) {
|
|
if(e.hp<=0)continue;const auto& d=types[e.type];Hero* blocker=nullptr;
|
|
for(auto& h:heroes)if(h.hp>0&&h.slot/2==e.lane&&(!blocker||h.slot<blocker->slot))blocker=&h;
|
|
float stop=blocker?z(*blocker)+1:-4;
|
|
if(e.z>stop)e.z=std::max(stop,e.z-d["speed"].get<float>()*e.slow*dt);
|
|
else if(blocker) {
|
|
e.cd-=dt;if(e.cd<=0){float amount=damage(d["attack"].get<float>()*stage["attackScale"].get<float>(),stage["element"],pets[blocker->pet]["element"],0);float absorbed=std::min(blocker->shield,amount);blocker->shield-=absorbed;blocker->absorbed+=absorbed;blocker->hp=std::max(0.f,blocker->hp-(amount-absorbed));e.cd=d["cooldown"];}
|
|
} else {durability-=d["leak"].get<int>();leaks[e.lane]++;e.hp=0;}
|
|
}
|
|
if(durability<=0||time>=stage["timeout"].get<float>())break;
|
|
if(spawned==9&&std::all_of(enemies.begin(),enemies.end(),[](const Enemy& e){return e.hp<=0;})){won=true;break;}
|
|
}
|
|
J stats=J::array();for(const auto& h:heroes)stats.push_back({{"pet",h.pet},{"slot",h.slot},{"damage",h.damage},{"healing",h.healing},{"absorbed",h.absorbed},{"skills",h.skills}});
|
|
return J{{"won",won},{"duration",time},{"durability",std::max(0,durability)},{"defeated",defeated},{"stageId",stageId},{"reward",stage["reward"]},{"configVersion",config()["version"]},{"stats",stats},{"leaks",{leaks[0],leaks[1],leaks[2]}}};
|
|
}
|
|
// 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,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");
|
|
std::vector<Hero> heroes; std::vector<Enemy> enemies;
|
|
for(int slot=0;slot<2;slot++) {
|
|
int pet=s["formation"][slot];if(pet<0) continue;
|
|
const auto& p=pets[pet];float g=(1+economy()["levelGrowth"].get<float>()*(s["level"].get<int>()-1))*(s["stages"][pet]==2?economy()["evolveGrowth"].get<float>():1);
|
|
float hp=p["hp"].get<float>()*g;heroes.push_back({pet,slot,hp,hp,p["attack"].get<float>()*g});
|
|
}
|
|
int spawned=0,durability=stage["durability"],defeated=0;float time=0;bool won=false;
|
|
constexpr float dt=.05f;
|
|
auto damage=[&](float raw,int attackElement,int defenseElement,float defense) {
|
|
if(!stage["elementsEnabled"].get<bool>()) return raw;
|
|
return std::max(1.f,std::floor(raw*gameplay::element_multiplier(attackElement,defenseElement)-defense));
|
|
};
|
|
for(int tick=0;tick<2401;tick++) {
|
|
time+=dt;
|
|
if(spawned<9 && time>=spawned/3*stage["waveGap"].get<float>()+spawned%3*stage["spawnGap"].get<float>()) {
|
|
int type=stage["enemyIds"][spawned];const auto& e=types[type];
|
|
enemies.push_back({spawned,type,(e["hp"].get<float>()+spawned*e["hpStep"].get<float>())*stage["hpScale"].get<float>()});spawned++;
|
|
}
|
|
for(auto& h:heroes) {
|
|
if(h.hp<=0) continue;h.cd-=dt;h.heal-=dt;const auto& p=pets[h.pet];
|
|
if(p["heal"].get<float>()>0 && h.heal<=0) {
|
|
Hero* target=nullptr;
|
|
for(auto& other:heroes) if(other.hp>0 && other.hp<other.max && (!target || other.hp/other.max<target->hp/target->max)) target=&other;
|
|
if(target) {target->hp=std::min(target->max,target->hp+p["heal"].get<float>());h.heal=p["healCooldown"];}
|
|
}
|
|
Enemy* nearest=nullptr;float z=h.slot==0?0.f:-2.f;
|
|
for(auto& e:enemies) if(e.hp>0 && e.z-z<=p["range"].get<float>() && (!nearest||e.z<nearest->z)) nearest=&e;
|
|
if(nearest && h.cd<=0) {
|
|
nearest->hp-=damage(h.attack,p["element"],stage["element"],types[nearest->type]["defense"]);
|
|
h.cd=p["cooldown"];if(nearest->hp<=0) defeated++;
|
|
}
|
|
}
|
|
for(auto& e:enemies) {
|
|
if(e.hp<=0) continue;const auto& type=types[e.type];
|
|
Hero* blocker=nullptr;
|
|
for(auto& h:heroes) if(h.hp>0 && (!blocker||h.slot<blocker->slot)) blocker=&h;
|
|
float stop=blocker?(blocker->slot==0?1.f:-1.f):-4.f;
|
|
if(e.z>stop) e.z=std::max(stop,e.z-type["speed"].get<float>()*dt);
|
|
else if(blocker) {
|
|
e.cd-=dt;if(e.cd<=0) {blocker->hp=std::max(0.f,blocker->hp-damage(type["attack"].get<float>()*stage["attackScale"].get<float>(),stage["element"],pets[blocker->pet]["element"],0));e.cd=type["cooldown"];}
|
|
} else {durability-=type["leak"].get<int>();e.hp=0;}
|
|
}
|
|
if(durability<=0||time>=stage["timeout"].get<float>()) break;
|
|
if(spawned==9&&std::all_of(enemies.begin(),enemies.end(),[](const Enemy& e){return e.hp<=0;})) {won=true;break;}
|
|
}
|
|
return J{{"won",won},{"duration",time},{"durability",std::max(0,durability)},{"defeated",defeated},{"stageId",stageId},{"reward",stage["reward"]},{"configVersion",config()["version"]}};
|
|
}
|
|
bool identifier(const std::string& id) {
|
|
return id.size()>=16 && id.size()<=64 && std::all_of(id.begin(),id.end(),[](unsigned char c){return std::isalnum(c)||c=='-'||c=='_';});
|
|
}
|
|
// Persist once per authorized battle. Pages are read-only and never enter the operation log.
|
|
J authoritative_battle(J& state,const J& snapshot,const J& stage,const std::string& activity) {
|
|
J trace=combat_runtime::simulate(config(),snapshot,stage);
|
|
trace["traceHash"]=crypto::sha256_hex(trace.at("events").dump());
|
|
trace["gameplayHash"]=crypto::sha256_hex(config().dump());
|
|
trace["skillsHash"]=crypto::sha256_hex(combat_runtime::definitions().dump());
|
|
trace["traceId"]=crypto::sha256_hex(activity+trace["traceHash"].get<std::string>()+trace["gameplayHash"].get<std::string>()+trace["skillsHash"].get<std::string>());
|
|
J result={{"won",trace["won"]},{"duration",trace["duration"]},{"durability",trace["durability"]},{"defeated",trace["defeated"]},{"stats",trace["stats"]},{"leaks",trace["leaks"]},{"stageId",stage["id"]},{"reward",stage["reward"]},{"configVersion",config()["version"]},{"rulesVersion",3},{"logicVersion",1},{"traceHash",trace["traceHash"]}};
|
|
state["battleTrace"]=std::move(trace);return result;
|
|
}
|
|
void freeze_skills(J& state) {
|
|
state["battleSnapshot"]["rulesVersion"]=3;
|
|
state["battleSnapshot"]["skills"]=combat_runtime::definitions();
|
|
state["battleSnapshot"]["gameplayHash"]=state["battleTrace"]["gameplayHash"];
|
|
state["battleSnapshot"]["skillsHash"]=state["battleTrace"]["skillsHash"];
|
|
}
|
|
J trace_manifest(const J& trace) {
|
|
J result=trace;result.erase("events");result.erase("stats");result["eventCount"]=trace.at("events").size();return result;
|
|
}
|
|
bool activity_pending(const J& s) {
|
|
return s["pendingShot"]!=""||s["pendingBattle"]!=""||(s.contains("pendingFish")&&s["pendingFish"]!="");
|
|
}
|
|
std::vector<int> int_list(const J& c,const char* key,int cap) {
|
|
if(!c.contains(key)) return {};
|
|
const auto& a=c.at(key);
|
|
if(!a.is_array()||static_cast<int>(a.size())>cap) throw std::invalid_argument("invalid_input");
|
|
std::vector<int> out;
|
|
for(size_t i=0;i<a.size();++i) {
|
|
if(!a[i].is_number_integer()) throw std::invalid_argument("invalid_input");
|
|
int64_t n=a[i].get<int64_t>();
|
|
if(n<0||n>1000000000LL) throw std::invalid_argument("invalid_input");
|
|
out.push_back(static_cast<int>(n));
|
|
}
|
|
return out;
|
|
}
|
|
J roll_fish(int64_t now,const std::function<uint32_t(uint32_t)>& random) {
|
|
const auto& cfg=config().at("fishing");
|
|
int count=cfg.at("fishCount").get<int>(),durationMs=cfg.at("duration").get<int>()*1000,window=cfg.at("windowMs").get<int>();
|
|
auto roll=[&](uint32_t n)->uint32_t {
|
|
uint32_t value;
|
|
if(random) value=random(n);
|
|
else {std::random_device source;value=std::uniform_int_distribution<uint32_t>(0,n-1)(source);}
|
|
if(value>=n) throw std::runtime_error("invalid_random_source");
|
|
return value;
|
|
};
|
|
J fish=J::array();
|
|
int span=std::max(1,(durationMs-window-400)/count);
|
|
for(int i=0;i<count;i++) {
|
|
int at=400+i*span+static_cast<int>(roll(400));
|
|
int latest=durationMs-window;
|
|
if(at>latest) at=latest;
|
|
if(at<0) at=0;
|
|
fish.push_back({{"at",at},{"window",window},{"points",roll(5)==0?2:1}});
|
|
}
|
|
return J{{"startedAt",now},{"fish",fish}};
|
|
}
|
|
int integer(const J& j,const char* field) {
|
|
if(!j.contains(field)||!j[field].is_number_integer()) throw std::invalid_argument("invalid_input");
|
|
int64_t n=j[field].get<int64_t>();
|
|
if(n < -1000000000LL || n > 1000000000LL) throw std::invalid_argument("invalid_input");
|
|
return static_cast<int>(n);
|
|
}
|
|
J capture_info(const J& s) {
|
|
const auto& pool=config().at("capture");int sum=0,missingSum=0;bool tutorial=!s.at("captureTutorialDone").get<bool>();
|
|
for(size_t i=0;i<pool["weights"].size();i++) {sum+=pool["weights"][i].get<int>();if(s["stages"][i]==0) missingSum+=pool["weights"][i].get<int>();}
|
|
bool pity=missingSum>0 && s["duplicateStreak"].get<int>()>=pool["duplicateLimit"].get<int>();
|
|
J entries=J::array();
|
|
for(size_t i=0;i<pool["weights"].size();i++) {
|
|
double base=pool["weights"][i].get<double>()/sum;
|
|
double chance=tutorial?(i==pool["tutorialPet"].get<size_t>()?1.:0.):pity?(s["stages"][i]==0?pool["weights"][i].get<double>()/missingSum:0.):base;
|
|
entries.push_back({{"pet",i},{"baseProbability",base},{"probability",chance}});
|
|
}
|
|
return J{{"tutorial",tutorial},{"completed",missingSum==0},{"pityReady",pity},{"count",s["captureCount"]},{"duplicateStreak",s["duplicateStreak"]},{"entries",entries}};
|
|
}
|
|
J capture(J& s,const std::function<uint32_t(uint32_t)>& random) {
|
|
const auto& pool=config().at("capture");auto info=capture_info(s);
|
|
bool tutorial=info["tutorial"],pity=!tutorial&&info["pityReady"].get<bool>();
|
|
int pet=pool["tutorialPet"];
|
|
if(!tutorial) {
|
|
uint32_t total=0;for(size_t i=0;i<pool["weights"].size();i++) if(!pity||s["stages"][i]==0) total+=pool["weights"][i].get<uint32_t>();
|
|
uint32_t roll;
|
|
if(random) roll=random(total);
|
|
else {std::random_device source;roll=std::uniform_int_distribution<uint32_t>(0,total-1)(source);}
|
|
if(roll>=total) throw std::runtime_error("invalid_random_source");
|
|
for(size_t i=0;i<pool["weights"].size();i++) if(!pity||s["stages"][i]==0) {
|
|
uint32_t weight=pool["weights"][i];if(roll<weight) {pet=static_cast<int>(i);break;}roll-=weight;
|
|
}
|
|
s["captureCount"]=s["captureCount"].get<int>()+1;
|
|
} else s["captureTutorialDone"]=true;
|
|
bool duplicate=s["stages"][pet].get<int>()>0;
|
|
s["tickets"]=s["tickets"].get<int>()-1;
|
|
if(duplicate) s["shards"][pet]=s["shards"][pet].get<int>()+economy()["duplicateShards"].get<int>();else s["stages"][pet]=1;
|
|
if(!tutorial) {
|
|
bool complete=std::all_of(s["stages"].begin(),s["stages"].end(),[](const J& value){return value.get<int>()>0;});
|
|
s["duplicateStreak"]=complete||!duplicate?0:std::min(pool["duplicateLimit"].get<int>(),s["duplicateStreak"].get<int>()+1);
|
|
}
|
|
return J{{"pet",pet},{"duplicate",duplicate},{"tutorial",tutorial},{"pity",pity},{"shards",duplicate?economy()["duplicateShards"].get<int>():0},{"poolVersion",config()["version"]}};
|
|
}
|
|
// Every expected refusal becomes a durable operation result, including stale revisions.
|
|
std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint32_t(uint32_t)>& random) {
|
|
const auto action=c.at("action").get<std::string>();
|
|
if(integer(c,"revision")!=s["revision"].get<int>()) return "stale_revision";
|
|
if(action=="shot.begin") {
|
|
int aim=integer(c,"aim");if(aim < -config()["pinball"]["maxAim"].get<int>() || aim > config()["pinball"]["maxAim"].get<int>()) return "invalid_aim";
|
|
if(activity_pending(s)) return "pending_activity";
|
|
if(s["balls"].get<int>()<1) return "insufficient_balls";
|
|
int balls=s["balls"];s["balls"]=balls-1;
|
|
if(balls>=economy()["ballCap"].get<int>()) s["ballRecoveryUnix"]=now;
|
|
s["pendingShot"]=c["requestId"];s["pendingAim"]=aim;
|
|
s["shotData"]=pinball(aim);
|
|
s["shotReadyAt"]=now+static_cast<int64_t>(std::ceil((s["shotData"]["path"].size()-1)/60.0));
|
|
} else if(action=="shot.finish") {
|
|
if(!c.contains("activityId")||c["activityId"]!=s["pendingShot"]||s["pendingShot"]=="") return "activity_missing";
|
|
if(now<s["shotReadyAt"].get<int64_t>()) return "activity_not_ready";
|
|
const auto shot=s.at("shotData");
|
|
s["coins"]=s["coins"].get<int>()+shot["coins"].get<int>();s["candy"]=s["candy"].get<int>()+shot["candy"].get<int>();
|
|
int shots=s["shots"].get<int>()+1;s["shots"]=shots;
|
|
if(shots==1||shots%economy()["ticketEvery"].get<int>()==0) s["tickets"]=s["tickets"].get<int>()+1;
|
|
s["lastCandy"]=shot["candy"];s["lastCoins"]=shot["coins"];s["lastShot"]=s["pendingShot"];s["pendingShot"]="";
|
|
s.erase("shotData");s.erase("shotReadyAt");
|
|
daily_event(s,0);
|
|
} else if(action=="battle.begin") {
|
|
if(activity_pending(s)) return "pending_activity";
|
|
int stageId=c.contains("stageId")?integer(c,"stageId"):s["selectedStage"].get<int>();
|
|
if(stageId==0) stageId=s["selectedStage"];
|
|
if(stageId<1||stageId>static_cast<int>(config()["stages"].size())) return "invalid_stage";
|
|
if(stageId>unlocked(s)) return "stage_locked";
|
|
bool deployed=false;for(int slot=0;slot<gameplay::stage(stageId)["lanes"].get<int>()*2;slot++)if(s["formation"][slot]!=-1)deployed=true;
|
|
if(!deployed)return "empty_formation";
|
|
s["selectedStage"]=stageId;
|
|
int protocol=c.contains("combatProtocol")?integer(c,"combatProtocol"):0;if(protocol<0||protocol>1)return "unsupported_combat_protocol";
|
|
s["pendingBattle"]=c["requestId"];s["battleData"]=protocol==1?authoritative_battle(s,s,gameplay::stage(stageId),c["requestId"]):battle(s,stageId);
|
|
// 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}};
|
|
if(protocol==1)freeze_skills(s);
|
|
} else if(action=="challenge.begin") {
|
|
if(activity_pending(s)) 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;
|
|
int protocol=c.contains("combatProtocol")?integer(c,"combatProtocol"):0;if(protocol<0||protocol>1)return "unsupported_combat_protocol";
|
|
J data=protocol==1?authoritative_battle(s,sim,challenge,c["requestId"]):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}};
|
|
if(protocol==1)freeze_skills(s);
|
|
} 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;
|
|
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");s.erase("battleTrace");
|
|
if(!abandon&&!challenge) daily_event(s,1);
|
|
} else if(action=="fish.begin"||action=="fish.finish"||action=="fish.abandon") {
|
|
const auto& cfg=config().at("fishing");
|
|
if(action=="fish.begin") {
|
|
if(activity_pending(s)) return "pending_activity";
|
|
if(s["clearedStages"][0]!=1) return "fish_locked";
|
|
if(s["balls"].get<int>()<cfg.at("ballCost").get<int>()) return "insufficient_balls";
|
|
if(s["fishPlayed"].get<int>()>=cfg.at("dailyLimit").get<int>()) return "fish_limit";
|
|
s["balls"]=s["balls"].get<int>()-cfg.at("ballCost").get<int>();
|
|
s["fishPlayed"]=s["fishPlayed"].get<int>()+1;
|
|
s["pendingFish"]=c["requestId"];
|
|
s["fishData"]=roll_fish(now,random);
|
|
result={{"id",c["requestId"]}};
|
|
} else {
|
|
if(!c.contains("activityId")||c["activityId"]!=s["pendingFish"]||s["pendingFish"]=="") return "activity_missing";
|
|
int duration=cfg.at("duration").get<int>(),grace=cfg.at("grace").get<int>();
|
|
int64_t started=s["fishData"]["startedAt"].get<int64_t>();
|
|
bool expired=now>started+duration+grace;
|
|
if(action=="fish.finish"&&expired) return "fish_expired";
|
|
int score=0;
|
|
J reward={{"coins",0},{"candy",0},{"balls",0},{"tickets",0}};
|
|
if(action=="fish.finish") {
|
|
auto ids=int_list(c,"tapFish",16);
|
|
auto taps=int_list(c,"taps",16);
|
|
if(ids.size()!=taps.size()) throw std::invalid_argument("invalid_input");
|
|
int64_t elapsedMs=(now-started)*1000;
|
|
int durationMs=duration*1000;
|
|
const auto& spots=s["fishData"]["fish"];
|
|
std::vector<int> used(spots.size(),0);
|
|
for(size_t i=0;i<ids.size();++i) {
|
|
int id=ids[i],ms=taps[i];
|
|
if(id<0||id>=static_cast<int>(spots.size())||ms<0||ms>durationMs) throw std::invalid_argument("invalid_input");
|
|
if(static_cast<int64_t>(ms)>elapsedMs) return "invalid_fish_tap";
|
|
int at=spots[id]["at"].get<int>(),window=spots[id]["window"].get<int>();
|
|
if(ms>=at&&ms<=at+window&&!used[id]) {used[id]=1;score+=spots[id]["points"].get<int>();}
|
|
}
|
|
for(const auto& tier:cfg.at("tiers")) if(score>=tier.at("minScore").get<int>()) reward=tier.at("reward");
|
|
grant(s,reward);
|
|
if(score>s["fishBest"].get<int>()) s["fishBest"]=score;
|
|
}
|
|
s["pendingFish"]="";s.erase("fishData");
|
|
result={{"id",c["activityId"]},{"fishScore",score},{"reward",reward}};
|
|
}
|
|
} else {
|
|
if(activity_pending(s)) return "pending_activity";
|
|
if(action=="stage.select") {
|
|
int id=integer(c,"stageId");if(id<1||id>static_cast<int>(config()["stages"].size())) return "invalid_stage";
|
|
if(id>unlocked(s)) return "stage_locked";s["selectedStage"]=id;
|
|
} else if(action=="camp.place"||action=="camp.store") {
|
|
const auto& camp=config().at("camp");
|
|
if(s["clearedStages"][camp["buildUnlockStage"].get<int>()-1]!=1) return "camp_build_locked";
|
|
int slot=integer(c,"slot");if(slot<0||slot>=camp["slots"].get<int>()) return "invalid_camp_slot";
|
|
if(action=="camp.store") {
|
|
if(s["campLayout"][slot]==-1) return "camp_slot_empty";
|
|
result={{"item",s["campLayout"][slot]},{"slot",slot},{"chargedCoins",0}};s["campLayout"][slot]=-1;
|
|
} else {
|
|
int item=integer(c,"item");if(item<0||item>=static_cast<int>(camp["decorations"].size())) return "invalid_decoration";
|
|
int target=s["campLayout"][slot];if(target>=0&&target!=item) return "camp_slot_occupied";
|
|
int cost=s["decorations"][item]==1?0:camp["decorations"][item]["coins"].get<int>();
|
|
if(s["coins"].get<int>()<cost) return "insufficient_coins";
|
|
s["coins"]=s["coins"].get<int>()-cost;s["decorations"][item]=1;
|
|
for(auto& old:s["campLayout"]) if(old==item) old=-1;
|
|
s["campLayout"][slot]=item;result={{"item",item},{"slot",slot},{"chargedCoins",cost}};
|
|
}
|
|
result["id"]=c["requestId"];
|
|
} else if(action=="camp.interact") {
|
|
if(s["clearedStages"][config()["camp"]["restUnlockStage"].get<int>()-1]!=1) return "camp_rest_locked";
|
|
int pet=integer(c,"pet");if(pet<0||pet>=static_cast<int>(config()["pets"].size())||s["stages"][pet]==0) return "invalid_pet";
|
|
s["restPet"]=pet;s["campInteractions"]=std::min(s["campInteractions"].get<int>(),999999999)+1;
|
|
result={{"id",c["requestId"]},{"pet",pet}};
|
|
daily_event(s,2);
|
|
} else if(action=="mail.read"||action=="mail.claim") {
|
|
auto id=c.at("mailId").get<std::string>();if(id.empty()||id.size()>64) return "invalid_input";
|
|
auto m=find_mail(s,id);if(!m) return "mail_not_found";
|
|
if(action=="mail.read") {(*m)["read"]=true;result={{"id",c["requestId"]},{"mailId",id}};}
|
|
else {
|
|
auto status=claim_mail(s,m);if(status!="claimed") return "mail_"+status;
|
|
result={{"id",c["requestId"]},{"mailId",id},{"reward",m->at("reward")},{"claimedCount",1}};
|
|
}
|
|
} else if(action=="mail.claimAll") {
|
|
const auto& ids=c.at("mailIds");if(!ids.is_array()||ids.empty()||ids.size()>20) return "invalid_input";
|
|
std::vector<std::string> unique;
|
|
for(const auto& value:ids) {auto id=value.get<std::string>();if(id.empty()||id.size()>64||std::find(unique.begin(),unique.end(),id)!=unique.end()) return "invalid_input";unique.push_back(id);}
|
|
J total={{"coins",0},{"candy",0},{"balls",0},{"tickets",0}},entries=J::array();int count=0;
|
|
for(const auto& id:unique) {
|
|
auto m=find_mail(s,id);auto status=claim_mail(s,m);entries.push_back({{"mailId",id},{"status",status}});
|
|
if(status=="claimed") {count++;for(const char* k:{"coins","candy","balls","tickets"}) total[k]=total[k].get<int>()+m->at("reward")[k].get<int>();}
|
|
}
|
|
result={{"id",c["requestId"]},{"reward",total},{"claimedCount",count},{"entries",entries}};
|
|
} else if(action=="shop.buy") {
|
|
if(integer(c,"day")!=s["dailyDay"].get<int>()) return "shop_expired";
|
|
int item=integer(c,"item");if(item<0||item>=3) return "invalid_goods";
|
|
const auto& goods=config()["shop"]["goods"][item];int unlock=goods["unlockStage"];
|
|
if(unlock>0&&s["clearedStages"][unlock-1]!=1) return "goods_locked";
|
|
if(s["shopBought"][item].get<int>()>=goods["dailyLimit"].get<int>()) return "shop_limit";
|
|
int cost=goods["coins"];if(s["coins"].get<int>()<cost) return "insufficient_coins";
|
|
s["coins"]=s["coins"].get<int>()-cost;grant(s,goods["reward"]);s["shopBought"][item]=s["shopBought"][item].get<int>()+1;
|
|
result={{"id",c["requestId"]},{"item",item},{"day",s["dailyDay"]},{"chargedCoins",cost},{"reward",goods["reward"]}};
|
|
} else if(action=="daily.claim") {
|
|
if(integer(c,"day")!=s["dailyDay"].get<int>()) return "daily_expired";
|
|
int item=integer(c,"item");if(item<0||item>=3) return "invalid_task";
|
|
if(s["dailyClaimed"][item]==1) return "reward_claimed";
|
|
if(s["dailyProgress"][item].get<int>()<config()["daily"]["tasks"][item]["target"].get<int>()) return "task_incomplete";
|
|
auto reward=config()["daily"]["tasks"][item]["reward"];grant(s,reward);s["dailyClaimed"][item]=1;
|
|
result={{"id",c["requestId"]},{"item",item},{"day",s["dailyDay"]},{"reward",reward}};
|
|
} else if(action=="signin.claim") {
|
|
int item=integer(c,"item");if(item<0||item>=7) return "invalid_signin";
|
|
if(item>=s["signinDays"].get<int>()) return "signin_locked";
|
|
if(s["signinClaimed"][item]==1) return "reward_claimed";
|
|
auto reward=config()["daily"]["signin"][item];grant(s,reward);s["signinClaimed"][item]=1;
|
|
result={{"id",c["requestId"]},{"item",item},{"reward",reward}};
|
|
} else if(action=="chest.claimDaily") {
|
|
const auto& rewards=config()["rewards"];int chest=rewards["dailyChest"];
|
|
if(s["chestDailyClaimed"].get<int>()==1) return "chest_claimed";
|
|
s["chestDailyClaimed"]=1;s["chests"][chest]=s["chests"][chest].get<int>()+1;
|
|
result={{"id",c["requestId"]},{"chest",chest},{"day",s["dailyDay"]},{"count",1}};
|
|
} else if(action=="chest.open") {
|
|
const auto& rewards=config()["rewards"];
|
|
int chest=integer(c,"chest");if(chest<0||chest>=static_cast<int>(rewards["chests"].size())) return "invalid_chest";
|
|
if(s["chests"][chest].get<int>()<1) return "chest_missing";
|
|
const auto& def=rewards["chests"][chest];std::string kind=def["kind"];
|
|
J reward=def["contents"];
|
|
if(kind=="choice") {
|
|
if(!c.contains("choice")) return "invalid_choice";
|
|
int choice=integer(c,"choice");if(choice<0||choice>=static_cast<int>(def["choices"].size())) return "invalid_choice";
|
|
reward=def["choices"][choice]["reward"];
|
|
}
|
|
s["chests"][chest]=s["chests"][chest].get<int>()-1;grant_chest(s,reward);
|
|
result={{"id",c["requestId"]},{"chest",chest},{"kind",kind},{"remaining",s["chests"][chest]},{"chestReward",reward}};
|
|
if(kind=="choice") result["choice"]=integer(c,"choice");
|
|
} else if(action=="capture") {
|
|
if(s["tickets"].get<int>()<1) return "insufficient_tickets";
|
|
result=capture(s,random);result["id"]=c["requestId"];s["lastCaptureData"]=result;
|
|
} else if(action=="train") {
|
|
int level=s["level"];if(level>=economy()["levelCap"].get<int>()) return "level_cap";
|
|
if(s["candy"].get<int>()<economy()["trainCost"].get<int>()*level) return "insufficient_candy";
|
|
s["candy"]=s["candy"].get<int>()-economy()["trainCost"].get<int>()*level;s["level"]=level+1;
|
|
} else if(action=="evolve") {
|
|
int pet=integer(c,"pet");if(pet<0||pet>=static_cast<int>(config()["pets"].size())) return "invalid_pet";
|
|
if(s["stages"][pet]!=1||s["level"].get<int>()<economy()["evolveLevel"].get<int>()||s["shards"][pet].get<int>()<economy()["evolveShards"].get<int>()||s["coins"].get<int>()<economy()["evolveCoins"].get<int>()) return "evolve_requirements";
|
|
s["shards"][pet]=s["shards"][pet].get<int>()-economy()["evolveShards"].get<int>();s["coins"]=s["coins"].get<int>()-economy()["evolveCoins"].get<int>();s["stages"][pet]=2;
|
|
} else if(action=="deploy") {
|
|
int pet=integer(c,"pet"),slot=integer(c,"slot");
|
|
if(pet<0||pet>=static_cast<int>(config()["pets"].size())||slot<0||slot>=gameplay::stage(s["selectedStage"])["lanes"].get<int>()*2||s["stages"][pet]==0) return "invalid_formation";
|
|
int old=-1;for(int i=0;i<6;i++) if(s["formation"][i]==pet) old=i;
|
|
if(old>=0) s["formation"][old]=s["formation"][slot];s["formation"][slot]=pet;
|
|
} else if(action=="undeploy") {
|
|
int slot=integer(c,"slot");if(slot<0||slot>=6||s["formation"][slot]==-1)return "invalid_formation";s["formation"][slot]=-1;
|
|
} else if(action=="autoDeploy") {
|
|
s["formation"]={-1,-1,-1,-1,-1,-1};int lanes=gameplay::stage(s["selectedStage"])["lanes"];
|
|
const auto& pets=config()["pets"];std::vector<int> used(pets.size(),0);
|
|
// Front line: closest-range owned partners first; back line: longest-range owned partners first.
|
|
auto fill=[&](int offset,bool front){
|
|
for(int lane=0;lane<lanes;lane++) {
|
|
int best=-1;float bestRange=front?1e9f:-1.f;
|
|
for(size_t pet=0;pet<pets.size();pet++) {
|
|
if(s["stages"][pet].get<int>()==0||used[pet])continue;
|
|
float range=pets[pet]["range"].get<float>();
|
|
if(front?(range<bestRange):(range>bestRange)){bestRange=range;best=static_cast<int>(pet);}
|
|
}
|
|
if(best>=0){s["formation"][lane*2+offset]=best;used[best]=1;}
|
|
}
|
|
};
|
|
fill(0,true);fill(1,false);
|
|
}
|
|
else if(action=="goal.claim") {
|
|
if(!s["cleared"].get<bool>()) return "goal_incomplete";if(s["goalClaimed"].get<bool>()) return "goal_claimed";
|
|
s["goalClaimed"]=true;s["balls"]=s["balls"].get<int>()+economy()["goalBalls"].get<int>();
|
|
result={{"id",c["requestId"]},{"reward",{{"coins",0},{"candy",0},{"balls",economy()["goalBalls"]}}}};
|
|
} else return "unknown_action";
|
|
}
|
|
return "";
|
|
}
|
|
J response(const J& s,int64_t now) {
|
|
J visible=s;for(const char* k:{"shotData","battleData","battleSnapshot","battleTrace","shotReadyAt","battleReadyAt","lastBattleData","lastCaptureData","fishData"}) visible.erase(k);
|
|
J r{{"ok",true},{"state",visible},{"serverTime",now},{"configVersion",config()["version"]},{"config",config()}};
|
|
r["combatProtocol"]=1;
|
|
r["capture"]=capture_info(s);
|
|
r["daily"]={{"day",s["dailyDay"]},{"resetAt",(s["dailyDay"].get<int64_t>()+1)*86400-config()["daily"]["utcOffsetSeconds"].get<int>()}};
|
|
r["rewards"]={{"dailyClaimed",s["chestDailyClaimed"]}};
|
|
if(s.contains("lastCaptureData")) r["lastCaptureResult"]=s["lastCaptureData"];
|
|
if(s.contains("lastBattleData")) r["lastBattleResult"]=s["lastBattleData"];
|
|
if(s["pendingShot"]!="") {r["shot"]=s["shotData"];r["shot"]["id"]=s["pendingShot"];r["shot"]["readyAt"]=s["shotReadyAt"];}
|
|
if(s["pendingBattle"]!="") {
|
|
r["battle"]={{"id",s["pendingBattle"]},{"readyAt",s["battleReadyAt"]},{"snapshot",s["battleSnapshot"]}};
|
|
if(s.contains("battleTrace"))r["battle"]["trace"]=trace_manifest(s["battleTrace"]);
|
|
}
|
|
if(s.contains("pendingFish")&&s["pendingFish"]!="") {
|
|
const auto& data=s.at("fishData");
|
|
r["fish"]={{"id",s["pendingFish"]},{"startedAt",data.at("startedAt")},{"deadline",data.at("startedAt").get<int64_t>()+config().at("fishing").at("duration").get<int>()},{"spots",data.at("fish")}};
|
|
}
|
|
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 "";
|
|
}
|
|
struct FriendWrite {int64_t pid=0;std::string name;J state;};
|
|
std::string relation_of(const J& self,int64_t id) {
|
|
if(has_id(self["friendBlocked"],id)) return "blocked";
|
|
if(has_id(self["friendIds"],id)) return "friend";
|
|
if(has_id(self["friendIn"],id)) return "incoming";
|
|
if(has_id(self["friendOut"],id)) return "outgoing";
|
|
return "none";
|
|
}
|
|
J friend_card(int64_t pid,const std::string& name,const std::string& relation,const J* state,bool lineup,bool camp) {
|
|
J card={{"pid",pid},{"name",name},{"relation",relation},{"formation",J::array()},{"stages",J::array()},{"camp",J::array()}};
|
|
if(state&&lineup) {card["formation"]=(*state)["formation"];card["stages"]=(*state)["stages"];}
|
|
if(state&&camp) card["camp"]=(*state)["campLayout"];
|
|
return card;
|
|
}
|
|
// Loads another account inside the caller's transaction. A missing save is created only when the action must record something on their side.
|
|
std::string load_friend(sqlite3* db,int64_t pid,int64_t now,bool create,J& state,std::string& name,bool& save) {
|
|
save=false;
|
|
Statement user(db,"SELECT username FROM users WHERE pid=?");user.number(1,pid);
|
|
if(user.step()!=SQLITE_ROW) return "friend_missing";
|
|
name=user.column(0);if(name.empty()||name.size()>64) return "friend_unavailable";
|
|
Statement q(db,"SELECT state FROM game_states WHERE pid=?");q.number(1,pid);
|
|
bool had=q.step()==SQLITE_ROW;
|
|
if(had) {try {state=J::parse(q.column(0));} catch(...) {return "friend_unavailable";}}
|
|
else if(!create) {state=J();return "";}
|
|
else state=initial(now);
|
|
auto before=state.dump();
|
|
try {migrate(state,now);sync_calendar(state,now);sync_mail(state,now);} catch(...) {return "friend_unavailable";}
|
|
save=!had||state.dump()!=before;
|
|
return "";
|
|
}
|
|
void store_friend(sqlite3* db,const FriendWrite& row) {
|
|
Statement q(db,"INSERT INTO game_states(pid,state) VALUES(?,?) ON CONFLICT(pid) DO UPDATE SET state=excluded.state");
|
|
q.number(1,row.pid);q.text(2,row.state.dump());q.step();
|
|
}
|
|
J friend_book(sqlite3* db,J& self,int64_t selfPid,int64_t now,std::vector<FriendWrite>& writes) {
|
|
const auto& cfg=config()["friends"];
|
|
auto rows=[&](const char* key,bool lineup){
|
|
J list=J::array();
|
|
for(size_t i=0;i<self[key].size();++i) {
|
|
int64_t pid=self[key][i].get<int64_t>();
|
|
if(pid==selfPid) continue;
|
|
const J* pending=nullptr;
|
|
for(const auto& row:writes) if(row.pid==pid) pending=&row.state;
|
|
if(pending) {
|
|
std::string pendingName;
|
|
for(const auto& row:writes) if(row.pid==pid) pendingName=row.name;
|
|
list.push_back(friend_card(pid,pendingName,relation_of(self,pid),pending,lineup,false));continue;
|
|
}
|
|
J state;std::string name;bool save=false;
|
|
if(!load_friend(db,pid,now,false,state,name,save).empty()) continue;
|
|
if(save) writes.push_back({pid,name,state});
|
|
list.push_back(friend_card(pid,name,relation_of(self,pid),state.is_null()?nullptr:&state,lineup,false));
|
|
}
|
|
return list;
|
|
};
|
|
return {{"limit",cfg["limit"]},{"requestLimit",cfg["requestLimit"]},{"blockLimit",cfg["blockLimit"]},{"sendLimit",cfg["sendLimit"]},{"receiveLimit",cfg["receiveLimit"]},
|
|
{"sent",static_cast<int>(self["giftSent"].size())},{"received",static_cast<int>(self["giftFrom"].size())},
|
|
{"friends",rows("friendIds",true)},{"incoming",rows("friendIn",false)},{"outgoing",rows("friendOut",false)},{"blocked",rows("friendBlocked",false)}};
|
|
}
|
|
std::string make_friend(sqlite3* db,J& self,int64_t selfPid,const J& c,int64_t now,J& out,std::vector<FriendWrite>& writes,const std::function<uint32_t(uint32_t)>& random) {
|
|
std::string action=c.at("action").get<std::string>();
|
|
const auto& cfg=config()["friends"];
|
|
auto finish=[&](const J* card){out=friend_book(db,self,selfPid,now,writes);if(card) out["card"]=*card;return "";};
|
|
if(action=="friend.list") return finish(nullptr);
|
|
int64_t target=integer(c,"target");
|
|
if(target<=0) return "friend_missing";
|
|
if(target==selfPid) return "friend_self";
|
|
bool mutating=action!="friend.lookup"&&action!="friend.visit";
|
|
J other;std::string name;bool save=false;
|
|
std::string loaded=load_friend(db,target,now,mutating,other,name,save);
|
|
if(!loaded.empty()) return loaded;
|
|
bool ready=!other.is_null();
|
|
std::string relation=relation_of(self,target);
|
|
bool theyBlocked=ready&&has_id(other["friendBlocked"],selfPid);
|
|
if(theyBlocked&&action!="friend.list") return "friend_blocked";
|
|
auto remember=[&](){if(save) writes.push_back({target,name,other});};
|
|
if(action=="friend.lookup") {
|
|
J card=friend_card(target,name,relation,nullptr,false,false);
|
|
remember();return finish(&card);
|
|
}
|
|
if(action=="friend.visit") {
|
|
if(relation!="friend"||!ready) return "friend_required";
|
|
J card=friend_card(target,name,relation,&other,true,true);
|
|
remember();return finish(&card);
|
|
}
|
|
if(action=="friend.treasure") {
|
|
if(relation!="friend"||!ready||!has_id(self["friendIds"],target)||!has_id(other["friendIds"],selfPid)) return "friend_required";
|
|
if(self["clearedStages"][0].get<int>()!=1) return "treasure_locked";
|
|
const auto& hunt=config().at("treasure");
|
|
if(self["treasurePlayed"].get<int>()>=hunt.at("dailyLimit").get<int>()) return "treasure_limit";
|
|
if(has_id(self["treasureSeen"],target)) return "treasure_seen";
|
|
int clears=0;
|
|
for(size_t i=0;i<self["clearedStages"].size();++i) if(self["clearedStages"][i].get<int>()==1) clears++;
|
|
J reward={{"coins",0},{"candy",0},{"balls",0},{"tickets",0}};
|
|
for(const auto& band:hunt.at("bands")) if(clears>=band.at("minClears").get<int>()) reward=band.at("reward");
|
|
int count=static_cast<int>(hunt.at("events").size());
|
|
uint32_t roll;
|
|
if(random) roll=random(static_cast<uint32_t>(count));
|
|
else {std::random_device source;roll=std::uniform_int_distribution<uint32_t>(0,static_cast<uint32_t>(count-1))(source);}
|
|
if(roll>=static_cast<uint32_t>(count)) throw std::runtime_error("invalid_random_source");
|
|
grant(self,reward);
|
|
add_id(self["treasureSeen"],target);
|
|
self["treasurePlayed"]=self["treasurePlayed"].get<int>()+1;
|
|
J card=friend_card(target,name,relation,&other,true,true);
|
|
remember();
|
|
out=friend_book(db,self,selfPid,now,writes);
|
|
out["card"]=card;
|
|
out["treasure"]={{"index",static_cast<int>(roll)},{"reward",reward}};
|
|
return "";
|
|
}
|
|
if(!ready) return "friend_unavailable";
|
|
auto bothFriends=[&](){return has_id(self["friendIds"],target)&&has_id(other["friendIds"],selfPid);};
|
|
auto become=[&](){
|
|
if(static_cast<int>(self["friendIds"].size())>=cfg["limit"].get<int>()||static_cast<int>(other["friendIds"].size())>=cfg["limit"].get<int>()) return std::string("friend_full");
|
|
erase_id(self["friendIn"],target);erase_id(self["friendOut"],target);erase_id(other["friendIn"],selfPid);erase_id(other["friendOut"],selfPid);
|
|
add_id(self["friendIds"],target);add_id(other["friendIds"],selfPid);save=true;return std::string();
|
|
};
|
|
if(action=="friend.request") {
|
|
if(relation=="blocked"||has_id(other["friendBlocked"],selfPid)) return "friend_blocked";
|
|
if(relation=="friend") return "friend_exists";
|
|
if(relation=="outgoing") return "friend_pending";
|
|
if(relation=="incoming") {auto error=become();if(!error.empty()) return error;}
|
|
else {
|
|
if(static_cast<int>(self["friendOut"].size())>=cfg["requestLimit"].get<int>()||static_cast<int>(other["friendIn"].size())>=cfg["requestLimit"].get<int>()) return "friend_full";
|
|
if(static_cast<int>(self["friendIds"].size())>=cfg["limit"].get<int>()||static_cast<int>(other["friendIds"].size())>=cfg["limit"].get<int>()) return "friend_full";
|
|
add_id(self["friendOut"],target);add_id(other["friendIn"],selfPid);save=true;
|
|
}
|
|
} else if(action=="friend.accept") {
|
|
if(relation!="incoming"||!has_id(other["friendOut"],selfPid)) return "friend_request_missing";
|
|
auto error=become();if(!error.empty()) return error;
|
|
} else if(action=="friend.decline") {
|
|
if(relation!="incoming") return "friend_request_missing";
|
|
erase_id(self["friendIn"],target);erase_id(other["friendOut"],selfPid);save=true;
|
|
} else if(action=="friend.cancel") {
|
|
if(relation!="outgoing") return "friend_request_missing";
|
|
erase_id(self["friendOut"],target);erase_id(other["friendIn"],selfPid);save=true;
|
|
} else if(action=="friend.remove") {
|
|
if(!bothFriends()) return "friend_required";
|
|
erase_id(self["friendIds"],target);erase_id(other["friendIds"],selfPid);save=true;
|
|
} else if(action=="friend.block") {
|
|
if(relation!="blocked"&&static_cast<int>(self["friendBlocked"].size())>=cfg["blockLimit"].get<int>()) return "friend_full";
|
|
erase_id(self["friendIds"],target);erase_id(self["friendIn"],target);erase_id(self["friendOut"],target);
|
|
erase_id(other["friendIds"],selfPid);erase_id(other["friendIn"],selfPid);erase_id(other["friendOut"],selfPid);
|
|
add_id(self["friendBlocked"],target);save=true;
|
|
} else if(action=="friend.unblock") {
|
|
if(relation!="blocked") return "friend_not_blocked";
|
|
erase_id(self["friendBlocked"],target);save=true;
|
|
} else if(action=="friend.gift") {
|
|
if(!bothFriends()) return "friend_required";
|
|
if(relation=="blocked"||has_id(other["friendBlocked"],selfPid)) return "friend_blocked";
|
|
if(has_id(self["giftSent"],target)) return "gift_sent";
|
|
if(has_id(self["giftFrom"],target)||has_id(other["giftSent"],selfPid)) return "gift_pair";
|
|
if(static_cast<int>(self["giftSent"].size())>=cfg["sendLimit"].get<int>()) return "gift_send_limit";
|
|
if(static_cast<int>(other["giftFrom"].size())>=cfg["receiveLimit"].get<int>()) return "gift_receive_limit";
|
|
grant(other,cfg["gift"]);add_id(self["giftSent"],target);add_id(other["giftFrom"],selfPid);save=true;
|
|
} else return "unknown_action";
|
|
remember();
|
|
J card=friend_card(target,name,relation_of(self,target),&other,relation_of(self,target)=="friend",action=="friend.visit");
|
|
return finish(&card);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
std::lock_guard<std::recursive_mutex> guard(mutex_);
|
|
auto pid=pid_for_token(token);if(!pid) return {401,"{\"ok\":false,\"error\":\"unauthorized\"}"};
|
|
if(now<=0) now=static_cast<int64_t>(std::time(nullptr));
|
|
J c;std::string id;
|
|
try {
|
|
if(!command.empty()) {
|
|
if(command.size()>8192) throw std::invalid_argument("size");
|
|
c=J::parse(command);id=c.at("requestId").get<std::string>();
|
|
if(!identifier(id)||!c.at("action").is_string()) throw std::invalid_argument("id");
|
|
integer(c,"revision");
|
|
}
|
|
} catch(...) {return {400,"{\"ok\":false,\"error\":\"invalid_input\"}"};}
|
|
// An authenticated page read is bounded and has no calendar, revision or operation-log writes.
|
|
if(!command.empty()&&c.at("action")=="battle.events") {
|
|
try {
|
|
int cursor=integer(c,"cursor"),limit=integer(c,"limit");
|
|
if(cursor<0||limit<1||limit>256||!c.at("activityId").is_string()||!c.at("traceId").is_string())throw std::invalid_argument("page");
|
|
Statement q(db_,"SELECT state FROM game_states WHERE pid=?");q.number(1,*pid);
|
|
if(q.step()!=SQLITE_ROW)return {404,"{\"ok\":false,\"error\":\"activity_missing\"}"};
|
|
auto state=J::parse(q.column(0));
|
|
if(state.at("pendingBattle")!=c.at("activityId")||!state.contains("battleTrace"))return {404,"{\"ok\":false,\"error\":\"activity_missing\"}"};
|
|
const auto& trace=state.at("battleTrace");
|
|
if(trace.at("traceId")!=c.at("traceId"))return {409,"{\"ok\":false,\"error\":\"trace_mismatch\"}"};
|
|
const auto& events=trace.at("events");int total=static_cast<int>(events.size());if(cursor>total)throw std::invalid_argument("cursor");
|
|
int next=std::min(total,cursor+limit);J page=J::array();for(int i=cursor;i<next;i++)page.push_back(events[i]);std::string payload=page.dump();
|
|
J reply={{"ok",true},{"page",{{"activityId",c["activityId"]},{"traceId",trace["traceId"]},{"cursor",cursor},{"nextCursor",next},{"total",total},{"payload",payload},{"sha256",crypto::sha256_hex(payload)}}}};
|
|
return {200,reply.dump()};
|
|
}catch(const J::exception&){return {400,"{\"ok\":false,\"error\":\"invalid_input\"}"};}catch(const std::invalid_argument&){return {400,"{\"ok\":false,\"error\":\"invalid_input\"}"};}catch(...){return {500,"{\"ok\":false,\"error\":\"storage_unavailable\"}"};}
|
|
}
|
|
try {
|
|
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,savedRank,savedFriends;std::string error;
|
|
bool replayed=false,keepRank=false,keepFriends=false;
|
|
std::vector<FriendWrite> friendWrites;
|
|
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;}
|
|
if(old.contains("friends")) {savedFriends=old["friends"];keepFriends=true;}
|
|
} else {
|
|
auto before=s;
|
|
try {
|
|
auto actionName=c.at("action").get<std::string>();
|
|
if(actionName.rfind("friend.",0)==0) {error=make_friend(db_,s,*pid,c,now,savedFriends,friendWrites,random);if(error.empty()) keepFriends=true;}
|
|
else if(actionName=="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;keepFriends=false;friendWrites.clear();}
|
|
}
|
|
}
|
|
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;
|
|
if(keepFriends) r["friends"]=savedFriends;
|
|
{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();}
|
|
for(const auto& row:friendWrites) store_friend(db_,row);
|
|
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\"}"};}
|
|
}
|