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:
@@ -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"]&¤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<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"]&¤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<bool>()&¤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<<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;}}
|
||||
Reference in New Issue
Block a user