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
+63
View File
@@ -0,0 +1,63 @@
#include "combat_runtime.hpp"
#include "combat_slice.hpp"
#include "skill_runtime_data.hpp"
#include <algorithm>
#include <cmath>
#include <set>
#include <stdexcept>
namespace combat_runtime {
using J=nlohmann::json;
using namespace combat_slice;
static int64_t fixed(double n){return std::llround(n*1000);}
static int ticks(double seconds){return static_cast<int>(std::ceil(seconds*20-1e-8));}
const J& definitions(){static const J data=[](){
auto d=J::parse(kSkillRuntimeJson);
if(d.at("protocol")!=1||d.at("logicVersion")!=1||d.at("runtimeEnabled")!=true||d.at("skills").size()!=25||d.at("clock").at("tickMs")!=50)throw std::runtime_error("Invalid runtime skill config");
std::set<std::string> ids;
for(const auto& t:d.at("templates")){
if(!ids.insert(t.at("id")).second)throw std::runtime_error("Duplicate skill template");std::set<std::string> actions;
for(const auto& a:t.at("actions")){std::string type=a.at("type");if(!actions.insert(a.at("id")).second||(type!="damage"&&type!="heal"&&type!="apply_status")||a.at("trigger")!="delivery_hit")throw std::runtime_error("Unsupported action");}
}
ids.clear();for(const auto& s:d.at("skills"))if(!ids.insert(s.at("id")).second)throw std::runtime_error("Duplicate skill binding");
return d;}();return data;
}
static Skill resolve(const J& binding,const J& p,int tier,int64_t attack){
const auto& plan=definitions();J t;for(const auto& v:plan["templates"])if(v["id"]==binding["template"])t=v;if(t.is_null())throw std::runtime_error("Missing skill template");
const bool ability=binding["slot"]=="ability";const auto& data=ability?p.at("ability"):p;
auto timing=binding.value("timing",t["timing"]);Skill s;s.id=binding["id"];s.ability=ability;s.projectile=t["delivery"]=="projectile";s.release=timing["releaseTicks"];s.recovery=timing["recoveryTicks"];
s.cooldown=ticks(data.at("cooldown"));s.range=static_cast<int>(fixed(data.at("range")));s.value=attack;
if(ability){s.targets=data.at(tier==2?"t2Targets":"targets");s.radius=data.at(tier==2?"t2Radius":"radius");s.duration=ticks(data.at(tier==2?"t2Duration":"duration"));}
for(const auto& action:t.at("actions")){
Effect e;e.id=action.at("id");std::string type=action.at("type");e.duration=s.duration;
if(type=="damage"){e.action=Action::Damage;e.value=attack;if(action.value("scale","")=="ability_value")e.value=attack*std::llround(data.at(tier==2?"t2":"t1").get<double>()*10000)/10000;}
else if(type=="heal"){e.action=Action::Heal;e.value=fixed(data.at(tier==2?"t2":"t1"));}
else if(action.at("statusId")=="shield_main"){e.action=Action::Shield;e.value=fixed(data.at(tier==2?"t2":"t1"));}
else if(action.at("statusId")=="slow_main"){e.action=Action::Slow;e.value=std::llround(data.at(tier==2?"t2":"t1").get<double>()*10000);e.bossScale=static_cast<int>(std::llround(data.at("bossControlScale").get<double>()*10000));}
else throw std::runtime_error("Unsupported status");
s.actions.push_back(e);
}
if(s.actions.empty())throw std::runtime_error("Empty skill actions");s.action=s.actions[0].action;
if(s.release+s.recovery>s.cooldown)throw std::runtime_error("Skill duration exceeds cooldown");return s;
}
J simulate(const J& gameplay,const J& save,const J& stage){
const auto& plan=definitions();std::vector<Unit> units;
for(int slot=0;slot<stage.at("lanes").get<int>()*2;slot++){
int pet=save.at("formation").at(slot);if(pet<0)continue;const auto& p=gameplay.at("pets").at(pet);Unit u;u.id=pet;u.art=pet;u.team=0;u.slot=slot;u.lane=slot/2;u.z=slot%2==0?0:-2000;u.tier=save.at("stages").at(pet);u.element=p.at("element");
auto& economy=gameplay.at("economy");int64_t growth=10000+std::llround(economy.at("levelGrowth").get<double>()*10000)*(save.at("level").get<int>()-1);
if(u.tier==2)growth=growth*std::llround(economy.at("evolveGrowth").get<double>()*10000)/10000;
u.hp=u.maxHp=fixed(p.at("hp"))*growth/10000;int64_t attack=fixed(p.at("attack"))*growth/10000;
for(const auto& binding:plan["skills"])if(binding["actorKind"]=="pet"&&binding["actorId"]==pet){bool ability=binding["slot"]=="ability";(ability?u.ability:u.basic)=resolve(binding,p,u.tier,attack);}
if(u.basic.id.empty()||u.ability.id.empty())throw std::runtime_error("Missing pet binding");units.push_back(u);
}
for(int index=0;index<9;index++){
int type=stage.at("enemyIds").at(index);auto d=gameplay.at("enemies").at(type);Unit u;u.id=100+index;u.art=type;u.team=1;u.lane=stage.at("enemyLanes").at(index);u.boss=d.at("boss");u.z=11000;u.spawned=false;
u.spawnTick=ticks(index/3*stage.at("waveGap").get<double>()+index%3*stage.at("spawnGap").get<double>());u.speed=static_cast<int>(std::llround(d.at("speed").get<double>()*50));u.leak=d.at("leak");
u.hp=u.maxHp=fixed((d.at("hp").get<double>()+index*d.at("hpStep").get<double>())*stage.at("hpScale").get<double>());u.defense=fixed(d.at("defense"));u.element=stage.at("element");d["range"]=1;
for(const auto& binding:plan["skills"])if(binding["actorKind"]=="enemy"&&binding["actorId"]==type)u.basic=resolve(binding,d,1,fixed(d.at("attack").get<double>()*stage.at("attackScale").get<double>()));
if(u.basic.id.empty())throw std::runtime_error("Missing enemy binding");units.push_back(u);
}
Settings settings;settings.maxTicks=ticks(stage.at("timeout"));settings.durability=stage.at("durability");settings.elements=stage.at("elementsEnabled");settings.projectileSpeed=plan["projectile"]["speedMmPerTick"];settings.projectileLifetime=plan["projectile"]["maxLifetimeTicks"];
settings.advantage=static_cast<int>(std::llround(gameplay["elements"]["advantage"].get<double>()*10000));settings.disadvantage=static_cast<int>(std::llround(gameplay["elements"]["disadvantage"].get<double>()*10000));
Battle sim(std::move(units),settings);auto trace=sim.run();trace["kind"]="authoritative_battle";trace["protocol"]=1;trace["logicVersion"]=1;trace["rulesVersion"]=3;trace["duration"]=(trace["lastTick"].get<int>()+1)*.05;trace["initialDurability"]=settings.durability;return trace;
}
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#include "json.hpp"
namespace combat_runtime {
const nlohmann::json& definitions();
nlohmann::json simulate(const nlohmann::json& gameplay,const nlohmann::json& save,const nlohmann::json& stage);
}
+114
View File
@@ -0,0 +1,114 @@
#include "combat_slice.hpp"
#include <algorithm>
#include <cstdlib>
#include <stdexcept>
namespace combat_slice {
using J=nlohmann::json;
static J state(const Unit& u) {return {{"id",u.id},{"team",u.team},{"art",u.art},{"lane",u.lane},{"z",u.z},{"hp",u.hp},{"maxHp",u.maxHp},{"shield",u.shield},{"exited",u.exited},{"spawned",u.spawned},{"tier",u.tier},{"slot",u.slot},{"boss",u.boss},{"slow",u.slow}};}
Battle::Battle(std::vector<Unit> units,Settings settings):units_(std::move(units)),settings_(settings) {
if(units_.empty()||units_.size()>32||settings.maxTicks<1||settings.maxTicks>2400||settings.projectileSpeed<1||settings.projectileLifetime<1)throw std::invalid_argument("slice bounds");
std::sort(units_.begin(),units_.end(),[](const Unit&a,const Unit&b){return a.id<b.id;});
initial_=J::array();int last=-1;durability_=settings.durability;
for(auto& u:units_) {
if(u.id<=last||u.id<0||u.team<0||u.team>1||u.hp<=0||u.hp>1000000000||u.speed<0||u.lane<0||u.lane>2)throw std::invalid_argument("slice unit");
last=u.id;if(u.maxHp==0)u.maxHp=u.hp;
for(auto* s:{&u.basic,&u.ability})if(!s->id.empty()&&(s->release<0||s->recovery<1||s->cooldown<1||s->value<0||s->value>1000000000||s->targets<1||s->targets>32||s->range<0||s->duration<0))throw std::invalid_argument("slice skill");
initial_.push_back(state(u));
}
}
Unit& Battle::unit(int id) {auto it=std::find_if(units_.begin(),units_.end(),[id](const Unit&u){return u.id==id;});if(it==units_.end())throw std::out_of_range("entity");return *it;}
void Battle::emit(J event) {if(events_.size()>=50000)throw std::runtime_error("slice event limit");event["tick"]=tick_;event["seq"]=events_.size();events_.push_back(std::move(event));}
std::vector<int> Battle::targets(const Unit& source,const Skill& s) const {
if(s.action==Action::Shield)return {source.id};
if(s.action==Action::Heal) {
auto lane=[](int n){return n==0?0:n==1?-1:1;};std::vector<const Unit*> allies;
for(const auto& u:units_)if(u.alive()&&u.team==source.team&&u.hp<u.maxHp&&std::abs(lane(u.lane)-lane(source.lane))<=s.radius&&std::abs(u.z-source.z)<=s.range)allies.push_back(&u);
std::sort(allies.begin(),allies.end(),[](const Unit*a,const Unit*b){auto x=a->hp*b->maxHp,y=b->hp*a->maxHp;return x==y?a->id<b->id:x<y;});
std::vector<int> ids;for(const auto* u:allies){ids.push_back(u->id);if(static_cast<int>(ids.size())==s.targets)break;}return ids;
}
std::vector<const Unit*> result;
for(const auto& u:units_)if(u.alive()&&u.team!=source.team&&u.lane==source.lane&&std::abs(u.z-source.z)<=s.range)result.push_back(&u);
std::sort(result.begin(),result.end(),[&](const Unit*a,const Unit*b){int da=std::abs(a->z-source.z),db=std::abs(b->z-source.z);return da==db?a->id<b->id:da<db;});
std::vector<int> ids;for(const auto* u:result){ids.push_back(u->id);if(static_cast<int>(ids.size())==s.targets)break;}return ids;
}
void Battle::hit(int sourceId,int targetId,int cast,const Skill& s) {
if(!s.actions.empty()) {
for(const auto& effect:s.actions){Skill one=s;one.actions.clear();one.action=effect.action;one.actionId=effect.id;one.value=effect.value;one.duration=effect.duration;one.bossScale=effect.bossScale;hit(sourceId,targetId,cast,one);}return;
}
auto& source=unit(sourceId);auto& target=unit(targetId);
if(!target.alive()){emit({{"kind","expired"},{"source",sourceId},{"target",targetId},{"cast",cast},{"skill",s.id},{"reason","target_lost"}});return;}
if(s.action==Action::Shield) {
target.shield=std::max(target.shield,s.value);target.shieldExpiry=tick_+s.duration;
emit({{"kind","shield"},{"source",sourceId},{"target",targetId},{"cast",cast},{"skill",s.id},{"action",s.actionId},{"shield",target.shield},{"expires",target.shieldExpiry}});return;
}
if(s.action==Action::Heal){int64_t amount=std::min(target.maxHp-target.hp,s.value);target.hp+=amount;source.healing+=amount;emit({{"kind","heal"},{"source",sourceId},{"target",targetId},{"cast",cast},{"skill",s.id},{"action",s.actionId},{"amount",amount},{"hp",target.hp}});return;}
if(s.action==Action::Slow){int factor=target.boss?10000-static_cast<int>((10000-s.value)*s.bossScale/10000):static_cast<int>(s.value);target.slow=std::min(target.slow,factor);target.slowExpiry=std::max(target.slowExpiry,tick_+s.duration);emit({{"kind","slow"},{"source",sourceId},{"target",targetId},{"cast",cast},{"skill",s.id},{"action",s.actionId},{"factor",target.slow},{"expires",target.slowExpiry}});return;}
int factor=10000;
if(settings_.elements&&source.element&&target.element) {
// Existing cycle: fire -> grass -> rock -> electric -> water -> fire.
const int cycle[]={1,2,3,4,5};
for(int i=0;i<5;i++)if(cycle[i]==source.element){if(cycle[(i+1)%5]==target.element)factor=settings_.advantage;if(cycle[(i+4)%5]==target.element)factor=settings_.disadvantage;}
}
int64_t raw=s.value;
if(settings_.elements)raw=std::max<int64_t>(1000,((raw*factor/10000-target.defense)/1000)*1000);
const int64_t absorbed=std::min(target.shield,raw),amount=std::min(target.hp,raw-absorbed);
target.shield-=absorbed;target.absorbed+=absorbed;target.hp-=amount;source.damage+=amount;
emit({{"kind","hit"},{"source",sourceId},{"target",targetId},{"cast",cast},{"skill",s.id},{"action",s.actionId},{"amount",amount},{"absorbed",absorbed},{"hp",target.hp},{"shield",target.shield}});
if(!target.alive()){if(target.team==1)defeated_++;emit({{"kind","death"},{"source",sourceId},{"target",targetId},{"cast",cast},{"skill",s.id},{"defeated",defeated_}});}
}
void Battle::step() {
if(finished_)return;++tick_;
for(auto& u:units_)if(!u.spawned&&tick_>=u.spawnTick){u.spawned=true;auto event=state(u);event["kind"]="spawn";event["target"]=u.id;emit(std::move(event));}
for(auto& u:units_)if(u.shield>0&&tick_>=u.shieldExpiry){u.shield=0;emit({{"kind","shield_expired"},{"target",u.id},{"shield",0}});}
for(auto& u:units_)if(u.slow<10000&&tick_>=u.slowExpiry){u.slow=10000;emit({{"kind","slow_expired"},{"target",u.id},{"factor",10000}});}
std::vector<Projectile> arriving;
for(auto it=projectiles_.begin();it!=projectiles_.end();)if(it->due<=tick_){arriving.push_back(*it);it=projectiles_.erase(it);}else ++it;
std::sort(arriving.begin(),arriving.end(),[](const Projectile&a,const Projectile&b){if(a.due!=b.due)return a.due<b.due;if(a.source!=b.source)return a.source<b.source;if(a.cast!=b.cast)return a.cast<b.cast;return a.target<b.target;});
for(const auto& p:arriving)hit(p.source,p.target,p.cast,p.skill);
// Enemy movement stops at the front living blocker. No Physics or animation input.
for(auto& u:units_)if(u.alive()&&u.team==1&&!u.cast.active) {
int stop=-4000;for(const auto& h:units_)if(h.alive()&&h.team==0&&h.lane==u.lane)stop=std::max(stop,h.z+1000);
int next=std::max(stop,u.z-static_cast<int>(static_cast<int64_t>(u.speed)*u.slow/10000));
if(next!=u.z){u.z=next;emit({{"kind","move"},{"target",u.id},{"z",u.z}});}
if(u.z<=-4000){u.exited=true;durability_=std::max(0,durability_-u.leak);leaks_[u.lane]++;emit({{"kind","exit"},{"target",u.id},{"durability",durability_},{"lane",u.lane},{"leaks",leaks_[u.lane]}});}
}
// Reserve one main-action channel. Ability wins over basic; cooldown starts on cast.
for(auto& u:units_) {
auto& c=u.cast;
if(c.active&&!u.alive()){emit({{"kind",c.released?"cast_end":"cancel"},{"source",u.id},{"cast",c.id},{"skill",c.skill.id}});c.active=false;}
if(c.active&&tick_>=c.end){emit({{"kind","cast_end"},{"source",u.id},{"cast",c.id},{"skill",c.skill.id}});c.active=false;}
if(!u.alive()||c.active)continue;
for(const auto* skill:{&u.ability,&u.basic}) {
int& ready=skill->ability?u.abilityReady:u.basicReady;
if(skill->id.empty()||tick_<ready)continue;auto ids=targets(u,*skill);if(ids.empty())continue;
c={++nextCast_,tick_,tick_+skill->release,tick_+skill->release+skill->recovery,true,false,*skill,ids};ready=tick_+skill->cooldown;
if(skill->ability)u.skillCasts++;
emit({{"kind","cast_start"},{"source",u.id},{"target",ids[0]},{"cast",c.id},{"skill",skill->id},{"release",c.release},{"end",c.end}});break;
}
}
// Gather all valid releases before applying damage, preserving simultaneous releases.
struct Release {int source;Cast cast;};std::vector<Release> releasing;
for(auto& u:units_)if(u.alive()&&u.cast.active&&!u.cast.released&&tick_>=u.cast.release){u.cast.released=true;releasing.push_back({u.id,u.cast});}
for(const auto& r:releasing) {
const auto& c=r.cast;emit({{"kind","release"},{"source",r.source},{"cast",c.id},{"skill",c.skill.id}});
for(int id:c.targets) {
if(!unit(id).alive()){emit({{"kind","expired"},{"source",r.source},{"target",id},{"cast",c.id},{"skill",c.skill.id},{"reason","target_lost"}});continue;}
if(c.skill.projectile) {
int flight=std::max(1,(std::abs(unit(id).z-unit(r.source).z)+settings_.projectileSpeed-1)/settings_.projectileSpeed);
if(flight>settings_.projectileLifetime){emit({{"kind","expired"},{"source",r.source},{"target",id},{"cast",c.id},{"skill",c.skill.id},{"reason","lifetime"}});continue;}
projectiles_.push_back({c.id,r.source,id,tick_+flight,c.skill});
emit({{"kind","projectile"},{"source",r.source},{"target",id},{"cast",c.id},{"skill",c.skill.id},{"arrival",tick_+flight},{"fromZ",unit(r.source).z},{"toZ",unit(id).z}});
} else hit(r.source,id,c.id,c.skill);
}
}
bool allies=false,enemies=false;for(const auto& u:units_){if(u.team==0&&u.alive())allies=true;if(u.team==1&&(!u.spawned||u.alive()))enemies=true;}
if(((!allies||!enemies)&&projectiles_.empty())||tick_+1>=settings_.maxTicks||durability_<=0) {
finished_=true;won_=allies&&!enemies&&durability_>0&&tick_+1<settings_.maxTicks;
for(auto& u:units_)if(u.cast.active&&!u.cast.released){emit({{"kind","cancel"},{"source",u.id},{"cast",u.cast.id},{"skill",u.cast.skill.id}});u.cast.active=false;}
emit({{"kind","battle_end"},{"won",won_},{"durability",durability_},{"defeated",defeated_}});
}
}
J Battle::trace() const {J final=J::array(),stats=J::array();for(const auto& u:units_){final.push_back(state(u));if(u.team==0)stats.push_back({{"pet",u.art},{"slot",u.slot},{"damage",u.damage/1000.0},{"healing",u.healing/1000.0},{"absorbed",u.absorbed/1000.0},{"skills",u.skillCasts}});}return {{"schema",1},{"kind","isolated_skill_slice"},{"tickMs",50},{"lastTick",tick_},{"initial",initial_},{"events",events_},{"final",final},{"won",won_},{"durability",durability_},{"defeated",defeated_},{"leaks",{leaks_[0],leaks_[1],leaks_[2]}},{"stats",stats}};}
J Battle::run() {while(!finished_)step();return trace();}
}
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include "json.hpp"
#include <cstdint>
#include <string>
#include <vector>
// Deterministic combat kernel, shared by the original slice and authoritative battle adapter.
// No wallet, HTTP, database, Unity or wall-clock dependency.
// hp/attack: 1/1000; distance: mm; time: 50ms ticks; factors: 1/10000.
namespace combat_slice {
enum class Action { Damage, Shield, Heal, Slow };
struct Effect { std::string id="main"; Action action=Action::Damage; int64_t value=0; int duration=0, bossScale=10000; };
struct Skill {
std::string id;
Action action = Action::Damage;
bool projectile = false, ability = false;
int release = 0, recovery = 1, cooldown = 1, range = 2000, targets = 1, duration = 0;
int64_t value = 0;
int radius=0, bossScale=10000;
std::string actionId="main";
std::vector<Effect> actions;
};
struct Cast {
int id=0, start=0, release=0, end=0;
bool active=false, released=false;
Skill skill;
std::vector<int> targets;
};
struct Unit {
int id=0, team=0, art=0, lane=0, z=0, speed=0, element=0;
int basicReady=0, abilityReady=0, shieldExpiry=0;
int64_t hp=0, maxHp=0, shield=0, defense=0, damage=0, absorbed=0;
bool exited=false;
Skill basic, ability;
Cast cast;
int slot=0, tier=1, spawnTick=0, leak=1, slow=10000, slowExpiry=0, skillCasts=0;
int64_t healing=0;
bool spawned=true, boss=false;
bool alive() const { return spawned && hp>0 && !exited; }
};
struct Projectile { int cast=0, source=0, target=0, due=0; Skill skill; };
struct Settings { int maxTicks=800, projectileSpeed=800, projectileLifetime=40; bool elements=false; int advantage=12500, disadvantage=8000; int durability=10; };
class Battle {
public:
Battle(std::vector<Unit> units, Settings settings={});
void step();
nlohmann::json run();
nlohmann::json trace() const;
Unit& unit(int id);
const std::vector<Unit>& units() const {return units_;}
const nlohmann::json& events() const {return events_;}
int tick() const {return tick_;}
bool finished() const {return finished_;}
private:
std::vector<Unit> units_;
std::vector<Projectile> projectiles_;
Settings settings_;
nlohmann::json initial_, events_=nlohmann::json::array();
int tick_=-1, nextCast_=0;
bool finished_=false, won_=false;
int durability_=10, defeated_=0;
int leaks_[3]={};
std::vector<int> targets(const Unit&,const Skill&) const;
void emit(nlohmann::json);
void hit(int source,int target,int cast,const Skill&);
};
}
+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\"}"};}
}
+25
View File
@@ -97,6 +97,31 @@ const nlohmann::json& config() {
if(challenge.at("badge").get<std::string>().empty()||challenge.at("obtain").get<std::string>().empty()||challenge.at("code").get<std::string>().empty()) throw std::runtime_error("Invalid challenge text");
for(const char* key:{"coins","candy","balls"}) if(challenge.at("reward").at(key).get<int>()!=0) throw std::runtime_error("Challenge reward must stay on the badge");
}
const auto& friends=j.at("friends");
if(friends.at("limit")!=20||friends.at("requestLimit")!=20||friends.at("blockLimit")!=20||friends.at("sendLimit")!=5||friends.at("receiveLimit")!=5) throw std::runtime_error("Invalid friend limits");
const auto& gift=friends.at("gift");
if(gift.at("coins")!=20||gift.at("candy")!=0||gift.at("balls")!=0||gift.at("tickets")!=0) throw std::runtime_error("Invalid friend gift");
const auto& fishing=j.at("fishing");
if(fishing.at("unlockStage")!=1||fishing.at("ballCost")!=1||fishing.at("dailyLimit")!=5||fishing.at("duration")!=20||fishing.at("grace")!=3||fishing.at("fishCount")!=8||fishing.at("windowMs")!=900) throw std::runtime_error("Invalid fishing rules");
const auto& tiers=fishing.at("tiers");
if(tiers.size()!=3||tiers[0].at("minScore")!=3||tiers[1].at("minScore")!=5||tiers[2].at("minScore")!=8) throw std::runtime_error("Invalid fishing tiers");
if(tiers[0].at("reward").at("coins")!=10||tiers[1].at("reward").at("coins")!=20||tiers[2].at("reward").at("coins")!=30||tiers[2].at("reward").at("candy")!=5) throw std::runtime_error("Invalid fishing reward");
for(const auto& tier:tiers) {
const auto& reward=tier.at("reward");
if(reward.at("balls")!=0||reward.at("tickets")!=0) throw std::runtime_error("Invalid fishing reward");
if(tier.at("minScore").get<int>()<8&&reward.at("candy")!=0) throw std::runtime_error("Invalid fishing reward");
}
const auto& treasure=j.at("treasure");
if(treasure.at("unlockStage")!=1||treasure.at("dailyLimit")!=3||treasure.at("events").size()!=3) throw std::runtime_error("Invalid treasure rules");
for(const auto& ev:treasure.at("events")) if(!ev.is_string()||ev.get<std::string>().empty()) throw std::runtime_error("Invalid treasure event");
const auto& bands=treasure.at("bands");
if(bands.size()!=3||bands[0].at("minClears")!=1||bands[1].at("minClears")!=4||bands[2].at("minClears")!=10) throw std::runtime_error("Invalid treasure bands");
if(bands[0].at("reward").at("coins")!=10||bands[1].at("reward").at("coins")!=20||bands[2].at("reward").at("coins")!=30||bands[2].at("reward").at("candy")!=5) throw std::runtime_error("Invalid treasure reward");
for(const auto& band:bands) {
const auto& reward=band.at("reward");
if(reward.at("balls")!=0||reward.at("tickets")!=0) throw std::runtime_error("Invalid treasure reward");
if(band.at("minClears").get<int>()<10&&reward.at("candy")!=0) throw std::runtime_error("Invalid treasure reward");
}
j["version"]=kGameplayVersion;
return j;
}();
+1 -1
View File
@@ -464,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}, {"gameProtocol", 1}};
nlohmann::json body{{"ok", true}, {"service", "aiserver"}, {"maintenance", m.enabled}, {"gameProtocol", 1}, {"combatProtocol", 1}};
if (m.enabled) body["message"] = m.message;
auto resp = http_response(200, "OK", body.dump());
send_all(s, resp.data(), resp.size());
+3
View File
@@ -0,0 +1,3 @@
#pragma once
// Generated from the formal runtime skill table; edits belong in Config/Combat.
static constexpr const char* kSkillRuntimeJson = R"SKILLCONFIG(@SKILL_RUNTIME_JSON@)SKILLCONFIG";