#include "combat_runtime.hpp" #include "combat_slice.hpp" #include "game_config.hpp" #include "db.hpp" #include "crypto.hpp" #include "sqlite3.h" #include #include #include #include #include #include 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(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 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()<=9&&trace["durability"].get()>=0,"result bounded"); int last=-1,seq=0;std::set> hits;std::set deaths; for(const auto& e:trace["events"]){check(e["tick"].get()>=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(),e["target"].get(),e["action"].get())).second,"unique cast/action/target hit"); if(kind=="death")check(deaths.insert(e["target"].get()).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::ofstream out(folder/("stage_"+std::to_string(stage)+"_t"+std::to_string(tier)+"_f"+std::to_string(formation)+".json"),std::ios::binary);out<()){ 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());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"]&¤t["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(),"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"]&¤t["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()&¤t["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<