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
+46 -5
View File
@@ -10,17 +10,32 @@ file(READ "${GAMEPLAY_FILE}" GAMEPLAY_JSON)
file(SHA256 "${GAMEPLAY_FILE}" GAMEPLAY_HASH)
string(SUBSTRING "${GAMEPLAY_HASH}" 0 12 GAMEPLAY_HASH_SHORT)
set(GAMEPLAY_VERSION "greenfield-1-${GAMEPLAY_HASH_SHORT}")
# MSVC caps a single string literal at 16380 bytes; emit adjacent chunks that concatenate back
# to the exact file bytes (splitting at a fixed byte offset never rewrites any character).
# MSVC caps a single string literal. Chunks are cut on a newline so a UTF-8 character is never
# split, and adjacent literals concatenate back to the exact file bytes.
string(LENGTH "${GAMEPLAY_JSON}" GAMEPLAY_JSON_LENGTH)
set(GAMEPLAY_JSON_CHUNKS "")
set(GAMEPLAY_JSON_OFFSET 0)
while(GAMEPLAY_JSON_OFFSET LESS GAMEPLAY_JSON_LENGTH)
string(SUBSTRING "${GAMEPLAY_JSON}" ${GAMEPLAY_JSON_OFFSET} 8000 GAMEPLAY_JSON_PART)
math(EXPR GAMEPLAY_JSON_REMAINING "${GAMEPLAY_JSON_LENGTH} - ${GAMEPLAY_JSON_OFFSET}")
if(GAMEPLAY_JSON_REMAINING GREATER 4000)
string(SUBSTRING "${GAMEPLAY_JSON}" ${GAMEPLAY_JSON_OFFSET} 4000 GAMEPLAY_JSON_PART)
string(FIND "${GAMEPLAY_JSON_PART}" "\n" GAMEPLAY_JSON_BREAK REVERSE)
if(GAMEPLAY_JSON_BREAK GREATER 0)
math(EXPR GAMEPLAY_JSON_BREAK "${GAMEPLAY_JSON_BREAK} + 1")
string(SUBSTRING "${GAMEPLAY_JSON_PART}" 0 ${GAMEPLAY_JSON_BREAK} GAMEPLAY_JSON_PART)
endif()
else()
string(SUBSTRING "${GAMEPLAY_JSON}" ${GAMEPLAY_JSON_OFFSET} ${GAMEPLAY_JSON_REMAINING} GAMEPLAY_JSON_PART)
endif()
string(LENGTH "${GAMEPLAY_JSON_PART}" GAMEPLAY_JSON_PART_LENGTH)
string(APPEND GAMEPLAY_JSON_CHUNKS "R\"GAMECONFIG(${GAMEPLAY_JSON_PART})GAMECONFIG\"\n")
math(EXPR GAMEPLAY_JSON_OFFSET "${GAMEPLAY_JSON_OFFSET} + 8000")
math(EXPR GAMEPLAY_JSON_OFFSET "${GAMEPLAY_JSON_OFFSET} + ${GAMEPLAY_JSON_PART_LENGTH}")
endwhile()
configure_file(src/game_config.hpp.in generated/game_config_data.hpp @ONLY)
set(SKILL_RUNTIME_FILE "${CMAKE_CURRENT_SOURCE_DIR}/../Config/Combat/skill_runtime_v1.json")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${SKILL_RUNTIME_FILE}")
file(READ "${SKILL_RUNTIME_FILE}" SKILL_RUNTIME_JSON)
configure_file(src/skill_runtime.hpp.in generated/skill_runtime_data.hpp @ONLY)
include_directories("${CMAKE_CURRENT_BINARY_DIR}/generated")
add_executable(aiserver
@@ -28,6 +43,8 @@ add_executable(aiserver
src/db.cpp
src/game.cpp
src/game_config.cpp
src/combat_slice.cpp
src/combat_runtime.cpp
src/auth.cpp
src/http_ws_server.cpp
src/maintenance.cpp
@@ -65,7 +82,22 @@ add_custom_command(TARGET aiserver POST_BUILD
include(CTest)
if(BUILD_TESTING)
add_executable(game_tests tests/game_tests.cpp src/db.cpp src/game.cpp src/game_config.cpp third_party/sqlite3.c)
add_executable(combat_runtime_tests tests/combat_runtime_tests.cpp src/db.cpp src/game.cpp src/game_config.cpp src/combat_slice.cpp src/combat_runtime.cpp src/crypto.cpp third_party/sqlite3.c)
target_include_directories(combat_runtime_tests PRIVATE src third_party)
target_compile_definitions(combat_runtime_tests PRIVATE SQLITE_OMIT_LOAD_EXTENSION SQLITE_THREADSAFE=1 _CRT_SECURE_NO_WARNINGS)
if(MSVC)
target_compile_options(combat_runtime_tests PRIVATE /W3 /utf-8)
elseif(NOT WIN32)
target_link_libraries(combat_runtime_tests PRIVATE pthread dl)
endif()
add_test(NAME combat_authoritative_runtime COMMAND combat_runtime_tests)
add_executable(combat_slice_tests tests/combat_slice_tests.cpp src/combat_slice.cpp)
target_include_directories(combat_slice_tests PRIVATE src third_party)
if(MSVC)
target_compile_options(combat_slice_tests PRIVATE /W3 /utf-8)
endif()
add_test(NAME combat_skill_slice COMMAND combat_slice_tests)
add_executable(game_tests tests/game_tests.cpp src/db.cpp src/game.cpp src/game_config.cpp src/combat_slice.cpp src/combat_runtime.cpp src/crypto.cpp third_party/sqlite3.c)
target_include_directories(game_tests PRIVATE src third_party)
target_compile_definitions(game_tests PRIVATE SQLITE_OMIT_LOAD_EXTENSION SQLITE_THREADSAFE=1 _CRT_SECURE_NO_WARNINGS)
target_compile_definitions(game_tests PRIVATE GAME_TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures")
@@ -75,4 +107,13 @@ if(BUILD_TESTING)
target_link_libraries(game_tests PRIVATE pthread dl)
endif()
add_test(NAME game_transactions COMMAND game_tests)
add_executable(first_day_tests tests/first_day_tests.cpp src/db.cpp src/game.cpp src/game_config.cpp src/combat_slice.cpp src/combat_runtime.cpp src/crypto.cpp third_party/sqlite3.c)
target_include_directories(first_day_tests PRIVATE src third_party)
target_compile_definitions(first_day_tests PRIVATE SQLITE_OMIT_LOAD_EXTENSION SQLITE_THREADSAFE=1 _CRT_SECURE_NO_WARNINGS)
if(MSVC)
target_compile_options(first_day_tests PRIVATE /W3 /utf-8)
elseif(NOT WIN32)
target_link_libraries(first_day_tests PRIVATE pthread dl)
endif()
add_test(NAME first_day_progression COMMAND first_day_tests)
endif()
+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";
+82
View File
@@ -0,0 +1,82 @@
#include "combat_runtime.hpp"
#include "combat_slice.hpp"
#include "game_config.hpp"
#include "db.hpp"
#include "crypto.hpp"
#include "sqlite3.h"
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <set>
#include <stdexcept>
using J=nlohmann::json;
static int assertions=0;
static void check(bool value,const char* message){++assertions;if(!value)throw std::runtime_error(message);}
static void sql(const std::string& path,const std::string& text){sqlite3* db=nullptr;check(sqlite3_open(path.c_str(),&db)==SQLITE_OK,"fixture db");char* error=nullptr;int rc=sqlite3_exec(db,text.c_str(),nullptr,nullptr,&error);if(error)sqlite3_free(error);sqlite3_close(db);check(rc==SQLITE_OK,"fixture SQL");}
static J stored(const std::string& path){sqlite3* db=nullptr;sqlite3_open(path.c_str(),&db);sqlite3_stmt* q=nullptr;sqlite3_prepare_v2(db,"SELECT state FROM game_states WHERE pid=1",-1,&q,nullptr);check(sqlite3_step(q)==SQLITE_ROW,"stored state");J state=J::parse(reinterpret_cast<const char*>(sqlite3_column_text(q,0)));sqlite3_finalize(q);sqlite3_close(db);return state;}
static int operations(const std::string& path){sqlite3* db=nullptr;sqlite3_open(path.c_str(),&db);sqlite3_stmt* q=nullptr;sqlite3_prepare_v2(db,"SELECT COUNT(*) FROM game_operations",-1,&q,nullptr);sqlite3_step(q);int n=sqlite3_column_int(q,0);sqlite3_finalize(q);sqlite3_close(db);return n;}
static void replace(const std::string& path,const J& state){std::string escaped;for(char c:state.dump()){escaped+=c;if(c=='\'')escaped+='\'';}sql(path,"UPDATE game_states SET state='"+escaped+"' WHERE pid=1");}
static J save(int tier,int level,J formation){J stages=J::array();for(int i=0;i<10;i++)stages.push_back(tier);return {{"level",level},{"stages",stages},{"formation",formation}};}
static void kernel_tests(){
using namespace combat_slice;
Unit a,b,c;a.id=0;a.team=0;a.hp=a.maxHp=100000;b=a;b.id=1;b.lane=1;b.hp=20000;c=a;c.id=100;c.team=1;c.z=1000;
a.ability.id="heal";a.ability.action=Action::Heal;a.ability.ability=true;a.ability.cooldown=10;a.ability.value=30000;a.ability.range=2000;a.ability.radius=1;
Battle heal({a,b,c});heal.step();check(heal.unit(1).hp==50000&&heal.unit(0).healing==30000,"healing uses weakest ratio and adjacent lane");
b.lane=2;a.lane=1;Battle outside({a,b,c});outside.step();check(outside.unit(1).hp==20000,"outer lanes are not adjacent at radius one");
a.lane=0;a.ability.action=Action::Slow;a.ability.value=5000;a.ability.duration=3;a.ability.bossScale=5000;c.boss=true;Battle slow({a,c});slow.step();check(slow.unit(100).slow==7500,"boss discount on slow");slow.step();slow.step();slow.step();check(slow.unit(100).slow==10000,"slow expires on absolute tick");
a.ability.action=Action::Damage;a.ability.actions={{"damage",Action::Damage,12000,0,10000},{"slow",Action::Slow,5000,3,5000}};Battle composite({a,c});composite.step();check(composite.unit(100).hp==88000&&composite.unit(100).slow==7500,"ordered damage and slow compose");
a.ability={};c.spawned=false;c.spawnTick=5;Battle waves({a,c});for(int i=0;i<5;i++)waves.step();check(!waves.finished(),"future waves prevent early victory");waves.step();check(waves.unit(100).spawned,"spawn uses scheduled tick");
}
int main(int argc,char** argv){auto root=std::filesystem::temp_directory_path()/("aigame-combat-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));std::filesystem::create_directories(root);try{
kernel_tests();const auto& config=gameplay::config();J report={{"status","pass"},{"matrix",J::array()}};std::set<std::string> covered;
const J lineups[]={J({0,1,4,3,5,6}),J({0,2,5,7,9,8}),J({4,6,9,1,5,3})};
for(int tier=1;tier<=2;tier++)for(int stage=1;stage<=20;stage++)for(int formation=0;formation<3;formation++){
auto state=save(tier,gameplay::stage(stage)["recommendedLevel"],lineups[formation]);auto trace=combat_runtime::simulate(config,state,gameplay::stage(stage));
check(trace["events"].size()<50000,"event budget");check(trace["events"].dump().size()<8000000,"trace byte budget");check(trace["events"].back()["kind"]=="battle_end","result follows final event");
check(trace["defeated"].get<int>()<=9&&trace["durability"].get<int>()>=0,"result bounded");
int last=-1,seq=0;std::set<std::tuple<int,int,std::string>> hits;std::set<int> deaths;
for(const auto& e:trace["events"]){check(e["tick"].get<int>()>=last&&e["seq"]==seq++,"stable total event order");last=e["tick"];
std::string kind=e["kind"];if(kind=="cast_start")covered.insert(e["skill"]);
if(kind=="hit")check(hits.insert(std::make_tuple(e["cast"].get<int>(),e["target"].get<int>(),e["action"].get<std::string>())).second,"unique cast/action/target hit");
if(kind=="death")check(deaths.insert(e["target"].get<int>()).second,"unique death");
}
if(stage==1||stage==20)check(trace==combat_runtime::simulate(config,state,gameplay::stage(stage)),"repeat simulation exact");
if(argc>2&&((tier==1&&formation==stage%3)||(tier==2&&stage==20))){
auto folder=std::filesystem::path(argv[2]);std::filesystem::create_directories(folder);J manifest=trace;manifest.erase("events");manifest["eventCount"]=trace["events"].size();manifest["traceHash"]=crypto::sha256_hex(trace["events"].dump());manifest["gameplayHash"]=crypto::sha256_hex(config.dump());manifest["skillsHash"]=crypto::sha256_hex(combat_runtime::definitions().dump());manifest["traceId"]=crypto::sha256_hex(manifest["traceHash"].get<std::string>());
std::ofstream out(folder/("stage_"+std::to_string(stage)+"_t"+std::to_string(tier)+"_f"+std::to_string(formation)+".json"),std::ios::binary);out<<J{{"manifest",manifest},{"events",trace["events"]},{"save",state},{"stage",gameplay::stage(stage)},{"config",config}}.dump();check(out.good(),"client fixture export");
}
report["matrix"].push_back({{"stage",stage},{"tier",tier},{"formation",formation},{"won",trace["won"]},{"duration",trace["duration"]},{"events",trace["events"].size()},{"bytes",trace["events"].dump().size()}});
}
check(covered.size()==25,"all 25 skill bindings exercised");
auto path=(root/"protocol.db").string();{
db::Database db(path);check(db.open_ok(),"db opens");auto pid=db.create_user("combat-test","hash","salt");check(pid.has_value(),"user");db.store_token("combat-token",*pid,1,4102444800LL);int64_t now=2000000000;
auto read=[&](){auto r=db.game_request("combat-token","",now);check(r.status==200,"read");return J::parse(r.body);};auto current=read();int serial=0;
auto command=[&](std::string action,J extra=J::object()){extra["requestId"]="combat-command-"+std::to_string(++serial)+"-isolated";extra["action"]=action;extra["revision"]=current["state"]["revision"];return extra;};
auto send=[&](const J& c){auto r=db.game_request("combat-token",c.dump(),now);check(r.status==200,"command");current=J::parse(r.body);return current;};
auto begin=command("battle.begin",{{"combatProtocol",1}});auto reply=send(begin);check(reply["ok"]&&reply["battle"]["snapshot"]["rulesVersion"]==3,"new client opts into trace");auto meta=reply["battle"]["trace"];check(!reply["state"].contains("battleTrace")&&!meta.contains("events"),"no full trace in standard responses");
auto before=stored(path);int rows=operations(path);J events=J::array();int cursor=0;
auto page=command("battle.events",{{"activityId",begin["requestId"]},{"traceId",meta["traceId"]},{"cursor",0},{"limit",256}});
while(cursor<meta["eventCount"].get<int>()){
page["cursor"]=cursor;auto raw=db.game_request("combat-token",page.dump(),now);check(raw.status==200,"page available");auto p=J::parse(raw.body)["page"];check(p["cursor"]==cursor&&p["total"]==meta["eventCount"]&&crypto::sha256_hex(p["payload"])==p["sha256"],"page hash/cursor");auto chunk=J::parse(p["payload"].get<std::string>());for(const auto& e:chunk)events.push_back(e);cursor=p["nextCursor"];
}
check(crypto::sha256_hex(events.dump())==meta["traceHash"],"joined trace hash");check(stored(path)==before&&operations(path)==rows,"page reads do not write state or operation log");
check(send(begin)["replayed"]&&current["battle"]["trace"]==meta,"begin replay retains frozen trace");
auto pid2=db.create_user("combat-other","hash","salt");db.store_token("other-token",*pid2,1,4102444800LL);check(db.game_request("other-token",page.dump(),now).status==404,"other account cannot fetch trace");
auto bad=page;bad["limit"]=257;check(db.game_request("combat-token",bad.dump(),now).status==400,"page cap");bad=page;bad["cursor"]=-1;check(db.game_request("combat-token",bad.dump(),now).status==400,"negative cursor");bad=page;bad["traceId"]="wrong";check(db.game_request("combat-token",bad.dump(),now).status==409,"trace mismatch");
auto restored=read();check(restored["battle"]["trace"]==meta,"re-read retains trace");
// The frozen result must survive different config values in a pending snapshot.
auto frozen=stored(path);frozen["battleSnapshot"]["config"]["pets"][0]["attack"]=9999;replace(path,frozen);read();
auto finish=command("battle.finish",{{"activityId",begin["requestId"]},{"won",true}});check(!send(finish)["ok"].get<bool>(),"readyAt prevents early finish");
now+=200;finish=command("battle.finish",{{"activityId",begin["requestId"]},{"won",false}});auto paid=send(finish);check(paid["result"]["won"]==events.back()["won"]&&paid["result"]["traceHash"]==meta["traceHash"],"client fields cannot forge frozen outcome");
auto wallet=paid["state"];check(send(finish)["replayed"]&&current["state"]["coins"]==wallet["coins"],"finish retry pays once");check(!stored(path).contains("battleTrace"),"finish clears active trace");check(db.game_request("combat-token",page.dump(),now).status==404,"closed trace cannot leak");
auto old=command("battle.begin");check(send(old)["ok"]&&!current["battle"].contains("trace"),"old client still receives old rules");now+=200;send(command("battle.abandon",{{"activityId",old["requestId"]}}));
auto invalid=command("battle.begin",{{"combatProtocol",2}});check(!send(invalid)["ok"].get<bool>()&&current["state"]["pendingBattle"]=="","unsupported protocol has no side effects");
// Challenge snapshots use their temporary element formation while preserving the main lineup.
auto owned=stored(path);owned["stages"]=J({1,1,1,1,1,1,1,1,1,1});owned["clearedStages"][0]=1;replace(path,owned);read();auto challenge=command("challenge.begin",{{"stageId",1},{"combatProtocol",1}});auto ch=send(challenge);check(ch["ok"]&&ch["battle"]["snapshot"]["challengeId"]==1&&ch["battle"]["trace"]["rulesVersion"]==3,"element challenge trace");check(ch["state"]["formation"]==owned["formation"],"challenge leaves main formation");send(command("battle.abandon",{{"activityId",challenge["requestId"]}}));
report["protocol"]={{"hashes",true},{"authorization",true},{"paginationReadOnly",true},{"frozenOutcome",true},{"idempotentReward",true},{"legacy",true},{"challenge",true}};
}
report["coveredSkills"]=covered;report["assertions"]=assertions;
if(argc>1){std::ofstream out(argv[1],std::ios::binary);out<<report.dump(2);check(out.good(),"report");}
std::filesystem::remove_all(root);std::cout<<"PASS "<<assertions<<" assertions; 120 stage/tier/formation cases, 25 skills, authorized paged trace and rewards\n";return 0;
}catch(const std::exception& e){std::cerr<<"FAIL "<<e.what()<<" fixture="<<root<<"\n";return 1;}}
+93
View File
@@ -0,0 +1,93 @@
#include "combat_slice.hpp"
#include <cmath>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace combat_slice;
using J=nlohmann::json;
static int assertions=0;
static void check(bool value,const char* message){++assertions;if(!value)throw std::runtime_error(message);}
static int count(const J& events,const std::string& kind,int source=-1){int n=0;for(const auto& e:events)if(e["kind"]==kind&&(source<0||e.value("source",-1)==source))++n;return n;}
static Unit actor(int id,int team,int z=0){Unit u;u.id=id;u.team=team;u.z=z;u.hp=u.maxHp=100000;return u;}
static Skill attack(int windup=2,bool projectile=false){Skill s;s.id="test.basic";s.release=windup;s.recovery=2;s.cooldown=10;s.range=10000;s.value=12000;s.projectile=projectile;return s;}
static void tests(){
{
auto a=actor(0,0),b=actor(100,1,1600);a.basic=attack(2,true);Battle sim({a,b});
sim.step();check(sim.unit(100).hp==100000,"cast start has no damage");sim.step();sim.step();
check(count(sim.events(),"projectile")==1&&sim.unit(100).hp==100000,"release launches without early damage");sim.step();check(sim.unit(100).hp==100000,"flight is pending");sim.step();
check(sim.unit(100).hp==88000&&count(sim.events(),"hit")==1,"one arrival causes exactly one damage");
auto trace=sim.trace();for(const auto& e:trace["events"])check(e["seq"].get<int>()<static_cast<int>(trace["events"].size()),"sequence bounded");
}
{
auto a=actor(0,0),b=actor(100,1,1000);a.basic=attack();a.ability=attack(0);a.ability.id="test.shield";a.ability.ability=true;a.ability.action=Action::Shield;a.ability.duration=3;a.ability.value=20000;
b.basic=attack(0);Battle sim({a,b});sim.step();
check(sim.unit(0).shield==8000&&sim.unit(0).hp==100000,"zero windup shield absorbs same-tick hit");
check(count(sim.events(),"cast_start",0)==1&&count(sim.events(),"shield")==1,"ability excludes concurrent basic");
sim.step();sim.step();check(count(sim.events(),"cast_start",0)==2,"channel opens only at recovery end");sim.step();check(sim.unit(0).shield==0&&count(sim.events(),"shield_expired")==1,"shield expires on its absolute tick");
}
{
auto a=actor(0,0),b=actor(100,1,1000);a.basic=attack(4);b.basic=attack(0);b.basic.value=100000;
Battle sim({a,b});sim.run();check(count(sim.events(),"release",0)==0&&count(sim.events(),"cancel",0)==1,"death before release cancels once");check(count(sim.events(),"death")==1,"death is emitted once");
}
{
auto a=actor(0,0),b=actor(100,1,1600);a.basic=attack(0,true);a.basic.value=100000;b.basic=attack(0);b.basic.value=100000;
Battle sim({a,b});sim.run();check(sim.unit(0).hp==0&&sim.unit(100).hp==0&&count(sim.events(),"hit",0)==1,"released projectile survives caster death");
}
{
auto a=actor(0,0),b=actor(100,1,1600),c=actor(1,0);a.basic=attack(0,true);c.basic=attack(1);c.basic.value=100000;
Battle sim({a,b,c});sim.run();check(count(sim.events(),"expired",0)==1&&count(sim.events(),"hit",0)==0,"target lost never retargets or damages twice");
}
{
auto a=actor(0,0),b=actor(100,1,500);a.basic=attack(0);a.basic.cooldown=3;
Battle sim({a,b});for(int i=0;i<7;i++)sim.step();check(count(sim.events(),"cast_start",0)==3,"cooldown boundary uses absolute integer tick");
auto once=sim.events().size();sim.run();sim.step();check(sim.finished()&&sim.events().size()>=once&&count(sim.events(),"battle_end")==1,"finished battle is idempotent");
}
{
auto a=actor(0,0),b=actor(100,1,500);a.basic=attack(0);a.element=1;b.element=2;b.defense=1000;Settings cfg;cfg.elements=true;
Battle sim({a,b},cfg);sim.step();check(sim.unit(100).hp==86000,"element multiplier and defense round in fixed units");
auto x=actor(0,0),y=actor(100,1,500);x.basic=attack(0);x.basic.value=999;Battle raw({x,y});raw.step();check(raw.unit(100).hp==99001,"elements off keeps fractional raw damage");
}
{
auto a=actor(0,0),b=actor(100,1,1000);a.basic=attack(0,true);a.basic.value=100000;b.hp=b.maxHp=5000;Battle first({a,b});Battle second({b,a});
check(first.run()==second.run(),"input ordering does not change deterministic trace");
bool rejected=false;try{Battle bad({a,a});}catch(const std::invalid_argument&){rejected=true;}check(rejected,"duplicate entity ids rejected");
}
{
auto a=actor(0,0),b=actor(100,1,1000);a.ability=attack(0);a.ability.action=Action::Shield;a.ability.ability=true;a.ability.value=10000;a.ability.duration=6;a.ability.cooldown=3;
Battle sim({a,b});for(int i=0;i<4;i++)sim.step();check(sim.unit(0).shield==10000&&sim.unit(0).shieldExpiry==9,"shield refresh replaces expiry and never adds values");
b.speed=100;Battle moving({a,b});moving.step();check(moving.unit(100).z==1000,"enemy stops at blocker");
a.basic=attack(0,true);a.ability={};b.z=4000;Settings limit;limit.projectileLifetime=1;Battle expiring({a,b},limit);expiring.step();check(count(expiring.events(),"expired")==1,"projectile lifetime bounded");
}
}
static J read(const char* path){std::ifstream in(path,std::ios::binary);if(!in)throw std::runtime_error(std::string("read ")+path);J j;in>>j;return j;}
static int ticks(double seconds){return static_cast<int>(std::ceil(seconds*20-1e-8));}
static int64_t fixed(double value){return std::llround(value*1000);}
static J sample(const J& gameplay,const J& proposal,const J& scenario){
std::vector<Unit> units;const int tier=scenario.at("tier");
auto resolve=[&](const J& binding,const J& p,bool ability){
J t;for(const auto& v:proposal["templates"])if(v["id"]==binding["template"])t=v;
if(t.is_null())throw std::runtime_error("missing template");
if(t["actions"].size()!=1)throw std::runtime_error("slice supports one action");
auto action=t["actions"][0];if(action["type"]!="damage"&&action.value("statusId","")!="shield_main")throw std::runtime_error("unsupported slice action");
auto timing=binding.value("timing",t["timing"]);auto data=ability?p["ability"]:p;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["cooldown"]);s.range=static_cast<int>(fixed(data["range"]));s.value=fixed(p["attack"]);
if(ability){s.targets=data[tier==2?"t2Targets":"targets"];double value=data[tier==2?"t2":"t1"];s.value=data["kind"]=="shield"?fixed(value):fixed(p["attack"].get<double>()*value);s.duration=ticks(data[tier==2?"t2Duration":"duration"]);}
if(action["type"]=="apply_status")s.action=Action::Shield;
return s;
};
for(int pet=0;pet<2;pet++){
auto p=gameplay["pets"][pet];auto u=actor(pet,0,pet==0?0:-2000);u.art=pet;u.hp=u.maxHp=fixed(p["hp"]);u.element=p["element"];
for(const auto& binding:proposal["skills"])if(binding["actorKind"]=="pet"&&binding["actorId"]==pet){bool ability=binding["slot"]=="ability";(ability?u.ability:u.basic)=resolve(binding,p,ability);}
units.push_back(u);
}
int type=scenario["enemyType"],id=100;auto d=gameplay["enemies"][type];
for(int z:scenario["enemyZ"]){auto u=actor(id++,1,z);u.art=type;u.speed=static_cast<int>(std::llround(d["speed"].get<double>()*50));u.hp=u.maxHp=fixed(d["hp"].get<double>()*scenario["enemyHpScale"].get<double>());u.defense=fixed(d["defense"]);u.element=0;
for(const auto& binding:proposal["skills"])if(binding["actorKind"]=="enemy"&&binding["actorId"]==type){J p=d;p["range"]=1;u.basic=resolve(binding,p,false);}units.push_back(u);}
Settings settings;settings.maxTicks=scenario["maxTicks"];settings.elements=scenario["elementsEnabled"];settings.projectileSpeed=proposal["projectile"]["speedMmPerTick"];settings.projectileLifetime=proposal["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 battle(units,settings);auto result=battle.run();result["scenario"]=scenario;result["frozenGameplay"]=gameplay;result["skillDefinitions"]=proposal;
check(count(result["events"],"projectile",1)>0&&count(result["events"],"hit",1)>0,"sample fire launch and impact");
check(count(result["events"],"shield",0)>0&&count(result["events"],"hit",0)>0,"sample shield and rock melee");
bool absorbed=false;for(const auto& e:result["events"])if(e.value("absorbed",int64_t(0))>0)absorbed=true;check(absorbed,"sample shield absorbs an enemy attack");return result;
}
int main(int argc,char** argv){try{tests();if(argc==5){auto trace=sample(read(argv[1]),read(argv[2]),read(argv[3]));std::ofstream out(argv[4],std::ios::binary);out<<trace.dump(2);check(out.good(),"trace exported");std::cout<<"trace ticks="<<trace["lastTick"]<<" events="<<trace["events"].size()<<" won="<<trace["won"]<<"\n";}else if(argc!=1)throw std::runtime_error("usage: combat_slice_tests [gameplay proposal scenario output]");std::cout<<"PASS "<<assertions<<" skill-slice assertions\n";return 0;}catch(const std::exception& e){std::cerr<<"FAIL "<<e.what()<<"\n";return 1;}}
+109
View File
@@ -0,0 +1,109 @@
#include "db.hpp"
#include "game_config.hpp"
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
using J = nlohmann::json;
// Exercise real transactions from a new account. Only the server clock is advanced
// past the required playback time; no wallet, ownership or clear flags are seeded.
int main(int argc, char** argv) {
auto tempRoot = std::filesystem::weakly_canonical(std::filesystem::temp_directory_path());
auto folder = tempRoot /
("aigame-first-day-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(folder);
J report = {{"status", "running"}, {"seededResources", false}, {"injectedClock", true},
{"battles", J::array()}, {"operations", J::array()}};
try {
{
db::Database database((folder / "test.db").string());
if (!database.open_ok()) throw std::runtime_error("isolated database");
auto pid = database.create_user("first-day-new-player", "test-hash", "test-salt");
if (!pid) throw std::runtime_error("new account");
database.store_token("first-day-test-token", *pid, 1, 4102444800LL);
int64_t now = 1900040000; int serial = 0;
auto read = [&]() {
auto r = database.game_request("first-day-test-token", "", now);
if (r.status != 200) throw std::runtime_error("read snapshot");
return J::parse(r.body);
};
J current = read();
report["initial"] = current["state"];
auto send = [&](const std::string& action, J extra = J::object()) {
extra["requestId"] = "first-day-command-" + std::to_string(++serial);
extra["action"] = action; extra["revision"] = current["state"]["revision"];
auto r = database.game_request("first-day-test-token", extra.dump(), now);
auto reply = J::parse(r.body);
if (r.status != 200 || !reply.value("ok", false))
throw std::runtime_error(action + ": " + reply.value("error", "HTTP error"));
current = reply;
report["operations"].push_back({{"action", action}, {"coins", current["state"]["coins"]},
{"candy", current["state"]["candy"]}, {"balls", current["state"]["balls"]},
{"level", current["state"]["level"]}, {"shots", current["state"]["shots"]}});
return reply;
};
auto shot = [&]() {
if (current["state"]["shots"].get<int>() >= 60) throw std::runtime_error("shot budget exhausted");
auto begin = send("shot.begin", {{"aim", 0}});
now = std::max(now, begin["shot"]["readyAt"].get<int64_t>());
send("shot.finish", {{"activityId", begin["shot"]["id"]}});
};
auto train = [&](int target) {
while (current["state"]["level"].get<int>() < target) {
while (current["state"]["candy"].get<int>() < current["state"]["level"].get<int>() * gameplay::config()["economy"]["trainCost"].get<int>()) shot();
send("train");
}
};
shot(); send("capture"); train(2);
for (int stage = 1; stage <= 5; ++stage) {
train(std::max(2, stage)); send("stage.select", {{"stageId", stage}}); send("autoDeploy");
auto before = current["state"];
auto begin = send("battle.begin", {{"combatProtocol", 1}});
if (begin["battle"]["snapshot"]["rulesVersion"] != 3) throw std::runtime_error("not authoritative runtime");
now = std::max(now, begin["battle"]["readyAt"].get<int64_t>());
auto result = send("battle.finish", {{"activityId", begin["battle"]["id"]}});
report["battles"].push_back({{"stage", stage}, {"level", before["level"]},
{"formation", before["formation"]}, {"shots", before["shots"]},
{"result", result["result"]}});
if (!result["result"]["won"].get<bool>()) throw std::runtime_error("starter roster lost stage " + std::to_string(stage));
const auto& reward = gameplay::stage(stage)["reward"];
for (const auto* resource : {"coins", "candy", "balls"})
if (current["state"][resource].get<int>() != before[resource].get<int>() + reward[resource].get<int>())
throw std::runtime_error("clear reward mismatch");
if (stage == 1) {
send("goal.claim"); send("mail.claimAll", {{"mailIds", {"welcome_supply_v1"}}});
send("signin.claim", {{"item", 0}}); send("shop.buy", {{"item", 0}, {"day", current["state"]["dailyDay"]}});
send("chest.open", {{"chest", 0}});
}
}
auto beforeRetry = current["state"];
auto retry = send("battle.begin", {{"combatProtocol", 1}});
now = std::max(now, retry["battle"]["readyAt"].get<int64_t>());
auto replayed = send("battle.finish", {{"activityId", retry["battle"]["id"]}});
if (!replayed["result"]["won"].get<bool>() || replayed["result"]["firstClear"].get<bool>())
throw std::runtime_error("repeat clear flags");
for (const auto* field : {"coins", "candy", "balls", "tickets", "chests"})
if (current["state"][field] != beforeRetry[field]) throw std::runtime_error("repeat clear paid again");
report["repeatClearNoReward"] = true;
report["final"] = current["state"]; report["status"] = "pass";
}
} catch (const std::exception& error) {
report["status"] = "failed"; report["error"] = error.what();
}
if (argc > 1) {
auto destination = std::filesystem::path(argv[1]); std::error_code error;
if (!destination.parent_path().empty()) std::filesystem::create_directories(destination.parent_path(), error);
std::ofstream out(destination, std::ios::binary); out << report.dump(2);
if (error || !out.good()) { report["status"] = "failed"; report["error"] = "cannot write acceptance report"; }
}
if (std::filesystem::weakly_canonical(folder).parent_path() != tempRoot ||
folder.filename().string().rfind("aigame-first-day-", 0) != 0)
throw std::runtime_error("refusing cleanup outside isolated temp directory");
std::filesystem::remove_all(folder);
if (report["status"] != "pass") { std::cerr << report.value("error", "failed") << '\n'; return 1; }
std::cout << "PASS fresh account through first five stages; normal rewards and T1 tutorial roster\n";
return 0;
}
+208 -18
View File
@@ -27,7 +27,7 @@ struct Fixture {
check(db.store_token(token,*pid,1,4102444800LL),"token created");read();
}
J read() {
auto r=db.game_request(token,"",now);if(r.status!=200) std::cerr<<"READ "<<r.status<<" "<<r.body<<"\n";check(r.status==200,"read succeeds");auto j=J::parse(r.body);state=j["state"];return j;
auto r=db.game_request(token,"",now);check(r.status==200,"read succeeds");auto j=J::parse(r.body);state=j["state"];return j;
}
J command(const std::string& action,J extra=J::object()) {
extra["action"]=action;extra["revision"]=state["revision"];
@@ -271,7 +271,7 @@ int main(int argc,char** argv) {
old["schema"]=2;old["stages"]={1,2,1};old["shards"]={12,25,30};old["formation"]={2,1,-1,-1,-1,-1};
old["clearedStages"]={1,1,0,0,0};old["selectedStage"]=2;old["level"]=5;old["coins"]=123;old["candy"]=456;old["tickets"]=7;
old.erase("captureTutorialDone");old.erase("captureCount");old.erase("duplicateStreak");f.replace(old);
check(f.state["schema"]==11&&f.state["stages"][0]==1&&f.state["stages"][1]==2&&f.state["stages"][2]==1&&f.state["stages"].size()==10&&f.state["shards"][0]==12&&f.state["shards"][1]==25&&f.state["shards"][2]==30&&f.state["shards"].size()==10&&f.state["formation"]==J({2,1,-1,-1,-1,-1}),"G2 migration preserves owned evolutions, shards and formation");
check(f.state["schema"]==14&&f.state["stages"][0]==1&&f.state["stages"][1]==2&&f.state["stages"][2]==1&&f.state["stages"].size()==10&&f.state["shards"][0]==12&&f.state["shards"][1]==25&&f.state["shards"][2]==30&&f.state["shards"].size()==10&&f.state["formation"]==J({2,1,-1,-1,-1,-1}),"G2 migration preserves owned evolutions, shards and formation");
check(f.state["captureTutorialDone"]&&f.state["captureCount"]==0&&f.state["selectedStage"]==2&&f.state["clearedStages"][0]==1&&f.state["clearedStages"][1]==1&&f.state["clearedStages"].size()==20&&f.state["coins"]==123&&f.state["tickets"]==7,"G2 migration preserves progress and suppresses extra tutorial reward");
auto r=f.send(f.command("capture"),[](uint32_t n){check(n==10,"migrated ordinary pool size");return 3u;});
check(r["result"]["pet"]==3&&!r["result"]["tutorial"].get<bool>()&&f.state["captureCount"]==1,"migrated player enters ordinary pool");
@@ -296,7 +296,7 @@ int main(int argc,char** argv) {
{
Fixture f((root/"migration.db").string());
auto old=f.state;old["schema"]=1;old["stages"].get_ref<J::array_t&>().resize(3);old["shards"].get_ref<J::array_t&>().resize(3);old["cleared"]=true;old["goalClaimed"]=true;old["coins"]=777;old["candy"]=888;old.erase("selectedStage");old.erase("clearedStages");
f.replace(old);check(f.state["schema"]==11&&f.state["clearedStages"][0]==1&&f.state["clearedStages"].size()==20&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["goalClaimed"],"G1 completed save migrates without reward or reset");
f.replace(old);check(f.state["schema"]==14&&f.state["clearedStages"][0]==1&&f.state["clearedStages"].size()==20&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["goalClaimed"],"G1 completed save migrates without reward or reset");
auto next=f.state;next["cleared"]=false;next["clearedStages"]=cleared(static_cast<int>(f.read()["config"]["stages"].size()),0);f.replace(next);
auto start=f.command("battle.begin");auto b=f.send(start);old=f.stored();old["schema"]=1;old["stages"].get_ref<J::array_t&>().resize(3);old["shards"].get_ref<J::array_t&>().resize(3);old["battleSnapshot"]["stages"].get_ref<J::array_t&>().resize(3);old.erase("selectedStage");old.erase("clearedStages");
freeze_classic(old,2);
@@ -312,7 +312,7 @@ int main(int argc,char** argv) {
}
{
Fixture f((root/"camp.db").string());auto cfg=f.read()["config"];
check(f.state["schema"]==11&&f.state["campLayout"]==J({-1,-1,-1})&&f.state["decorations"]==J({0,0,0})&&f.state["chests"]==J({0,0,0}),"fresh camp is empty, no free purchases");
check(f.state["schema"]==14&&f.state["campLayout"]==J({-1,-1,-1})&&f.state["decorations"]==J({0,0,0})&&f.state["chests"]==J({0,0,0}),"fresh camp is empty, no free purchases");
auto before=f.state;
check(f.send(f.command("camp.place",{{"item",0},{"slot",0}}))["error"]=="camp_build_locked","construction gated by actual clear");
check(f.send(f.command("camp.interact",{{"pet",0}}))["error"]=="camp_rest_locked"&&f.state==before,"rest gated without rewards");
@@ -353,13 +353,13 @@ int main(int argc,char** argv) {
f.sql("DROP TRIGGER fail_camp");check(f.send(buy)["ok"]&&f.state["coins"]==640,"original purchase succeeds once after repair");
auto old=f.stored();old["schema"]=3;old.erase("campLayout");old.erase("decorations");old.erase("restPet");old.erase("campInteractions");
old["captureCount"]=12;old["duplicateStreak"]=3;old["tickets"]=9;old["shards"][0]=77;f.replace(old);
check(f.state["schema"]==11&&f.state["coins"]==640&&f.state["tickets"]==9&&f.state["shards"][0]==77&&f.state["stages"].size()==10&&f.state["captureCount"]==12&&f.state["duplicateStreak"]==3&&f.state["campLayout"]==J({-1,-1,-1}),"schema3 migration preserves collection wallet and initializes empty camp");
check(f.state["schema"]==14&&f.state["coins"]==640&&f.state["tickets"]==9&&f.state["shards"][0]==77&&f.state["stages"].size()==10&&f.state["captureCount"]==12&&f.state["duplicateStreak"]==3&&f.state["campLayout"]==J({-1,-1,-1}),"schema3 migration preserves collection wallet and initializes empty camp");
auto beforeRead=f.state;f.read();check(f.state==beforeRead,"camp reads grant no extra production");
f.now+=600;f.read();check(f.state["balls"]==21,"camp uses existing single recovery source");
}
{
Fixture f((root/"daily.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];int64_t midnight=(static_cast<int64_t>(day)+1)*86400-28800;
check(f.state["schema"]==11&&f.state["signinDays"]==1&&f.state["dailyProgress"]==J({0,0,0})&&f.state["stages"].size()==10,"first server day unlocks day1 without fabricated events");
check(f.state["schema"]==14&&f.state["signinDays"]==1&&f.state["dailyProgress"]==J({0,0,0})&&f.state["stages"].size()==10,"first server day unlocks day1 without fabricated events");
auto before=f.state;f.read();check(f.state==before,"opening pages/reconnecting grants no task progress or login day");
check(f.send(f.command("daily.claim",{{"item",0},{"day",day},{"progress",999}}))["error"]=="task_incomplete","client cannot fabricate task progress");
check(f.send(f.command("signin.claim",{{"item",6},{"loginDays",7}}))["error"]=="signin_locked","client cannot unlock future sign-in");
@@ -408,12 +408,12 @@ int main(int argc,char** argv) {
for(int i=0;i<16;i++)retries.emplace_back([&]{auto r=f.db.game_request(f.token,sign.dump(),f.now);if(r.status==200&&J::parse(r.body)["ok"])ok++;});for(auto& t:retries)t.join();f.read();check(ok==16&&f.state["balls"]==35,"16 sign-in retries award ten once");
auto start=f.command("shot.begin",{{"aim",0}});auto launched=f.send(start);auto old=f.stored();old["schema"]=4;old["shots"]=100;old["coins"]=777;old["campInteractions"]=12;old["decorations"]={1,0,0};old["campLayout"]={-1,0,-1};
for(const char* key:{"dailyDay","dailyProgress","dailyClaimed","signinDays","signinClaimed"})old.erase(key);f.replace(old);
check(f.state["schema"]==11&&f.state["coins"]==777&&f.state["campLayout"]==J({-1,0,-1})&&f.state["campInteractions"]==12&&f.state["shots"]==100&&f.state["dailyProgress"]==J({0,0,0})&&f.state["signinDays"]==1&&f.state["signinClaimed"]==J({0,0,0,0,0,0,0})&&f.state["pendingShot"]==start["requestId"],"schema4 migration preserves wallet camp and pending shot; no retroactive daily progress or sign-in award");
check(f.state["schema"]==14&&f.state["coins"]==777&&f.state["campLayout"]==J({-1,0,-1})&&f.state["campInteractions"]==12&&f.state["shots"]==100&&f.state["dailyProgress"]==J({0,0,0})&&f.state["signinDays"]==1&&f.state["signinClaimed"]==J({0,0,0,0,0,0,0})&&f.state["pendingShot"]==start["requestId"],"schema4 migration preserves wallet camp and pending shot; no retroactive daily progress or sign-in award");
f.now=launched["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",start["requestId"]}}));check(f.state["dailyProgress"][0]==1,"pre-migration pending shot counts once when actually settled");
}
{
Fixture f((root/"shop.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];
check(f.state["schema"]==11&&f.state["shopBought"]==J({0,0,0})&&f.state["balls"]==20&&f.state["chests"]==J({0,0,0}),"fresh shop stock has no automatic supply award");
check(f.state["schema"]==14&&f.state["shopBought"]==J({0,0,0})&&f.state["balls"]==20&&f.state["chests"]==J({0,0,0}),"fresh shop stock has no automatic supply award");
auto before=f.state;f.read();check(f.state==before,"opening shop does not consume stock or grant inventory");
auto free=f.command("shop.buy",{{"item",0},{"day",day},{"balls",999}});auto r=f.send(free);
check(r["ok"]&&r["result"]["chargedCoins"]==0&&r["result"]["reward"]["balls"]==5&&f.state["balls"]==25&&f.state["shopBought"]==J({1,0,0}),"free supply grants configured five once");
@@ -457,7 +457,7 @@ int main(int argc,char** argv) {
auto shot=f.command("shot.begin",{{"aim",0}});auto launched=f.send(shot);before=f.state;check(f.send(f.command("shop.buy",{{"item",1},{"day",day}}))["error"]=="pending_activity"&&f.state==before,"pending activity blocks purchase");
f.now=launched["shot"]["readyAt"].get<int64_t>();f.send(f.command("shot.finish",{{"activityId",shot["requestId"]}}));
auto old=f.stored();old["schema"]=5;old.erase("shopBought");old["dailyClaimed"]={1,0,0};old["dailyProgress"][0]=3;old["decorations"]={1,0,0};old["campLayout"]={-1,0,-1};int coins=f.state["coins"],tickets=f.state["tickets"];f.replace(old);
check(f.state["schema"]==11&&f.state["coins"]==coins&&f.state["tickets"]==tickets&&f.state["dailyClaimed"]==J({1,0,0})&&f.state["shopBought"]==J({0,0,0})&&f.state["campLayout"]==J({-1,0,-1}),"schema5 migration preserves wallets daily and decorations, initializes only shop limits");
check(f.state["schema"]==14&&f.state["coins"]==coins&&f.state["tickets"]==tickets&&f.state["dailyClaimed"]==J({1,0,0})&&f.state["shopBought"]==J({0,0,0})&&f.state["campLayout"]==J({-1,0,-1}),"schema5 migration preserves wallets daily and decorations, initializes only shop limits");
}
{
Fixture f((root/"shop_pending.db").string());auto begin=f.command("battle.begin");auto fight=f.send(begin);auto old=f.stored();freeze_classic(old,3);old["schema"]=5;old.erase("shopBought");old["battleSnapshot"]["config"].erase("shop");f.replace(old);
@@ -468,7 +468,7 @@ int main(int argc,char** argv) {
}
{
Fixture f((root/"mail_single.db").string());
check(f.state["schema"]==11&&f.state["mails"].size()==2&&f.state["balls"]==20&&f.state["candy"]==0&&f.state["chests"]==J({0,0,0}),"schema7 adds letters, no automatic wallet award");
check(f.state["schema"]==14&&f.state["mails"].size()==2&&f.state["balls"]==20&&f.state["candy"]==0&&f.state["chests"]==J({0,0,0}),"schema7 adds letters, no automatic wallet award");
auto id=f.state["mails"][0]["mailId"],notice=f.state["mails"][1]["mailId"];auto reward=f.state["mails"][0]["reward"];
auto before=f.state;auto read=f.command("mail.read",{{"mailId",id}});auto r=f.send(read);
check(r["ok"]&&f.state["mails"][0]["read"]&&!f.state["mails"][0]["claimed"]&&f.state["balls"]==before["balls"],"reading never grants attachment");
@@ -523,7 +523,7 @@ int main(int argc,char** argv) {
{
Fixture f((root/"mail_migration.db").string());auto begin=f.command("battle.begin");auto fight=f.send(begin);auto old=f.stored();
freeze_classic(old,4);old["schema"]=6;old.erase("mails");old["coins"]=777;old["candy"]=888;old["signinClaimed"]={1,0,0,0,0,0,0};old["shopBought"]={1,0,0};old["battleSnapshot"]["config"].erase("mail");f.replace(old);
auto migrated=f.read();check(f.state["schema"]==11&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["balls"]==20&&f.state["mails"].size()==2&&f.state["shopBought"]==J({1,0,0})&&f.state["signinClaimed"]==J({1,0,0,0,0,0,0}),"schema6 migration adds mail only, preserves wallet and daily stock");
auto migrated=f.read();check(f.state["schema"]==14&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["balls"]==20&&f.state["mails"].size()==2&&f.state["shopBought"]==J({1,0,0})&&f.state["signinClaimed"]==J({1,0,0,0,0,0,0}),"schema6 migration adds mail only, preserves wallet and daily stock");
check(f.state["pendingBattle"]==begin["requestId"]&&!migrated["battle"]["snapshot"]["config"].contains("mail"),"migration preserves pending battle and pre-mail frozen config");
auto read=f.command("mail.read",{{"mailId",f.state["mails"][0]["mailId"]}});check(f.send(read)["error"]=="pending_activity","mail operations preserve pending activity exclusion");
f.now=fight["battle"]["readyAt"].get<int64_t>();auto end=f.send(f.command("battle.finish",{{"activityId",begin["requestId"]}}));
@@ -535,7 +535,7 @@ int main(int argc,char** argv) {
}
{
Fixture f((root/"chest.db").string());auto cfg=f.read()["config"];int day=f.state["dailyDay"];
check(f.state["schema"]==11&&f.state["chests"]==J({0,0,0})&&f.state["chestDailyClaimed"]==0&&f.state["balls"]==20&&f.state["stages"].size()==10,"fresh chest inventory is empty and grants nothing");
check(f.state["schema"]==14&&f.state["chests"]==J({0,0,0})&&f.state["chestDailyClaimed"]==0&&f.state["balls"]==20&&f.state["stages"].size()==10,"fresh chest inventory is empty and grants nothing");
auto before=f.state;f.read();check(f.state==before,"opening the chest page grants no chest");
check(f.send(f.command("chest.open",{{"chest",0}}))["error"]=="chest_missing"&&f.state==before,"opening a missing chest is refused");
check(f.send(f.command("chest.open",{{"chest",3}}))["error"]=="invalid_chest","unknown chest refused");
@@ -593,15 +593,15 @@ int main(int argc,char** argv) {
check(ok==16&&f.state["chests"]==J({1,0,0})&&f.state["chestDailyClaimed"]==1,"16 concurrent daily chest retries add one box");
auto old=f.stored();old["schema"]=7;old.erase("chests");old.erase("chestDailyClaimed");
old["coins"]=777;old["candy"]=888;old["mails"]=old["mails"];f.replace(old);
check(f.state["schema"]==11&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["chests"]==J({0,0,0})&&f.state["chestDailyClaimed"]==0&&f.state["mails"].size()==2&&f.state["stages"].size()==10&&f.state["clearedStages"].size()==20,"schema7 migration preserves wallet and mail, initializes empty chests without free rewards");
check(f.state["schema"]==14&&f.state["coins"]==777&&f.state["candy"]==888&&f.state["chests"]==J({0,0,0})&&f.state["chestDailyClaimed"]==0&&f.state["mails"].size()==2&&f.state["stages"].size()==10&&f.state["clearedStages"].size()==20,"schema7 migration preserves wallet and mail, initializes empty chests without free rewards");
}
{
Fixture f((root/"challenge.db").string());auto cfg=f.read()["config"];
check(cfg["challenges"].size()==5&&f.state["schema"]==11&&f.state["challengeBadges"]==J({0,0,0,0,0})&&f.state["challengeBestMs"]==J({0,0,0,0,0})&&f.state["storyRankAt"]==0&&f.state["challengeRankAt"]==J({0,0,0,0,0}),"fresh save has five locked element trials and no badges");
check(cfg["challenges"].size()==5&&f.state["schema"]==14&&f.state["challengeBadges"]==J({0,0,0,0,0})&&f.state["challengeBestMs"]==J({0,0,0,0,0})&&f.state["storyRankAt"]==0&&f.state["challengeRankAt"]==J({0,0,0,0,0})&&f.state["friendIds"].empty()&&f.state["giftSent"].empty()&&f.state["giftFrom"].empty(),"fresh save has five locked element trials and no badges or friends");
auto locked=f.command("challenge.begin",{{"stageId",1}});auto before=f.state;
check(f.send(locked)["error"]=="challenge_locked"&&f.state==before,"element trial stays closed before 1-1");
auto s=f.stored();s["schema"]=9;s.erase("challengeBadges");s.erase("challengeBestMs");s["shards"][0]=7;s["coins"]=640;f.replace(s);
check(f.state["schema"]==11&&f.state["challengeBadges"]==J({0,0,0,0,0})&&f.state["challengeBestMs"]==J({0,0,0,0,0})&&f.state["storyRankAt"]==0&&f.state["challengeRankAt"]==J({0,0,0,0,0})&&f.state["shards"][0]==7&&f.state["coins"]==640,"schema9 migration adds empty trials without shards or coins");
check(f.state["schema"]==14&&f.state["challengeBadges"]==J({0,0,0,0,0})&&f.state["challengeBestMs"]==J({0,0,0,0,0})&&f.state["storyRankAt"]==0&&f.state["challengeRankAt"]==J({0,0,0,0,0})&&f.state["shards"][0]==7&&f.state["coins"]==640,"schema9 migration adds empty trials without shards or coins");
s=f.stored();s["clearedStages"][0]=1;f.replace(s);before=f.state;auto story=before["formation"];
check(f.send(f.command("challenge.begin",{{"stageId",2}}))["error"]=="need_element"&&f.state==before,"grass trial explains itself by refusing until a grass partner is owned");
auto start=f.command("challenge.begin",{{"stageId",1}});auto began=f.send(start);
@@ -625,7 +625,7 @@ int main(int argc,char** argv) {
{
Fixture f((root/"rank.db").string());
int coins=f.state["coins"];
check(f.state["schema"]==11&&f.state["storyRankAt"]==0&&coins==20,"fresh account is absent from every board");
check(f.state["schema"]==14&&f.state["storyRankAt"]==0&&f.state["balls"]==20&&coins==0,"fresh account is absent from every board");
auto before=f.state;
check(f.send(f.command("rank.list",{{"stageId",9}}))["error"]=="invalid_board"&&f.state==before,"unknown board changes nothing");
check(f.send(f.command("rank.list",{{"stageId",-1}}))["error"]=="invalid_board"&&f.state==before,"negative board changes nothing");
@@ -640,7 +640,7 @@ int main(int argc,char** argv) {
check(f.send(f.command("battle.abandon",{{"activityId",start["requestId"]}}))["ok"],"abandon after a rank read");
auto migrated=f.stored();migrated["schema"]=10;migrated.erase("storyRankAt");migrated.erase("challengeRankAt");
migrated["clearedStages"][0]=1;migrated["challengeBestMs"][0]=4321;migrated["coins"]=640;f.replace(migrated);
check(f.state["schema"]==11&&f.state["storyRankAt"]==f.now&&f.state["challengeRankAt"][0]==f.now&&f.state["challengeRankAt"][1]==0&&f.state["coins"]==640&&f.state["shards"][0]==0,"schema10 migration stamps existing scores without paying rewards");
check(f.state["schema"]==14&&f.state["storyRankAt"]==f.now&&f.state["challengeRankAt"][0]==f.now&&f.state["challengeRankAt"][1]==0&&f.state["coins"]==640&&f.state["shards"][0]==0&&f.state["friendIds"].empty()&&f.state["giftSent"].empty(),"schema10 migration stamps existing scores without paying rewards");
auto paint=[&](int clears,int64_t at,int best,int64_t bestAt){auto s=f.stored();for(int i=0;i<static_cast<int>(s["clearedStages"].size());i++) s["clearedStages"][i]=i<clears?1:0;s["storyRankAt"]=at;s["challengeBestMs"][0]=best;s["challengeRankAt"][0]=best>0?bestAt:0;return s;};
auto write=[&](int64_t pid,J state){
std::string data=state.dump(),escaped;for(char c:data){escaped+=c;if(c=='\'') escaped+='\'';}
@@ -681,7 +681,197 @@ int main(int argc,char** argv) {
f.sql("UPDATE game_states SET state='"+escaped+"' WHERE pid="+std::to_string(*pid));
}
auto board=f.send(f.command("rank.list",{{"stageId",0}}));
check(board["ok"]&&board["rank"]["total"]==22&&board["rank"]["entries"].size()==20&&board["rank"]["entries"][0]["score"]==20&&board["rank"]["selfPlace"]==0&&f.state["coins"]==20,"the page keeps the top 20 and leaves an unranked player off the board");
check(board["ok"]&&board["rank"]["total"]==22&&board["rank"]["entries"].size()==20&&board["rank"]["entries"][0]["score"]==20&&board["rank"]["selfPlace"]==0&&f.state["coins"]==0&&f.state["balls"]==20,"the page keeps the top 20 and leaves an unranked player off the board");
}
{
Fixture f((root/"friends.db").string());
auto session=[&](const std::string& token){auto body=f.db.game_request(token,"",f.now);check(body.status==200,"friend read");return J::parse(body.body);};
auto act=[&](const std::string& token,const std::string& action,J extra){
auto view=session(token);extra["action"]=action;extra["revision"]=view["state"]["revision"];extra["requestId"]="friend-"+std::to_string(++f.serial)+"-unique-test";
auto body=f.db.game_request(token,extra.dump(),f.now);check(body.status==200,"friend command");auto reply=J::parse(body.body);
if(token==f.token) f.state=reply["state"];return reply;
};
auto person=[&](const std::string& name){
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"friend player");
check(f.db.store_token("friend-"+name,*pid,1,4102444800LL),"friend token");
auto opened=session("friend-"+name);check(opened["state"]["coins"]==0&&opened["state"]["friendIds"].empty(),"new player starts without friends or coins");
return *pid;
};
auto beginCmd=f.command("battle.begin",{{"stageId",1}});
auto begin=f.send(beginCmd);
check(begin["ok"]&&f.state["pendingBattle"]!="","battle started before the friend list");
auto during=f.send(f.command("friend.list"));
check(during["ok"]&&during["friends"]["friends"].empty()&&during["friends"]["sent"]==0&&f.state["pendingBattle"]!=""&&f.state["coins"]==0,"reading friends does not settle a battle or pay anyone");
check(f.send(f.command("battle.abandon",{{"activityId",beginCmd["requestId"]}}))["ok"],"abandon after the friend list");
check(!act(f.token,"friend.lookup",{{"target",1}})["ok"]&&f.state["coins"]==0,"a player cannot look up their own id");
check(act(f.token,"friend.lookup",{{"target",999999}})["error"]=="friend_missing","an unknown id is refused");
int64_t ally=person("ally");
auto found=act(f.token,"friend.lookup",{{"target",ally}});
check(found["ok"]&&found["friends"]["card"]["name"]=="ally"&&found["friends"]["card"]["relation"]=="none"&&found["friends"]["card"]["formation"].empty()&&found["friends"]["card"]["camp"].empty(),"lookup shows the name and hides the lineup");
check(act(f.token,"friend.visit",{{"target",ally}})["error"]=="friend_required","a camp visit requires friendship");
int allyRevision=session("friend-ally")["state"]["revision"].get<int>();
auto asked=act(f.token,"friend.request",{{"target",ally}});
auto allyView=session("friend-ally");
check(asked["ok"]&&f.state["friendOut"]==J::array({ally})&&allyView["state"]["friendIn"]==J::array({1})&&allyView["state"]["coins"]==0&&allyView["state"]["revision"]==allyRevision,"a request is recorded on both sides and does not pay or bump the other revision");
check(act(f.token,"friend.request",{{"target",ally}})["error"]=="friend_pending"&&f.state["friendOut"].size()==1,"the same request cannot be duplicated");
check(act(f.token,"friend.cancel",{{"target",ally}})["ok"]&&f.state["friendOut"].empty()&&session("friend-ally")["state"]["friendIn"].empty(),"cancel removes the request on both sides");
check(act("friend-ally","friend.request",{{"target",1}})["ok"],"the other player can request");
check(act(f.token,"friend.decline",{{"target",ally}})["ok"]&&f.state["friendIn"].empty()&&session("friend-ally")["state"]["friendOut"].empty(),"decline removes the incoming request");
check(act("friend-ally","friend.request",{{"target",1}})["ok"],"request again");
auto accepted=act(f.token,"friend.accept",{{"target",ally}});
check(accepted["ok"]&&f.state["friendIds"]==J::array({ally})&&f.state["friendIn"].empty()&&session("friend-ally")["state"]["friendIds"]==J::array({1})&&session("friend-ally")["state"]["friendOut"].empty(),"accept makes the friendship on both sides");
auto visit=act(f.token,"friend.visit",{{"target",ally}});
check(visit["ok"]&&visit["friends"]["card"]["formation"].size()==6&&visit["friends"]["card"]["camp"].size()==3&&!visit["friends"]["card"].contains("coins"),"a friend visit shows formation and camp only");
auto view=session(f.token);J gift={{"action","friend.gift"},{"revision",view["state"]["revision"]},{"requestId","friend-gift-once-unique"},{"target",ally}};
auto paid=J::parse(f.db.game_request(f.token,gift.dump(),f.now).body);f.state=paid["state"];
check(paid["ok"]&&f.state["coins"]==0&&f.state["giftSent"]==J::array({ally})&&session("friend-ally")["state"]["coins"]==20&&session("friend-ally")["state"]["giftFrom"]==J::array({1}),"the gift is paid once to the other player");
auto replayed=J::parse(f.db.game_request(f.token,gift.dump(),f.now).body);
check(replayed["replayed"]&&session("friend-ally")["state"]["coins"]==20,"replaying the gift does not pay again");
f.read();
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="gift_sent"&&session("friend-ally")["state"]["coins"]==20,"the same direction cannot be sent twice in one day");
check(act("friend-ally","friend.gift",{{"target",1}})["error"]=="gift_pair"&&f.read()["state"]["coins"]==0,"the pair cannot exchange a gift back on the same day");
check(act(f.token,"friend.remove",{{"target",ally}})["ok"]&&f.state["friendIds"].empty()&&session("friend-ally")["state"]["friendIds"].empty(),"delete removes both sides");
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="friend_required","a deleted friend cannot be gifted");
check(act(f.token,"friend.request",{{"target",ally}})["ok"],"request after delete");
auto mirrored=act("friend-ally","friend.request",{{"target",1}});
check(mirrored["ok"]&&mirrored["friends"]["card"]["relation"]=="friend"&&f.read()["state"]["friendIds"]==J::array({ally}),"a mutual request becomes a friendship");
check(act("friend-ally","friend.block",{{"target",1}})["ok"]&&session("friend-ally")["state"]["friendBlocked"]==J::array({1})&&f.read()["state"]["friendIds"].empty(),"block removes the friendship");
check(act(f.token,"friend.request",{{"target",ally}})["error"]=="friend_blocked","a blocked player cannot send a request");
check(act("friend-ally","friend.unblock",{{"target",1}})["ok"]&&session("friend-ally")["state"]["friendBlocked"].empty(),"unblock clears the block list");
f.now+=86400;
check(act(f.token,"friend.request",{{"target",ally}})["ok"]&&act("friend-ally","friend.accept",{{"target",1}})["ok"],"they can become friends again the next day");
check(act("friend-ally","friend.gift",{{"target",1}})["ok"]&&f.read()["state"]["coins"]==20&&session("friend-ally")["state"]["coins"]==20,"the other direction can send on a new day");
check(act(f.token,"friend.gift",{{"target",ally}})["error"]=="gift_pair"&&session("friend-ally")["state"]["coins"]==20,"that new day still allows only one direction");
int64_t hub=person("hub");
for(int i=0;i<6;i++) {
std::string name="giver"+std::to_string(i);int64_t giver=person(name);
check(act("friend-"+name,"friend.request",{{"target",hub}})["ok"]&&act("friend-hub","friend.accept",{{"target",giver}})["ok"],"receive-cap friendship");
auto sent=act("friend-"+name,"friend.gift",{{"target",hub}});
check(sent["ok"]==(i<5)&&session("friend-hub")["state"]["coins"]==(i<5?(i+1)*20:100),i<5?"a friend gift arrives":"the sixth gift is refused by the daily receive cap");
}
for(int i=0;i<6;i++) {
std::string name="taker"+std::to_string(i);int64_t taker=person(name);
check(act(f.token,"friend.request",{{"target",taker}})["ok"]&&act("friend-"+name,"friend.accept",{{"target",1}})["ok"],"send-cap friendship");
auto sent=act(f.token,"friend.gift",{{"target",taker}});
int takerCoins=session("friend-"+name)["state"]["coins"].get<int>();
check(sent["ok"]==(i<5)&&takerCoins==(i<5?20:0)&&f.read()["state"]["coins"]==20,i<5?"the sender can give five gifts":"the sixth send is refused and the sender's wallet stays unchanged");
}
int beforeFull=static_cast<int>(f.state["friendIds"].size());
for(int i=beforeFull;i<20;i++) {
std::string name="full"+std::to_string(i);int64_t other=person(name);
check(act(f.token,"friend.request",{{"target",other}})["ok"]&&act("friend-"+name,"friend.accept",{{"target",1}})["ok"],"fill the friend list");
}
int64_t extra=person("extra");
check(act(f.token,"friend.request",{{"target",extra}})["error"]=="friend_full"&&f.state["friendIds"].size()==20&&f.state["coins"]==20,"the friend cap refuses another request without changing the wallet");
auto migrated=f.state;int coins=migrated["coins"].get<int>();
migrated["schema"]=11;migrated.erase("friendIds");migrated.erase("friendIn");migrated.erase("friendOut");migrated.erase("friendBlocked");migrated.erase("giftSent");migrated.erase("giftFrom");
f.replace(migrated);
check(f.state["schema"]==14&&f.state["friendIds"].empty()&&f.state["giftSent"].empty()&&f.state["giftFrom"].empty()&&f.state["coins"]==coins,"schema11 migration adds empty friend lists and pays nothing");
}
{
Fixture f((root/"fish.db").string());
auto rng=[](uint32_t){return 0u;};
check(f.state["schema"]==14&&f.state["fishPlayed"]==0&&f.state["fishBest"]==0&&f.state["pendingFish"]==""&&f.state["coins"]==0&&f.state["balls"]==20,"fresh fishing counters are empty");
auto locked=f.send(f.command("fish.begin"),rng);
check(!locked["ok"]&&locked["error"]=="fish_locked"&&f.state["balls"]==20&&f.state["fishPlayed"]==0,"fishing stays locked before 1-1");
f.state["clearedStages"][0]=1;f.state["storyRankAt"]=f.now;f.replace(f.state);
auto battle=f.command("battle.begin");
check(f.send(battle)["ok"]&&f.send(f.command("fish.begin"),rng)["error"]=="pending_activity","a pending battle blocks fishing");
check(f.send(f.command("battle.abandon",{{"activityId",battle["requestId"]}}))["ok"]&&f.state["balls"]==20,"abandoning the battle spends no ball");
auto beginCmd=f.command("fish.begin");
auto began=f.send(beginCmd,rng);
check(began["ok"]&&f.state["balls"]==19&&f.state["fishPlayed"]==1&&f.state["pendingFish"]==beginCmd["requestId"]&&began["fish"]["spots"].size()==8,"begin spends one ball and freezes eight fish");
int expect=0;
for(int i=0;i<8;i++) {
check(began["fish"]["spots"][i]["at"]==400+i*2337&&began["fish"]["spots"][i]["window"]==900&&began["fish"]["spots"][i]["points"]==2,"the frozen pattern is fixed when the random source returns zero");
expect+=began["fish"]["spots"][i]["points"].get<int>();
}
auto cheat=f.send(f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"fishScore",99},{"tapFish",J::array({0})},{"taps",J::array({999999})}}));
check(!cheat["ok"]&&cheat["error"]=="invalid_input"&&f.state["pendingFish"]==beginCmd["requestId"]&&f.state["coins"]==0,"a forged score or tap does not pay");
auto future=f.send(f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"tapFish",J::array({0})},{"taps",J::array({5000})}}));
check(future["error"]=="invalid_fish_tap"&&f.state["coins"]==0&&f.state["pendingFish"]==beginCmd["requestId"],"a tap ahead of the server clock is refused");
f.now+=20;
J ids=J::array(),taps=J::array();
for(int i=0;i<8;i++) {ids.push_back(i);taps.push_back(began["fish"]["spots"][i]["at"]);}
auto fin=f.command("fish.finish",{{"activityId",beginCmd["requestId"]},{"tapFish",ids},{"taps",taps}});
auto done=f.send(fin);
check(done["ok"]&&done["result"]["fishScore"]==expect&&expect==16&&f.state["coins"]==30&&f.state["candy"]==5&&f.state["pendingFish"]==""&&f.state["fishBest"]==expect&&!f.state.contains("fishData"),"server scores the taps and pays the top tier once");
check(f.send(fin)["replayed"].get<bool>()&&f.state["coins"]==30&&f.state["candy"]==5,"replaying the catch does not pay again");
auto b2=f.command("fish.begin");
check(f.send(b2,rng)["ok"]&&f.state["balls"]==18,"second session spends another ball");
f.now+=20;
auto miss=f.send(f.command("fish.finish",{{"activityId",b2["requestId"]},{"tapFish",J::array()},{"taps",J::array()}}));
check(miss["ok"]&&miss["result"]["fishScore"]==0&&f.state["coins"]==30&&f.state["candy"]==5&&f.state["fishPlayed"]==2,"a miss closes the session without a reward");
auto b3=f.command("fish.begin");
check(f.send(b3,rng)["ok"],"third session");
check(f.send(f.command("fish.abandon",{{"activityId",b3["requestId"]}}))["ok"]&&f.state["pendingFish"]==""&&f.state["coins"]==30&&f.state["balls"]==17&&f.state["fishPlayed"]==3,"abandon keeps the spent ball and pays nothing");
auto b4=f.command("fish.begin");
check(f.send(b4,rng)["ok"],"fourth session");
check(f.send(f.command("battle.begin"))["error"]=="pending_activity","fishing blocks another activity");
f.now+=30;
check(f.send(f.command("fish.finish",{{"activityId",b4["requestId"]},{"tapFish",J::array()},{"taps",J::array()}}))["error"]=="fish_expired"&&f.state["pendingFish"]==b4["requestId"],"an expired session cannot be scored");
check(f.send(f.command("fish.abandon",{{"activityId",b4["requestId"]}}))["ok"]&&f.state["coins"]==30&&f.state["fishPlayed"]==4,"abandon after expiry still pays nothing");
auto b5=f.command("fish.begin");
check(f.send(b5,rng)["ok"]&&f.state["fishPlayed"]==5&&f.state["balls"]==15,"fifth session reaches the daily cap");
check(f.send(f.command("fish.abandon",{{"activityId",b5["requestId"]}}))["ok"],"fifth session can be abandoned");
check(f.send(f.command("fish.begin"),rng)["error"]=="fish_limit"&&f.state["balls"]==15&&f.state["fishPlayed"]==5,"the sixth session is refused and does not spend a ball");
int coins=f.state["coins"].get<int>();
f.now+=86400;f.read();
check(f.state["fishPlayed"]==0&&f.state["fishBest"]==expect&&f.state["coins"]==coins,"a new day restores attempts without resetting the best score or wallet");
auto old=f.state;old["schema"]=12;old.erase("fishPlayed");old.erase("fishBest");old.erase("pendingFish");old.erase("fishData");
f.replace(old);
check(f.state["schema"]==14&&f.state["fishPlayed"]==0&&f.state["fishBest"]==0&&f.state["pendingFish"]==""&&f.state["coins"]==coins,"schema12 migration adds empty fishing counters and pays nothing");
}
{
Fixture f((root/"treasure.db").string());
auto rng=[](uint32_t){return 0u;};
auto session=[&](const std::string& token){auto body=f.db.game_request(token,"",f.now);check(body.status==200,"treasure read");return J::parse(body.body);};
auto act=[&](const std::string& token,const std::string& action,J extra){
auto view=session(token);extra["action"]=action;extra["revision"]=view["state"]["revision"];extra["requestId"]="treasure-"+std::to_string(++f.serial)+"-unique-test";
auto body=f.db.game_request(token,extra.dump(),f.now,rng);check(body.status==200,"treasure command");auto reply=J::parse(body.body);
if(token==f.token) f.state=reply["state"];return reply;
};
auto person=[&](const std::string& name){
auto pid=f.db.create_user(name,"hash","salt");check(pid.has_value(),"treasure player");
check(f.db.store_token("treasure-"+name,*pid,1,4102444800LL),"treasure token");
return *pid;
};
auto befriend=[&](int64_t pid,const std::string& name){
check(act(f.token,"friend.request",{{"target",pid}})["ok"]&&act("treasure-"+name,"friend.accept",{{"target",1}})["ok"],"treasure friendship");
};
check(f.state["schema"]==14&&f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==0&&f.state["candy"]==0,"fresh treasure counters are empty");
int64_t ally=person("ally");
check(act(f.token,"friend.treasure",{{"target",ally},{"index",99}})["error"]=="friend_required"&&f.state["coins"]==0&&session("treasure-ally")["state"]["coins"]==0,"treasure requires a friend and ignores a forged event");
befriend(ally,"ally");
check(act(f.token,"friend.treasure",{{"target",ally}})["error"]=="treasure_locked"&&f.state["coins"]==0&&session("treasure-ally")["state"]["coins"]==0,"treasure stays locked before 1-1");
f.state["clearedStages"][0]=1;f.state["storyRankAt"]=f.now;f.replace(f.state);
auto battle=f.command("battle.begin");
check(f.send(battle)["ok"],"a story battle can stay open");
auto first=f.command("friend.treasure",{{"target",ally},{"index",99}});
auto found=f.send(first,rng);
check(found["ok"]&&found["friends"]["treasure"]["index"]==0&&found["friends"]["treasure"]["reward"]["coins"]==10&&found["friends"]["treasure"]["reward"]["candy"]==0&&f.state["coins"]==10&&f.state["candy"]==0&&f.state["balls"]==20&&f.state["treasurePlayed"]==1&&f.state["treasureSeen"]==J::array({ally})&&f.state["pendingBattle"]!="","the server picks the event and pays the visitor during a battle");
auto allyAfter=session("treasure-ally")["state"];
check(allyAfter["coins"]==0&&allyAfter["candy"]==0&&allyAfter["balls"]==20&&allyAfter["campLayout"]==J::array({-1,-1,-1}),"the friend's wallet and camp stay unchanged");
check(f.send(f.command("battle.abandon",{{"activityId",battle["requestId"]}}))["ok"]&&f.state["pendingBattle"]=="","the battle can still be abandoned");
check(f.send(first)["replayed"].get<bool>()&&f.state["coins"]==10,"replaying the search does not pay again");
check(act(f.token,"friend.treasure",{{"target",ally}})["error"]=="treasure_seen"&&f.state["coins"]==10,"the same camp can be searched once each day");
int64_t second=person("second"),third=person("third"),fourth=person("fourth");
befriend(second,"second");befriend(third,"third");befriend(fourth,"fourth");
check(act(f.token,"friend.treasure",{{"target",second}})["ok"]&&f.state["coins"]==20&&f.state["treasurePlayed"]==2,"the second camp pays the same band");
check(act(f.token,"friend.treasure",{{"target",third}})["ok"]&&f.state["coins"]==30&&f.state["treasurePlayed"]==3,"the third camp reaches the daily cap");
check(act(f.token,"friend.treasure",{{"target",fourth}})["error"]=="treasure_limit"&&f.state["coins"]==30&&f.state["balls"]==20&&session("treasure-fourth")["state"]["coins"]==0,"the fourth search is refused and does not spend a ball");
int coins=f.state["coins"].get<int>();
f.now+=86400;f.read();
check(f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==coins,"a new day restores searches without changing the wallet");
check(act(f.token,"friend.treasure",{{"target",ally}})["ok"]&&f.state["coins"]==40&&f.state["treasurePlayed"]==1,"the same camp can be searched again the next day");
for(size_t i=0;i<f.state["clearedStages"].size();++i) f.state["clearedStages"][i]=1;
f.replace(f.state);
int64_t fifth=person("fifth");befriend(fifth,"fifth");
auto deep=act(f.token,"friend.treasure",{{"target",fifth}});
check(deep["ok"]&&deep["friends"]["treasure"]["reward"]["coins"]==30&&deep["friends"]["treasure"]["reward"]["candy"]==5&&f.state["coins"]==70&&f.state["candy"]==5&&session("treasure-fifth")["state"]["coins"]==0&&session("treasure-fifth")["state"]["candy"]==0,"ten clears pay the top band from the event pool");
auto old=f.state;old["schema"]=13;old.erase("treasurePlayed");old.erase("treasureSeen");
f.replace(old);
check(f.state["schema"]==14&&f.state["treasurePlayed"]==0&&f.state["treasureSeen"].empty()&&f.state["coins"]==70&&f.state["candy"]==5,"schema13 migration adds empty treasure counters and pays nothing");
}
std::filesystem::remove_all(root);
if(argc>1) {std::ofstream output(argv[1],std::ios::binary);output<<parity.dump(2);check(output.good(),"parity vectors exported");}