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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-21 23:01:31 +08:00
co-authored by Cursor
parent 89e0fa246f
commit 079313fb06
677 changed files with 395371 additions and 9692 deletions
+376 -25
View File
@@ -1,5 +1,7 @@
#include "db.hpp"
#include "game_config.hpp"
#include "combat_runtime.hpp"
#include "crypto.hpp"
#include "json.hpp"
#include "sqlite3.h"
#include <algorithm>
@@ -13,6 +15,7 @@
#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"); }
@@ -45,6 +48,17 @@ struct Transaction {
~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;
@@ -96,18 +110,62 @@ void migrate(J& s,int64_t now) {
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());
// 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"]) {
@@ -135,6 +193,9 @@ void sync_calendar(J& s,int64_t now) {
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);}
@@ -318,6 +379,62 @@ J battle(const J& s,int stageId) {
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>();
@@ -366,7 +483,7 @@ std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint
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(s["pendingShot"]!=""||s["pendingBattle"]!="") return "pending_activity";
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;
@@ -384,7 +501,7 @@ std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint
s.erase("shotData");s.erase("shotReadyAt");
daily_event(s,0);
} else if(action=="battle.begin") {
if(s["pendingShot"]!=""||s["pendingBattle"]!="") return "pending_activity";
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";
@@ -392,12 +509,14 @@ std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint
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;
s["pendingBattle"]=c["requestId"];s["battleData"]=battle(s,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(s["pendingShot"]!=""||s["pendingBattle"]!="") return "pending_activity";
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";
@@ -411,12 +530,14 @@ std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint
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);
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";
@@ -451,10 +572,52 @@ std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint
}
result["id"]=s["pendingBattle"];s["lastBattleData"]=result;
s["lastBattle"]=s["pendingBattle"];s["pendingBattle"]="";
s.erase("battleData");s.erase("battleReadyAt");s.erase("battleSnapshot");
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(s["pendingBattle"]!=""||s["pendingShot"]!="") return "pending_activity";
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;
@@ -584,8 +747,9 @@ std::string apply(J& s,const J& c,int64_t now,J& result,const std::function<uint
return "";
}
J response(const J& s,int64_t now) {
J visible=s;for(const char* k:{"shotData","battleData","battleSnapshot","shotReadyAt","battleReadyAt","lastBattleData","lastCaptureData"}) visible.erase(k);
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"]}};
@@ -594,6 +758,11 @@ J response(const J& s,int64_t now) {
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;
}
@@ -648,6 +817,166 @@ std::string make_rank(sqlite3* db,const J& self,int64_t selfPid,int board,J& out
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) {
@@ -663,36 +992,58 @@ db::GameReply db::Database::game_request(const std::string& token,const std::str
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;std::string error;
bool replayed=false,keepRank=false;
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 {
if(c.at("action")=="rank.list") {error=make_rank(db_,s,*pid,integer(c,"stageId"),savedRank);if(error.empty()) keepRank=true;}
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;}
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(const std::exception& e) {return {500,std::string("{\"ok\":false,\"error\":\"")+e.what()+"\"}"};}
catch(...) {return {500,"{\"ok\":false,\"error\":\"storage_unavailable\"}"};}
} catch(...) {return {500,"{\"ok\":false,\"error\":\"storage_unavailable\"}"};}
}