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
+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");}