From 6d8423764d6fc07f7ccdd714061d32da5dacc31c Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 23 Apr 2026 11:51:49 +0200 Subject: [PATCH] feat(editor): writer surgical del bloc data: al YAML del tilemap --- CMakeLists.txt | 16 +++ source/game/map/tilemap_writer.cpp | 118 +++++++++++++++++++ source/game/map/tilemap_writer.hpp | 34 ++++++ tests/test_tilemap_writer.cpp | 177 +++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 source/game/map/tilemap_writer.cpp create mode 100644 source/game/map/tilemap_writer.hpp create mode 100644 tests/test_tilemap_writer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e17a016..90adcd8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,6 +92,7 @@ set(APP_SOURCES source/game/entities/player.cpp source/game/map/map.cpp source/game/map/tilemap.cpp + source/game/map/tilemap_writer.cpp ${ENTITY_SOURCES} # Fitxer només del binari principal: `loadFromLevel` depèn de # Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar @@ -327,4 +328,19 @@ if(BUILD_TESTS) add_test(NAME scene_flow COMMAND test_scene_flow WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + + # Smoke del writer surgical del tilemap (escriu el bloc `data:` sense + # tocar la resta del fitxer). Pura manipulació de text, zero + # dependències: no cal ni SDL ni el Resource::Cache. + add_executable(test_tilemap_writer + tests/test_tilemap_writer.cpp + source/game/map/tilemap_writer.cpp + ) + target_include_directories(test_tilemap_writer PRIVATE + "${CMAKE_SOURCE_DIR}/source" + ) + target_compile_options(test_tilemap_writer PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME tilemap_writer + COMMAND test_tilemap_writer + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) endif() diff --git a/source/game/map/tilemap_writer.cpp b/source/game/map/tilemap_writer.cpp new file mode 100644 index 0000000..9a5a61d --- /dev/null +++ b/source/game/map/tilemap_writer.cpp @@ -0,0 +1,118 @@ +#include "game/map/tilemap_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace TilemapWriter { + + namespace { + + // Retorna la posició de l'inici de la línia que conté "data:" a + // columna 0. La detecció requereix que els 5 caràcters siguin al + // principi d'una línia i que el següent siga un terminador típic + // (fi de línia, espai, tabulador o comentari). Així evitem trigger + // amb claus com "data_version:" o valors escalars que comencin + // amb "data:". + auto findDataLine(const std::string& content) -> std::size_t { + std::size_t pos = 0; + while (pos < content.size()) { + const bool AT_LINE_START = (pos == 0 || content[pos - 1] == '\n'); + if (AT_LINE_START && content.compare(pos, 5, "data:") == 0) { + const std::size_t AFTER = pos + 5; + if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') { + return pos; + } + } + const auto NL = content.find('\n', pos); + if (NL == std::string::npos) { break; } + pos = NL + 1; + } + return std::string::npos; + } + + // Avança mentre trobe línies indentades (espai/tab). S'atura al + // primer caràcter a columna 0 (nova clau top-level) o davant una + // línia buida (separadors de seccions). Així els comentaris i el + // blanc que precedeixen a la pròxima secció queden fora del bloc. + auto advancePastIndented(const std::string& content, std::size_t pos) -> std::size_t { + while (pos < content.size()) { + const char C = content[pos]; + if (C != ' ' && C != '\t') { break; } + const auto NL = content.find('\n', pos); + pos = (NL == std::string::npos) ? content.size() : NL + 1; + } + return pos; + } + + auto buildBlock(const std::vector>& data) -> std::string { + int max_width = 1; + for (const auto& row : data) { + for (const int V : row) { + const int W = static_cast(std::to_string(V).size()); + max_width = std::max(max_width, W); + } + } + + std::string out; + for (const auto& row : data) { + out += " - ["; + for (std::size_t i = 0; i < row.size(); ++i) { + if (i > 0) { out += ","; } + const auto S = std::to_string(row[i]); + const int PAD = max_width - static_cast(S.size()); + out.append(static_cast(PAD), ' '); + out += S; + } + out += "]\n"; + } + return out; + } + + } // namespace + + auto saveDataBlock(const std::string& path, const std::vector>& data) -> bool { + std::ifstream in(path, std::ios::binary); + if (!in) { return false; } + std::ostringstream ss; + ss << in.rdbuf(); + const std::string CONTENT = ss.str(); + in.close(); + + const auto DATA_POS = findDataLine(CONTENT); + if (DATA_POS == std::string::npos) { return false; } + + const auto LINE_END = CONTENT.find('\n', DATA_POS); + const auto AFTER_DATA = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END + 1; + const auto BLOCK_END = advancePastIndented(CONTENT, AFTER_DATA); + + const std::string NEW_CONTENT = + CONTENT.substr(0, AFTER_DATA) + buildBlock(data) + CONTENT.substr(BLOCK_END); + + const auto TMP_PATH = path + ".tmp"; + { + std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc); + if (!out) { return false; } + out.write(NEW_CONTENT.data(), static_cast(NEW_CONTENT.size())); + out.close(); + if (!out) { + std::error_code ec; + std::filesystem::remove(TMP_PATH, ec); + return false; + } + } + + std::error_code ec; + std::filesystem::rename(TMP_PATH, path, ec); + if (ec) { + std::filesystem::remove(TMP_PATH, ec); + return false; + } + return true; + } + +} // namespace TilemapWriter diff --git a/source/game/map/tilemap_writer.hpp b/source/game/map/tilemap_writer.hpp new file mode 100644 index 0000000..d0f76a9 --- /dev/null +++ b/source/game/map/tilemap_writer.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +// Reescriu NOMÉS el bloc `data:` d'un YAML del tilemap, preservant byte a +// byte la resta del fitxer: comentaris, ordre de claus, format de +// `geometry:` i `entities:`, encoding i line endings entre línies no +// tocades. Pensat exclusivament per a l'editor (Debug): només la graella +// canvia durant l'edició; el preàmbul i l'epíleg mai. +// +// Semàntica del bloc: +// - Línia de capçalera: primera aparició de "data:" a columna 0. +// - Contingut: línies següents indentades (espai o tab). Una línia buida +// o una línia a columna 0 marquen la fi del bloc. +// +// Format de sortida: +// - Cada fila en flow style: ` - [v0, v1, v2, ...]`, valors separats +// per `,` i alineats a dreta dins de `max_width` caràcters (calculat +// a partir del valor més llarg de la graella), cadascun precedit +// d'espais de farciment per mantenir columnes visuals. +// - Line endings: '\n' al bloc generat (el preàmbul i l'epíleg es +// copien byte a byte, així que no els toquem). +// +// Escriptura atòmica: via `.tmp` + `std::filesystem::rename`, així +// un crash a mitja escriptura mai deixa el YAML corrupte. +// +// Retorna false si el fitxer no es pot obrir, no conté "data:" a columna +// 0, o l'escriptura/renombrament atòmic falla. +namespace TilemapWriter { + + auto saveDataBlock(const std::string& path, const std::vector>& data) -> bool; + +} // namespace TilemapWriter diff --git a/tests/test_tilemap_writer.cpp b/tests/test_tilemap_writer.cpp new file mode 100644 index 0000000..2efad68 --- /dev/null +++ b/tests/test_tilemap_writer.cpp @@ -0,0 +1,177 @@ +// Smoke test per a TilemapWriter::saveDataBlock. Stand-alone: no obre +// finestra, no necessita SDL. Comprova que: +// +// T1 round-trip pur — escriure el mateix `data:` no canvia el fitxer. +// T2 modificació — canviar un tile només toca el bloc `data:` i la +// resta del fitxer queda byte a byte idèntica. +// T3 format — el bloc generat respecta ` - [v0, v1, ...]` amb +// alineació a dreta (màx. amplada) i `]\n` final. +// T4 error path — fitxer sense `data:` a columna 0 → retorna false +// i no escriu. +// +// Execució: `./build/test_tilemap_writer` o via `ctest`. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "game/map/tilemap_writer.hpp" + +namespace { + + // El fixture usa max_width=3 (valor 230) perquè coincideixi amb el format + // que el writer emet per a la mateixa graella: així T1 pot comparar byte + // a byte el fitxer original amb el reescrit amb la mateixa data. + constexpr const char* FIXTURE = + "# Capçalera amb comentaris\n" + "tileset: data/tilesets/tomb.gif\n" + "tile_size: 16\n" + "origin: [-8, 0]\n" + "\n" + "# Bloc geometry preservat\n" + "geometry:\n" + " grid_cols: 4\n" + " grid_rows: 5\n" + "\n" + "data:\n" + " - [ 1, 2, 3]\n" + " - [ 4,230, 6]\n" + " - [ 7, 8, 9]\n" + "\n" + "# Epíleg amb comentari\n" + "entities:\n" + " - type: torch\n" + " position: [4, 2]\n"; + + auto readFile(const std::string& path) -> std::string { + std::ifstream in(path, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } + + void writeFile(const std::string& path, const std::string& content) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(content.data(), static_cast(content.size())); + } + + auto tmpPath(const char* name) -> std::string { + auto p = std::filesystem::temp_directory_path() / name; + return p.string(); + } + + // Extrau el substring entre "data:\n" i la pròxima línia buida o EOF. + // Útil per aïllar el bloc `data:` i comparar preàmbul/epíleg a banda. + auto splitAroundData(const std::string& content) -> std::tuple { + const auto DATA_LINE = content.find("\ndata:\n"); + assert(DATA_LINE != std::string::npos); + const auto DATA_START = DATA_LINE + std::string("\ndata:\n").size(); + auto block_end = DATA_START; + while (block_end < content.size()) { + const char C = content[block_end]; + if (C != ' ' && C != '\t') { break; } + const auto NL = content.find('\n', block_end); + block_end = (NL == std::string::npos) ? content.size() : NL + 1; + } + return { + content.substr(0, DATA_START), + content.substr(DATA_START, block_end - DATA_START), + content.substr(block_end), + }; + } + + void T1_roundTrip() { + const auto PATH = tmpPath("aee_test_tw_t1.yaml"); + writeFile(PATH, FIXTURE); + + const std::vector> data = { + {1, 2, 3}, {4, 230, 6}, {7, 8, 9} + }; + const bool OK = TilemapWriter::saveDataBlock(PATH, data); + assert(OK); + + const auto AFTER = readFile(PATH); + assert(AFTER == FIXTURE); + std::filesystem::remove(PATH); + std::printf("T1 round-trip: OK\n"); + } + + void T2_modificacio() { + const auto PATH = tmpPath("aee_test_tw_t2.yaml"); + writeFile(PATH, FIXTURE); + + // Dades distintes al fixture però mantenint max_width=3 (pel 231). + const std::vector> data = { + {10, 20, 30}, {40, 231, 60}, {70, 80, 90} + }; + const bool OK = TilemapWriter::saveDataBlock(PATH, data); + assert(OK); + + const auto AFTER = readFile(PATH); + const auto [PRE_BEFORE, DATA_BEFORE, POST_BEFORE] = splitAroundData(FIXTURE); + const auto [PRE_AFTER, DATA_AFTER, POST_AFTER] = splitAroundData(AFTER); + + // Preàmbul i epíleg byte a byte idèntics. + assert(PRE_BEFORE == PRE_AFTER); + assert(POST_BEFORE == POST_AFTER); + // El bloc data ha canviat. + assert(DATA_BEFORE != DATA_AFTER); + // Valor nou present. + assert(AFTER.find("231") != std::string::npos); + std::filesystem::remove(PATH); + std::printf("T2 modificació: OK\n"); + } + + void T3_format() { + const auto PATH = tmpPath("aee_test_tw_t3.yaml"); + writeFile(PATH, FIXTURE); + + // max_width=3 → valors a 3 chars, separats per ','. + const std::vector> data = { + {1, 2, 3}, + {230, 1, 1}, + {1, 1, 1} + }; + const bool OK = TilemapWriter::saveDataBlock(PATH, data); + assert(OK); + const auto AFTER = readFile(PATH); + + // Primera fila: " - [ 1, 2, 3]\n" + assert(AFTER.find(" - [ 1, 2, 3]\n") != std::string::npos); + // Fila amb valor de 3 chars: " - [230, 1, 1]\n" + assert(AFTER.find(" - [230, 1, 1]\n") != std::string::npos); + std::filesystem::remove(PATH); + std::printf("T3 format: OK\n"); + } + + void T4_errorPath() { + const auto PATH = tmpPath("aee_test_tw_t4.yaml"); + // Fitxer sense "data:" a columna 0. + writeFile(PATH, "tileset: x\ntile_size: 16\n# sense data\n"); + const auto BEFORE = readFile(PATH); + + const std::vector> data = {{1, 2, 3}}; + const bool OK = TilemapWriter::saveDataBlock(PATH, data); + assert(!OK); + + const auto AFTER = readFile(PATH); + assert(AFTER == BEFORE); // fitxer intacte + std::filesystem::remove(PATH); + std::printf("T4 error path: OK\n"); + } + +} // namespace + +auto main() -> int { + T1_roundTrip(); + T2_modificacio(); + T3_format(); + T4_errorPath(); + std::printf("tilemap_writer: tots els tests OK\n"); + return 0; +}