#include "db.hpp" #include "game_config.hpp" #include #include #include #include #include #include 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() >= 60) throw std::runtime_error("shot budget exhausted"); auto begin = send("shot.begin", {{"aim", 0}}); now = std::max(now, begin["shot"]["readyAt"].get()); send("shot.finish", {{"activityId", begin["shot"]["id"]}}); }; auto train = [&](int target) { while (current["state"]["level"].get() < target) { while (current["state"]["candy"].get() < current["state"]["level"].get() * gameplay::config()["economy"]["trainCost"].get()) 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()); 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()) 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() != before[resource].get() + reward[resource].get()) 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()); auto replayed = send("battle.finish", {{"activityId", retry["battle"]["id"]}}); if (!replayed["result"]["won"].get() || replayed["result"]["firstClear"].get()) 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; }