Implement recording functionality in agent test commands

- Added RecordingState structure to manage recording session details.
- Implemented SaveRecordingState and LoadRecordingState functions to handle recording state persistence.
- Enhanced HandleTestRecordCommand to support starting and stopping recording sessions with various options.
- Integrated gRPC calls for starting and stopping recordings in GuiAutomationClient.
- Updated ProposalRegistry to include sandbox directory and ROM path in ProposalMetadata.
- Implemented JSON parsing for Tile16Proposal to handle proposal creation from JSON input.
- Updated CMakeLists.txt to ensure proper linking of emulator targets.
This commit is contained in:
scawful
2025-10-03 21:16:08 -04:00
parent 5419633c52
commit 2c45453dd0
9 changed files with 973 additions and 109 deletions

View File

@@ -186,6 +186,74 @@ absl::StatusOr<ReplayTestResult> GuiAutomationClient::ReplayTest(
#endif
}
absl::StatusOr<StartRecordingResult> GuiAutomationClient::StartRecording(
const std::string& output_path, const std::string& session_name,
const std::string& description) {
#ifdef YAZE_WITH_GRPC
if (!stub_) {
return absl::FailedPreconditionError("Not connected. Call Connect() first.");
}
yaze::test::StartRecordingRequest request;
request.set_output_path(output_path);
request.set_session_name(session_name);
request.set_description(description);
yaze::test::StartRecordingResponse response;
grpc::ClientContext context;
grpc::Status status = stub_->StartRecording(&context, request, &response);
if (!status.ok()) {
return absl::InternalError(
absl::StrCat("StartRecording RPC failed: ", status.error_message()));
}
StartRecordingResult result;
result.success = response.success();
result.message = response.message();
result.recording_id = response.recording_id();
result.started_at = OptionalTimeFromMillis(response.started_at_ms());
return result;
#else
return absl::UnimplementedError("gRPC not available");
#endif
}
absl::StatusOr<StopRecordingResult> GuiAutomationClient::StopRecording(
const std::string& recording_id, bool discard) {
#ifdef YAZE_WITH_GRPC
if (!stub_) {
return absl::FailedPreconditionError("Not connected. Call Connect() first.");
}
if (recording_id.empty()) {
return absl::InvalidArgumentError("recording_id must not be empty");
}
yaze::test::StopRecordingRequest request;
request.set_recording_id(recording_id);
request.set_discard(discard);
yaze::test::StopRecordingResponse response;
grpc::ClientContext context;
grpc::Status status = stub_->StopRecording(&context, request, &response);
if (!status.ok()) {
return absl::InternalError(
absl::StrCat("StopRecording RPC failed: ", status.error_message()));
}
StopRecordingResult result;
result.success = response.success();
result.message = response.message();
result.output_path = response.output_path();
result.step_count = response.step_count();
result.duration = std::chrono::milliseconds(response.duration_ms());
return result;
#else
return absl::UnimplementedError("gRPC not available");
#endif
}
absl::StatusOr<AutomationResult> GuiAutomationClient::Click(
const std::string& target, ClickType type) {
#ifdef YAZE_WITH_GRPC

View File

@@ -134,6 +134,21 @@ struct ReplayTestResult {
std::vector<std::string> logs;
};
struct StartRecordingResult {
bool success = false;
std::string message;
std::string recording_id;
std::optional<absl::Time> started_at;
};
struct StopRecordingResult {
bool success = false;
std::string message;
std::string output_path;
int step_count = 0;
std::chrono::milliseconds duration{0};
};
enum class WidgetTypeFilter {
kUnspecified,
kAll,
@@ -303,6 +318,15 @@ class GuiAutomationClient {
const std::string& script_path, bool ci_mode,
const std::map<std::string, std::string>& parameter_overrides = {});
absl::StatusOr<StartRecordingResult> StartRecording(
const std::string& output_path,
const std::string& session_name,
const std::string& description);
absl::StatusOr<StopRecordingResult> StopRecording(
const std::string& recording_id,
bool discard = false);
/**
* @brief Check if client is connected
*/

View File

@@ -8,10 +8,13 @@
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "absl/strings/ascii.h"
#include "absl/strings/match.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_format.h"
#include "absl/time/time.h"
#include "nlohmann/json.hpp"
#include "util/macro.h"
namespace yaze {
@@ -31,6 +34,40 @@ std::filesystem::path DetermineDefaultRoot() {
return temp_dir / "yaze" / "proposals";
}
std::string StatusToString(ProposalRegistry::ProposalStatus status) {
switch (status) {
case ProposalRegistry::ProposalStatus::kAccepted:
return "accepted";
case ProposalRegistry::ProposalStatus::kRejected:
return "rejected";
case ProposalRegistry::ProposalStatus::kPending:
default:
return "pending";
}
}
ProposalRegistry::ProposalStatus ParseStatus(absl::string_view value) {
std::string lower = absl::AsciiStrToLower(value);
if (absl::StartsWith(lower, "accept")) {
return ProposalRegistry::ProposalStatus::kAccepted;
}
if (absl::StartsWith(lower, "reject")) {
return ProposalRegistry::ProposalStatus::kRejected;
}
return ProposalRegistry::ProposalStatus::kPending;
}
int64_t TimeToMillis(absl::Time time) {
return absl::ToUnixMillis(time);
}
std::optional<absl::Time> OptionalTimeFromMillis(int64_t millis) {
if (millis <= 0) {
return std::nullopt;
}
return absl::FromUnixMillis(millis);
}
} // namespace
ProposalRegistry& ProposalRegistry::Instance() {
@@ -65,92 +102,169 @@ absl::Status ProposalRegistry::EnsureRootExistsLocked() {
absl::Status ProposalRegistry::LoadProposalsFromDiskLocked() {
std::error_code ec;
// Check if root directory exists
if (!std::filesystem::exists(root_directory_, ec)) {
return absl::OkStatus(); // No proposals to load
return absl::OkStatus();
}
// Iterate over all directories in the root
for (const auto& entry : std::filesystem::directory_iterator(root_directory_, ec)) {
if (ec) {
continue; // Skip entries that cause errors
}
if (!entry.is_directory()) {
continue; // Skip non-directories
break;
}
std::string proposal_id = entry.path().filename().string();
// Skip if already loaded (shouldn't happen, but be defensive)
if (!entry.is_directory()) {
continue;
}
const std::string proposal_id = entry.path().filename().string();
if (proposals_.find(proposal_id) != proposals_.end()) {
continue;
}
// Reconstruct metadata from directory contents
// Since we don't have a metadata.json file, we need to infer what we can
std::filesystem::path log_path = entry.path() / "execution.log";
std::filesystem::path diff_path = entry.path() / "diff.txt";
// Check if log file exists to determine if this is a valid proposal
if (!std::filesystem::exists(log_path, ec)) {
continue; // Not a valid proposal directory
}
ProposalMetadata metadata;
bool metadata_loaded = false;
const std::filesystem::path metadata_path = entry.path() / "metadata.json";
// Extract timestamp from proposal ID (format: proposal-20251001T200215-1)
absl::Time created_at = absl::Now(); // Default to now if parsing fails
if (proposal_id.starts_with("proposal-")) {
std::string time_str = proposal_id.substr(9, 15); // Extract YYYYMMDDTHHmmSS
std::string error;
if (absl::ParseTime("%Y%m%dT%H%M%S", time_str, &created_at, &error)) {
// Successfully parsed time
if (std::filesystem::exists(metadata_path, ec) && !ec) {
std::ifstream metadata_file(metadata_path);
if (metadata_file.is_open()) {
try {
nlohmann::json metadata_json;
metadata_file >> metadata_json;
metadata.id = metadata_json.value("id", proposal_id);
if (metadata.id.empty()) {
metadata.id = proposal_id;
}
metadata.sandbox_id = metadata_json.value("sandbox_id", "");
if (metadata_json.contains("sandbox_directory") &&
metadata_json["sandbox_directory"].is_string()) {
metadata.sandbox_directory =
std::filesystem::path(metadata_json["sandbox_directory"].get<std::string>());
} else {
metadata.sandbox_directory.clear();
}
if (metadata_json.contains("sandbox_rom_path") &&
metadata_json["sandbox_rom_path"].is_string()) {
metadata.sandbox_rom_path =
std::filesystem::path(metadata_json["sandbox_rom_path"].get<std::string>());
} else {
metadata.sandbox_rom_path.clear();
}
metadata.description = metadata_json.value("description", "");
metadata.prompt = metadata_json.value("prompt", "");
metadata.status = ParseStatus(metadata_json.value("status", "pending"));
int64_t created_at_millis = metadata_json.value<int64_t>(
"created_at_millis", TimeToMillis(absl::Now()));
metadata.created_at = absl::FromUnixMillis(created_at_millis);
int64_t reviewed_at_millis = metadata_json.value<int64_t>(
"reviewed_at_millis", 0);
metadata.reviewed_at = OptionalTimeFromMillis(reviewed_at_millis);
std::string diff_path = metadata_json.value("diff_path", std::string("diff.txt"));
std::string log_path = metadata_json.value("log_path", std::string("execution.log"));
metadata.diff_path = entry.path() / diff_path;
metadata.log_path = entry.path() / log_path;
metadata.bytes_changed = metadata_json.value("bytes_changed", 0);
metadata.commands_executed = metadata_json.value("commands_executed", 0);
metadata.screenshots.clear();
if (metadata_json.contains("screenshots") &&
metadata_json["screenshots"].is_array()) {
for (const auto& screenshot : metadata_json["screenshots"]) {
if (screenshot.is_string()) {
metadata.screenshots.emplace_back(entry.path() /
screenshot.get<std::string>());
}
}
}
if (metadata.sandbox_directory.empty() &&
!metadata.sandbox_rom_path.empty()) {
metadata.sandbox_directory = metadata.sandbox_rom_path.parent_path();
}
metadata_loaded = true;
} catch (const std::exception& ex) {
std::cerr << "Warning: Failed to parse metadata for proposal "
<< proposal_id << ": " << ex.what() << "\n";
}
}
}
// Get file modification time as a fallback
auto ftime = std::filesystem::last_write_time(log_path, ec);
if (!ec) {
auto sctp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
ftime - std::filesystem::file_time_type::clock::now() +
std::chrono::system_clock::now());
auto time_t_value = std::chrono::system_clock::to_time_t(sctp);
created_at = absl::FromTimeT(time_t_value);
}
if (!metadata_loaded) {
std::filesystem::path log_path = entry.path() / "execution.log";
if (!std::filesystem::exists(log_path, ec) || ec) {
continue;
}
// Create minimal metadata for this proposal
ProposalMetadata metadata{
.id = proposal_id,
.sandbox_id = "", // Unknown - not stored in logs
.description = "Loaded from disk",
.prompt = "", // Unknown - not stored in logs
.status = ProposalStatus::kPending,
.created_at = created_at,
.reviewed_at = std::nullopt,
.diff_path = diff_path,
.log_path = log_path,
.screenshots = {},
.bytes_changed = 0,
.commands_executed = 0,
};
std::filesystem::path diff_path = entry.path() / "diff.txt";
// Count diff size if it exists
if (std::filesystem::exists(diff_path, ec) && !ec) {
metadata.bytes_changed = static_cast<int>(
std::filesystem::file_size(diff_path, ec));
}
absl::Time created_at = absl::Now();
if (proposal_id.starts_with("proposal-")) {
std::string time_str = proposal_id.substr(9, 15);
std::string error;
if (absl::ParseTime("%Y%m%dT%H%M%S", time_str, &created_at, &error)) {
// Parsed successfully.
}
}
// Scan for screenshots
for (const auto& file : std::filesystem::directory_iterator(entry.path(), ec)) {
if (ec) continue;
if (file.path().extension() == ".png" ||
file.path().extension() == ".jpg" ||
file.path().extension() == ".jpeg") {
metadata.screenshots.push_back(file.path());
auto ftime = std::filesystem::last_write_time(log_path, ec);
if (!ec) {
auto sctp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
ftime - std::filesystem::file_time_type::clock::now() +
std::chrono::system_clock::now());
auto time_t_value = std::chrono::system_clock::to_time_t(sctp);
created_at = absl::FromTimeT(time_t_value);
}
metadata = ProposalMetadata{
.id = proposal_id,
.sandbox_id = "",
.sandbox_directory = std::filesystem::path(),
.sandbox_rom_path = std::filesystem::path(),
.description = "Loaded from disk",
.prompt = "",
.status = ProposalStatus::kPending,
.created_at = created_at,
.reviewed_at = std::nullopt,
.diff_path = diff_path,
.log_path = log_path,
.screenshots = {},
.bytes_changed = 0,
.commands_executed = 0,
};
if (std::filesystem::exists(diff_path, ec) && !ec) {
metadata.bytes_changed = static_cast<int>(
std::filesystem::file_size(diff_path, ec));
}
for (const auto& file : std::filesystem::directory_iterator(entry.path(), ec)) {
if (ec) {
break;
}
if (file.path().extension() == ".png" || file.path().extension() == ".jpg" ||
file.path().extension() == ".jpeg") {
metadata.screenshots.push_back(file.path());
}
}
// Create a metadata file for legacy proposals so future loads are fast.
absl::Status write_status = WriteMetadataLocked(metadata);
if (!write_status.ok()) {
std::cerr << "Warning: Failed to persist metadata for legacy proposal "
<< proposal_id << ": " << write_status.message() << "\n";
}
}
proposals_[proposal_id] = metadata;
proposals_[metadata.id] = metadata;
}
return absl::OkStatus();
@@ -171,6 +285,7 @@ std::filesystem::path ProposalRegistry::ProposalDirectory(
absl::StatusOr<ProposalRegistry::ProposalMetadata>
ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
const std::filesystem::path& sandbox_rom_path,
absl::string_view prompt,
absl::string_view description) {
std::unique_lock<std::mutex> lock(mutex_);
@@ -191,6 +306,10 @@ ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
proposals_[id] = ProposalMetadata{
.id = id,
.sandbox_id = std::string(sandbox_id),
.sandbox_directory = sandbox_rom_path.empty()
? std::filesystem::path()
: sandbox_rom_path.parent_path(),
.sandbox_rom_path = sandbox_rom_path,
.description = std::string(description),
.prompt = std::string(prompt),
.status = ProposalStatus::kPending,
@@ -203,6 +322,8 @@ ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
.commands_executed = 0,
};
RETURN_IF_ERROR(WriteMetadataLocked(proposals_.at(id)));
return proposals_.at(id);
}
@@ -227,6 +348,8 @@ absl::Status ProposalRegistry::RecordDiff(const std::string& proposal_id,
// Update bytes_changed metric (rough estimate based on diff size)
it->second.bytes_changed = static_cast<int>(diff_content.size());
RETURN_IF_ERROR(WriteMetadataLocked(it->second));
return absl::OkStatus();
}
@@ -272,6 +395,21 @@ absl::Status ProposalRegistry::AddScreenshot(
}
it->second.screenshots.push_back(screenshot_path);
RETURN_IF_ERROR(WriteMetadataLocked(it->second));
return absl::OkStatus();
}
absl::Status ProposalRegistry::UpdateCommandStats(const std::string& proposal_id,
int commands_executed) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = proposals_.find(proposal_id);
if (it == proposals_.end()) {
return absl::NotFoundError(
absl::StrCat("Proposal not found: ", proposal_id));
}
it->second.commands_executed = commands_executed;
RETURN_IF_ERROR(WriteMetadataLocked(it->second));
return absl::OkStatus();
}
@@ -286,7 +424,7 @@ absl::Status ProposalRegistry::UpdateStatus(const std::string& proposal_id,
it->second.status = status;
it->second.reviewed_at = absl::Now();
RETURN_IF_ERROR(WriteMetadataLocked(it->second));
return absl::OkStatus();
}
@@ -354,6 +492,70 @@ ProposalRegistry::GetLatestPendingProposal() const {
return *latest;
}
absl::Status ProposalRegistry::WriteMetadataLocked(
const ProposalMetadata& metadata) const {
std::filesystem::path proposal_dir = ProposalDirectory(metadata.id);
std::error_code ec;
if (!std::filesystem::exists(proposal_dir, ec) || ec) {
return absl::NotFoundError(
absl::StrCat("Proposal directory missing for ", metadata.id));
}
auto relative_to_proposal = [&](const std::filesystem::path& path) {
if (path.empty()) {
return std::string();
}
std::error_code relative_error;
auto relative_path = std::filesystem::relative(path, proposal_dir, relative_error);
if (!relative_error) {
return relative_path.generic_string();
}
return path.generic_string();
};
nlohmann::json metadata_json;
metadata_json["id"] = metadata.id;
metadata_json["sandbox_id"] = metadata.sandbox_id;
if (!metadata.sandbox_directory.empty()) {
metadata_json["sandbox_directory"] = metadata.sandbox_directory.generic_string();
}
if (!metadata.sandbox_rom_path.empty()) {
metadata_json["sandbox_rom_path"] = metadata.sandbox_rom_path.generic_string();
}
metadata_json["description"] = metadata.description;
metadata_json["prompt"] = metadata.prompt;
metadata_json["status"] = StatusToString(metadata.status);
metadata_json["created_at_millis"] = TimeToMillis(metadata.created_at);
metadata_json["reviewed_at_millis"] = metadata.reviewed_at.has_value()
? TimeToMillis(*metadata.reviewed_at)
: int64_t{0};
metadata_json["diff_path"] = relative_to_proposal(metadata.diff_path);
metadata_json["log_path"] = relative_to_proposal(metadata.log_path);
metadata_json["bytes_changed"] = metadata.bytes_changed;
metadata_json["commands_executed"] = metadata.commands_executed;
nlohmann::json screenshots_json = nlohmann::json::array();
for (const auto& screenshot : metadata.screenshots) {
screenshots_json.push_back(relative_to_proposal(screenshot));
}
metadata_json["screenshots"] = std::move(screenshots_json);
std::ofstream metadata_file(proposal_dir / "metadata.json", std::ios::out);
if (!metadata_file.is_open()) {
return absl::InternalError(absl::StrCat(
"Failed to write metadata file for proposal ", metadata.id));
}
metadata_file << metadata_json.dump(2);
metadata_file.close();
if (!metadata_file) {
return absl::InternalError(absl::StrCat(
"Failed to flush metadata file for proposal ", metadata.id));
}
return absl::OkStatus();
}
absl::Status ProposalRegistry::RemoveProposal(const std::string& proposal_id) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = proposals_.find(proposal_id);

View File

@@ -37,6 +37,8 @@ class ProposalRegistry {
struct ProposalMetadata {
std::string id;
std::string sandbox_id;
std::filesystem::path sandbox_directory;
std::filesystem::path sandbox_rom_path;
std::string description;
std::string prompt; // Original agent prompt that created this proposal
ProposalStatus status;
@@ -65,6 +67,7 @@ class ProposalRegistry {
// is created under the root, and metadata is initialized.
absl::StatusOr<ProposalMetadata> CreateProposal(
absl::string_view sandbox_id,
const std::filesystem::path& sandbox_rom_path,
absl::string_view prompt,
absl::string_view description);
@@ -82,6 +85,11 @@ class ProposalRegistry {
absl::Status AddScreenshot(const std::string& proposal_id,
const std::filesystem::path& screenshot_path);
// Updates the number of commands executed for a proposal. Used to track
// how many CLI commands ran when generating the proposal.
absl::Status UpdateCommandStats(const std::string& proposal_id,
int commands_executed);
// Updates the proposal status (pending -> accepted/rejected) and sets
// the review timestamp.
absl::Status UpdateStatus(const std::string& proposal_id,
@@ -111,6 +119,7 @@ class ProposalRegistry {
absl::Status LoadProposalsFromDiskLocked();
std::string GenerateProposalIdLocked();
std::filesystem::path ProposalDirectory(absl::string_view proposal_id) const;
absl::Status WriteMetadataLocked(const ProposalMetadata& metadata) const;
std::filesystem::path root_directory_;
mutable std::mutex mutex_;

View File

@@ -1,12 +1,15 @@
#include "cli/service/planning/tile16_proposal_generator.h"
#include <sstream>
#include <fstream>
#include <sstream>
#include "absl/strings/match.h"
#include "absl/strings/str_split.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/numbers.h"
#include "app/zelda3/overworld/overworld.h"
#include "nlohmann/json.hpp"
#include "util/macro.h"
namespace yaze {
namespace cli {
@@ -54,9 +57,175 @@ std::string Tile16Proposal::ToJson() const {
return json.str();
}
absl::StatusOr<Tile16Proposal> Tile16Proposal::FromJson(const std::string& /* json */) {
// TODO: Implement JSON parsing using nlohmann/json when available
return absl::UnimplementedError("JSON parsing not yet implemented");
namespace {
absl::StatusOr<uint16_t> ParseTileValue(const nlohmann::json& json,
const char* field) {
if (!json.contains(field)) {
return absl::InvalidArgumentError(
absl::StrCat("Missing field '", field, "' in proposal change"));
}
if (json[field].is_number_integer()) {
int value = json[field].get<int>();
if (value < 0 || value > 0xFFFF) {
return absl::InvalidArgumentError(
absl::StrCat("Tile value for '", field,
"' out of range: ", value));
}
return static_cast<uint16_t>(value);
}
if (json[field].is_string()) {
std::string value = json[field].get<std::string>();
if (value.empty()) {
return absl::InvalidArgumentError(
absl::StrCat("Tile value for '", field, "' is empty"));
}
// Support hex strings in 0xFFFF format or plain decimal strings
if (absl::StartsWith(value, "0x") || absl::StartsWith(value, "0X")) {
if (value.size() <= 2) {
return absl::InvalidArgumentError(
absl::StrCat("Invalid hex tile value for '", field,
"': ", json[field].get<std::string>()));
}
value = value.substr(2);
unsigned int parsed = 0;
if (!absl::SimpleHexAtoi(value, &parsed) || parsed > 0xFFFF) {
return absl::InvalidArgumentError(
absl::StrCat("Invalid hex tile value for '", field,
"': ", json[field].get<std::string>()));
}
return static_cast<uint16_t>(parsed);
}
unsigned int parsed = 0;
if (!absl::SimpleAtoi(value, &parsed) || parsed > 0xFFFF) {
return absl::InvalidArgumentError(
absl::StrCat("Invalid tile value for '", field,
"': ", json[field].get<std::string>()));
}
return static_cast<uint16_t>(parsed);
}
return absl::InvalidArgumentError(
absl::StrCat("Unsupported JSON type for tile field '", field, "'"));
}
Tile16Proposal::Status ParseStatus(absl::string_view status_text) {
if (absl::StartsWith(status_text, "accept")) {
return Tile16Proposal::Status::ACCEPTED;
}
if (absl::StartsWith(status_text, "reject")) {
return Tile16Proposal::Status::REJECTED;
}
if (absl::StartsWith(status_text, "apply")) {
return Tile16Proposal::Status::APPLIED;
}
return Tile16Proposal::Status::PENDING;
}
} // namespace
absl::StatusOr<Tile16Proposal> Tile16Proposal::FromJson(
const std::string& json_text) {
nlohmann::json json;
try {
json = nlohmann::json::parse(json_text);
} catch (const nlohmann::json::parse_error& error) {
return absl::InvalidArgumentError(
absl::StrCat("Failed to parse proposal JSON: ", error.what()));
}
Tile16Proposal proposal;
if (!json.contains("id") || !json["id"].is_string()) {
return absl::InvalidArgumentError(
"Proposal JSON must include string field 'id'");
}
proposal.id = json["id"].get<std::string>();
if (!json.contains("prompt") || !json["prompt"].is_string()) {
return absl::InvalidArgumentError(
"Proposal JSON must include string field 'prompt'");
}
proposal.prompt = json["prompt"].get<std::string>();
if (json.contains("ai_service") && json["ai_service"].is_string()) {
proposal.ai_service = json["ai_service"].get<std::string>();
}
if (json.contains("reasoning") && json["reasoning"].is_string()) {
proposal.reasoning = json["reasoning"].get<std::string>();
}
if (json.contains("status")) {
if (!json["status"].is_string()) {
return absl::InvalidArgumentError(
"Proposal 'status' must be a string value");
}
proposal.status = ParseStatus(json["status"].get<std::string>());
} else {
proposal.status = Status::PENDING;
}
if (json.contains("changes")) {
if (!json["changes"].is_array()) {
return absl::InvalidArgumentError(
"Proposal 'changes' field must be an array");
}
for (const auto& change_json : json["changes"]) {
if (!change_json.is_object()) {
return absl::InvalidArgumentError(
"Each change entry must be a JSON object");
}
Tile16Change change;
if (!change_json.contains("map_id") ||
!change_json["map_id"].is_number_integer()) {
return absl::InvalidArgumentError(
"Tile change missing integer field 'map_id'");
}
change.map_id = change_json["map_id"].get<int>();
if (!change_json.contains("x") ||
!change_json["x"].is_number_integer()) {
return absl::InvalidArgumentError(
"Tile change missing integer field 'x'");
}
change.x = change_json["x"].get<int>();
if (!change_json.contains("y") ||
!change_json["y"].is_number_integer()) {
return absl::InvalidArgumentError(
"Tile change missing integer field 'y'");
}
change.y = change_json["y"].get<int>();
ASSIGN_OR_RETURN(change.old_tile,
ParseTileValue(change_json, "old_tile"));
ASSIGN_OR_RETURN(change.new_tile,
ParseTileValue(change_json, "new_tile"));
proposal.changes.push_back(change);
}
}
if (proposal.changes.empty()) {
return absl::InvalidArgumentError(
"Proposal JSON did not include any tile16 changes");
}
proposal.created_at = std::chrono::system_clock::now();
if (json.contains("created_at_ms") && json["created_at_ms"].is_number()) {
auto millis = json["created_at_ms"].get<int64_t>();
proposal.created_at = std::chrono::system_clock::time_point(
std::chrono::milliseconds(millis));
}
return proposal;
}
std::string Tile16ProposalGenerator::GenerateProposalId() const {