Add prototype gameplay loop with character/battlefield assets and server game APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-20 18:50:00 +08:00
co-authored by Cursor
parent 79369e4473
commit 6c64210331
833 changed files with 998079 additions and 1042 deletions
+19
View File
@@ -17,6 +17,7 @@ Database::Database(const std::string& path) {
}
exec("PRAGMA journal_mode=WAL;");
exec("PRAGMA foreign_keys=ON;");
sqlite3_busy_timeout(db_, 5000);
const char* schema = R"SQL(
CREATE TABLE IF NOT EXISTS users (
pid INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -33,6 +34,17 @@ CREATE TABLE IF NOT EXISTS tokens (
FOREIGN KEY(pid) REFERENCES users(pid) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tokens_pid ON tokens(pid);
CREATE TABLE IF NOT EXISTS game_states (
pid INTEGER PRIMARY KEY REFERENCES users(pid) ON DELETE CASCADE,
state TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS game_operations (
pid INTEGER NOT NULL REFERENCES users(pid) ON DELETE CASCADE,
request_id TEXT NOT NULL,
request TEXT NOT NULL,
response TEXT NOT NULL,
PRIMARY KEY(pid, request_id)
);
)SQL";
if (!exec(schema)) {
sqlite3_close(db_);
@@ -45,6 +57,7 @@ Database::~Database() {
}
bool Database::exec(const char* sql) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
char* errmsg = nullptr;
if (sqlite3_exec(db_, sql, nullptr, nullptr, &errmsg) != SQLITE_OK) {
err_ = errmsg ? errmsg : "exec failed";
@@ -56,6 +69,7 @@ bool Database::exec(const char* sql) {
std::optional<int64_t> Database::create_user(const std::string& username, const std::string& password_hash,
const std::string& salt) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql =
"INSERT INTO users(username, password_hash, salt, created_at) VALUES(?,?,?,?);";
sqlite3_stmt* st = nullptr;
@@ -78,6 +92,7 @@ std::optional<int64_t> Database::create_user(const std::string& username, const
}
std::optional<User> Database::find_user_by_name(const std::string& username) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql = "SELECT pid, username, password_hash, salt FROM users WHERE username=? COLLATE NOCASE LIMIT 1;";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) {
@@ -99,6 +114,7 @@ std::optional<User> Database::find_user_by_name(const std::string& username) {
}
std::optional<User> Database::find_user_by_pid(int64_t pid) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql = "SELECT pid, username, password_hash, salt FROM users WHERE pid=? LIMIT 1;";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) {
@@ -120,6 +136,7 @@ std::optional<User> Database::find_user_by_pid(int64_t pid) {
}
bool Database::store_token(const std::string& token, int64_t pid, int64_t created_at, int64_t expires_at) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql = "INSERT OR REPLACE INTO tokens(token, pid, created_at, expires_at) VALUES(?,?,?,?);";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) {
@@ -140,6 +157,7 @@ bool Database::store_token(const std::string& token, int64_t pid, int64_t create
}
std::optional<int64_t> Database::pid_for_token(const std::string& token) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
const char* sql = "SELECT pid, expires_at FROM tokens WHERE token=? LIMIT 1;";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) {
@@ -158,6 +176,7 @@ std::optional<int64_t> Database::pid_for_token(const std::string& token) {
}
void Database::purge_expired_tokens(int64_t now) {
std::lock_guard<std::recursive_mutex> guard(mutex_);
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM tokens WHERE expires_at < ?;", -1, &st, nullptr) != SQLITE_OK)
return;
+9
View File
@@ -2,11 +2,15 @@
#include <cstdint>
#include <optional>
#include <string>
#include <mutex>
#include <functional>
struct sqlite3;
namespace db {
struct GameReply { int status; std::string body; };
struct User {
int64_t pid = 0;
std::string username;
@@ -35,10 +39,15 @@ class Database {
std::optional<int64_t> pid_for_token(const std::string& token);
void purge_expired_tokens(int64_t now);
// Empty command reads a snapshot. now is injectable for deterministic server tests only.
GameReply game_request(const std::string& token, const std::string& command = {}, int64_t now = 0,
const std::function<uint32_t(uint32_t)>& random = {});
private:
bool exec(const char* sql);
sqlite3* db_ = nullptr;
std::string err_;
std::recursive_mutex mutex_;
};
} // namespace db
+577
View File
@@ -0,0 +1,577 @@
#include "db.hpp"
#include "game_config.hpp"
#include "json.hpp"
#include "sqlite3.h"
#include <algorithm>
#include <cmath>
#include <cctype>
#include <ctime>
#include <stdexcept>
#include <vector>
#include <random>
#include <functional>
namespace {
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;}
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.at("stages").size()!=config()["pets"].size() || s.at("shards").size()!=config()["pets"].size() || s.at("clearedStages").size()!=config()["stages"].size()) throw std::runtime_error("unsupported_save");
if(!s.at("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);
}
}
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,int stageId) {
struct Hero {int pet,slot,tier,skills=0;float hp,max,attack,cd=0,skill=0,shield=0,shieldTime=0,damage=0,healing=0,absorbed=0;};
struct Enemy {int index,type,lane;float hp,z=11,cd=0,slowTime=0,slow=1;};
const auto& stage=gameplay::stage(stageId);const auto& pets=config()["pets"];const auto& types=config()["enemies"];
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,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=='_';});
}
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(s["pendingShot"]!=""||s["pendingBattle"]!="") 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(s["pendingShot"]!=""||s["pendingBattle"]!="") 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;
s["pendingBattle"]=c["requestId"];s["battleData"]=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}};
} else if(action=="battle.finish"||action=="battle.abandon") {
if(!c.contains("activityId")||c["activityId"]!=s["pendingBattle"]||s["pendingBattle"]=="") return "activity_missing";
bool abandon=action=="battle.abandon";
if(!abandon&&now<s["battleReadyAt"].get<int64_t>()) return "activity_not_ready";
result=s["battleData"];if(abandon) result["won"]=false;
int stageId=result["stageId"];bool first=result["won"].get<bool>()&&s["clearedStages"][stageId-1]==0;result["firstClear"]=first;
if(first) {
s["clearedStages"][stageId-1]=1;if(stageId==1) s["cleared"]=true;
for(const char* key:{"coins","candy","balls"}) s[key]=s[key].get<int>()+result["reward"][key].get<int>();
int chest=first_clear_chest(stageId);
if(chest>=0) {s["chests"][chest]=s["chests"][chest].get<int>()+1;result["chest"]=chest;}
}
result["id"]=s["pendingBattle"];s["lastBattleData"]=result;
s["lastBattle"]=s["pendingBattle"];s["pendingBattle"]="";
s.erase("battleData");s.erase("battleReadyAt");s.erase("battleSnapshot");
if(!abandon) daily_event(s,1);
} else {
if(s["pendingBattle"]!=""||s["pendingShot"]!="") 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","shotReadyAt","battleReadyAt","lastBattleData","lastCaptureData"}) visible.erase(k);
J r{{"ok",true},{"state",visible},{"serverTime",now},{"configVersion",config()["version"]},{"config",config()}};
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"]}};
}
return r;
}
}
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\"}"};}
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;std::string error;
bool replayed=false;
if(!command.empty()) {
Statement q(db_,"SELECT request,response FROM game_operations WHERE pid=? AND request_id=?");q.number(1,*pid);q.text(2,id);
if(q.step()==SQLITE_ROW) {
if(q.column(0)!=c.dump()) return {409,"{\"ok\":false,\"error\":\"request_id_conflict\"}"};
auto old=J::parse(q.column(1));result=old["result"];error=old.value("error","");replayed=true;
} else {
auto before=s;
try {error=apply(s,c,now,result,random);} catch(const J::exception&) {error="invalid_input";} catch(const std::invalid_argument&) {error="invalid_input";}
if(!error.empty()) s=before;
}
}
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>();
{Statement q(db_,"INSERT INTO game_states(pid,state) VALUES(?,?) ON CONFLICT(pid) DO UPDATE SET state=excluded.state");q.number(1,*pid);q.text(2,s.dump());q.step();}
if(!command.empty()&&!replayed) {
Statement q(db_,"INSERT INTO game_operations(pid,request_id,request,response) VALUES(?,?,?,?)");
q.number(1,*pid);q.text(2,id);q.text(3,c.dump());q.text(4,r.dump());q.step();
}
tx.commit();return {200,r.dump()};
} catch(...) {return {500,"{\"ok\":false,\"error\":\"storage_unavailable\"}"};}
}
+99
View File
@@ -0,0 +1,99 @@
#include "game_config.hpp"
#include "game_config_data.hpp"
#include <stdexcept>
#include <algorithm>
#include <vector>
namespace gameplay {
const nlohmann::json& config() {
static const auto data=[] {
auto j=nlohmann::json::parse(kGameplayJson);
if(j.at("schema")!=1 || j.at("pets").size()!=10 || j.at("stages").size()!=20 || j.at("enemies").size()!=5)
throw std::runtime_error("Unsupported gameplay config");
if(j.at("battleRules")!=2) throw std::runtime_error("Unsupported battle rules");
for(int i=0;i<20;i++) {
const auto& s=j.at("stages").at(i);
if(s.at("id")!=i+1 || s.at("enemyIds").size()!=9 || s.at("hpScale").get<float>()<=0 || s.at("timeout").get<float>()<=0)
throw std::runtime_error("Invalid stage config");
for(const auto& id:s.at("enemyIds")) if(!id.is_number_integer()||id.get<int>()<0||id.get<int>()>=5) throw std::runtime_error("Invalid enemy id");
if(!s.at("chapter").is_string()||s["chapter"].get<std::string>().empty()) throw std::runtime_error("Invalid stage chapter");
int lanes=s.at("lanes");if(lanes<1||lanes>3||s.at("enemyLanes").size()!=9)throw std::runtime_error("Invalid lane config");
for(const auto& lane:s["enemyLanes"])if(!lane.is_number_integer()||lane.get<int>()<0||lane.get<int>()>=lanes)throw std::runtime_error("Invalid enemy lane");
}
for(const auto& p:j["pets"]) {
const auto& a=p.at("ability");std::string kind=a.at("kind");
if(kind!="shield"&&kind!="heal"&&kind!="burst"&&kind!="chain"&&kind!="slow")throw std::runtime_error("Invalid skill kind");
if(a.at("name").get<std::string>().empty()||a.at("target")!=(kind=="shield"?"self":kind=="heal"?"ally":"enemy"))throw std::runtime_error("Invalid skill target");
auto number=[&](const char* key,float lo,float hi){if(!a.at(key).is_number()||a[key].get<float>()<lo||a[key].get<float>()>hi)throw std::runtime_error("Invalid skill value");};
number("cooldown",.05f,120);number("range",0,12);number("duration",0,30);number("t2Duration",0,30);number("t1",.0001f,10000);number("t2",.0001f,10000);
for(const char* key:{"radius","t2Radius","targets","t2Targets"})if(!a.at(key).is_number_integer()||a[key].get<int>()<(std::string(key).find("argets")!=std::string::npos?1:0)||a[key].get<int>()>(std::string(key).find("argets")!=std::string::npos?3:2))throw std::runtime_error("Invalid skill capacity");
if(kind=="slow"&&(a["t1"].get<float>()>1||a["t2"].get<float>()>1))throw std::runtime_error("Invalid slow factor");
if(kind=="slow")number("bossControlScale",0,1);
}
const auto& pool=j.at("capture");
const auto& daily=j.at("daily");
if(daily.at("utcOffsetSeconds")!=28800||daily.at("tasks").size()!=3||daily.at("signin").size()!=7) throw std::runtime_error("Invalid daily config");
for(int i=0;i<3;i++) if(daily["tasks"][i].at("id")!=i||daily["tasks"][i].at("target")!=(i==0?3:1)) throw std::runtime_error("Invalid daily task");
auto validReward=[](const auto& r){for(const char* key:{"coins","candy","balls"}) if(!r.at(key).is_number_integer()||r.at(key).template get<int>()<0||r.at(key).template get<int>()>10000) throw std::runtime_error("Invalid daily reward");};
const auto& mail=j.at("mail");
if(!mail.at("lifetimeSeconds").is_number_integer()||mail["lifetimeSeconds"].get<int>()<1||mail["lifetimeSeconds"].get<int>()>31536000||!mail.at("initial").is_array()||mail["initial"].size()>100) throw std::runtime_error("Invalid mail config");
std::vector<std::string> mailIds;
for(const auto& m:mail["initial"]) {
auto id=m.at("mailId").get<std::string>();
if(id.empty()||id.size()>64||std::find(mailIds.begin(),mailIds.end(),id)!=mailIds.end()||m.at("title").get<std::string>().empty()||m["title"].get<std::string>().size()>240||m.at("body").get<std::string>().empty()||m["body"].get<std::string>().size()>4000) throw std::runtime_error("Invalid initial mail");
mailIds.push_back(id);validReward(m.at("reward"));
if(!m["reward"].at("tickets").is_number_integer()||m["reward"]["tickets"].get<int>()<0||m["reward"]["tickets"].get<int>()>10000) throw std::runtime_error("Invalid mail attachment");
}
for(const auto& t:daily["tasks"]) validReward(t.at("reward"));for(const auto& r:daily["signin"]) validReward(r);
const auto& shop=j.at("shop").at("goods");if(shop.size()!=3) throw std::runtime_error("Invalid shop config");
for(int i=0;i<3;i++) {
const auto& g=shop[i];if(g.at("id")!=i||g.at("coins").get<int>()<0||g.at("coins").get<int>()>100000||g.at("dailyLimit").get<int>()<1||g.at("dailyLimit").get<int>()>100||g.at("unlockStage").get<int>()<0||g.at("unlockStage").get<int>()>5) throw std::runtime_error("Invalid goods");
validReward(g.at("reward"));if(!g["reward"].at("tickets").is_number_integer()||g["reward"]["tickets"].get<int>()<0||g["reward"]["tickets"].get<int>()>10000) throw std::runtime_error("Invalid ticket reward");
}
const auto& rewards=j.at("rewards");
const auto& chests=rewards.at("chests");
if(!chests.is_array()||chests.size()!=3) throw std::runtime_error("Invalid chest config");
auto chestReward=[&](const auto& r){
validReward(r);
if(!r.at("tickets").is_number_integer()||r["tickets"].get<int>()<0||r["tickets"].get<int>()>10000) throw std::runtime_error("Invalid chest tickets");
if(!r.at("shardsPet").is_number_integer()||!r.at("shards").is_number_integer()) throw std::runtime_error("Invalid chest shards");
int pet=r["shardsPet"].get<int>(),shards=r["shards"].get<int>();
if(shards<0||shards>10000||(shards>0&&(pet<0||pet>=static_cast<int>(j["pets"].size())))||(shards==0&&pet!=-1)) throw std::runtime_error("Invalid chest shard reward");
};
for(int i=0;i<3;i++) {
const auto& c=chests[i];auto kind=c.at("kind").get<std::string>();
if(c.at("id")!=i||c.at("name").get<std::string>().empty()||kind!="fixed"&&kind!="choice"||!c.at("choices").is_array()) throw std::runtime_error("Invalid chest");
chestReward(c.at("contents"));
if(kind=="fixed"&&!c["choices"].empty()) throw std::runtime_error("Fixed chest cannot have choices");
if(kind=="choice"&&(c["choices"].size()<2||c["choices"].size()>j["pets"].size())) throw std::runtime_error("Invalid chest choices");
for(const auto& choice:c["choices"]) {if(choice.at("name").get<std::string>().empty()) throw std::runtime_error("Invalid chest choice");chestReward(choice.at("reward"));}
}
if(!rewards.at("dailyChest").is_number_integer()||rewards["dailyChest"].get<int>()<0||rewards["dailyChest"].get<int>()>=3) throw std::runtime_error("Invalid daily chest");
const auto& drops=rewards.at("stageDrops");if(!drops.is_array()||drops.size()>j["stages"].size()) throw std::runtime_error("Invalid chest drops");
std::vector<int> dropStages;
for(const auto& drop:drops) {
int stage=drop.at("stageId").get<int>(),chest=drop.at("chest").get<int>();
if(stage<1||stage>static_cast<int>(j["stages"].size())||chest<0||chest>=3||std::find(dropStages.begin(),dropStages.end(),stage)!=dropStages.end()) throw std::runtime_error("Invalid chest drop");
dropStages.push_back(stage);
}
const auto& camp=j.at("camp");
if(camp.at("slots")!=3||camp.at("decorations").size()!=3||camp.at("restUnlockStage")!=3||camp.at("buildUnlockStage")!=5) throw std::runtime_error("Invalid camp config");
for(int i=0;i<3;i++) if(camp["decorations"][i].at("id")!=i||camp["decorations"][i].at("coins").get<int>()<=0) throw std::runtime_error("Invalid decoration config");
if(pool.at("weights").size()!=j.at("pets").size()||pool.at("tutorialPet")!=2||pool.at("duplicateLimit").get<int>()<1) throw std::runtime_error("Invalid capture pool");
for(const auto& weight:pool.at("weights")) if(!weight.is_number_integer()||weight.get<int>()<1||weight.get<int>()>1000) throw std::runtime_error("Invalid capture weight");
j["version"]=kGameplayVersion;
return j;
}();
return data;
}
const nlohmann::json& stage(int id) {return config().at("stages").at(id-1);}
float element_multiplier(int attack,int defense) {
if(attack==0||defense==0) return 1;
const auto& e=config().at("elements");const auto& cycle=e.at("cycle");
for(size_t i=0;i<cycle.size();i++) {
if(cycle[i]==attack && cycle[(i+1)%cycle.size()]==defense) return e.at("advantage").get<float>();
if(cycle[i]==defense && cycle[(i+1)%cycle.size()]==attack) return e.at("disadvantage").get<float>();
}
return 1;
}
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include "json.hpp"
namespace gameplay {
const nlohmann::json& config();
const nlohmann::json& stage(int id);
float element_multiplier(int attack, int defense);
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
// Generated by CMake from the canonical JSON. Do not edit the generated header.
// The file is emitted as adjacent literal chunks so it can exceed MSVC's per-literal limit.
static constexpr const char* kGameplayJson =
@GAMEPLAY_JSON_CHUNKS@;
static constexpr const char* kGameplayVersion = "@GAMEPLAY_VERSION@";
+21 -1
View File
@@ -364,6 +364,10 @@ void HttpWsServer::handle_client(uintptr_t client_sock) {
auto cl_it = req.headers.find("content-length");
size_t content_length = 0;
if (cl_it != req.headers.end()) content_length = static_cast<size_t>(std::strtoull(cl_it->second.c_str(), nullptr, 10));
if (content_length > 64 * 1024) {
auto resp = http_response(413, "Content Too Large", "{\"error\":\"body_too_large\"}");
send_all(s, resp.data(), resp.size()); return;
}
while (req.body.size() < content_length) {
if (!recv_some(s, raw, 1024 * 1024)) return;
const auto pos = raw.find("\r\n\r\n");
@@ -418,6 +422,22 @@ void HttpWsServer::handle_client(uintptr_t client_sock) {
}
// HTTP API
if ((req.method == "GET" && req.path == "/game/state") ||
(req.method == "POST" && req.path == "/game/command")) {
const auto m = maintenance::load(maintenance_path_);
db::GameReply r;
if (m.enabled) r = {503, nlohmann::json{{"ok",false},{"error","maintenance"}}.dump()};
else {
const auto header = req.headers.find("authorization");
const auto value = header == req.headers.end() ? "" : header->second;
const auto token = value.rfind("Bearer ",0) == 0 ? value.substr(7) : "";
// An empty POST is malformed, never silently interpreted as a state read.
if (req.method == "POST" && req.body.empty()) r = {400,"{\"error\":\"invalid_input\"}"};
else r = db_.game_request(token, req.method == "POST" ? req.body : "");
}
auto resp = http_response(r.status, r.status == 200 ? "OK" : "Error", r.body);
send_all(s, resp.data(), resp.size()); return;
}
if (req.method == "POST" && (req.path == "/register" || req.path == "/login")) {
std::string username, password;
try {
@@ -444,7 +464,7 @@ void HttpWsServer::handle_client(uintptr_t client_sock) {
if (req.method == "GET" && req.path == "/health") {
const auto m = maintenance::load(maintenance_path_);
nlohmann::json body{{"ok", true}, {"service", "aiserver"}, {"maintenance", m.enabled}};
nlohmann::json body{{"ok", true}, {"service", "aiserver"}, {"maintenance", m.enabled}, {"gameProtocol", 1}};
if (m.enabled) body["message"] = m.message;
auto resp = http_response(200, "OK", body.dump());
send_all(s, resp.data(), resp.size());