647 lines
30 KiB
C++
647 lines
30 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include "pixelworld/PixelWorld.h"
|
|
#include "pixelworld/PixelMaterialCatalogCodec.h"
|
|
|
|
#include <algorithm>
|
|
#include <cstddef>
|
|
#include <cstring>
|
|
#include <limits>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
using namespace eve::pixelworld;
|
|
|
|
namespace {
|
|
|
|
std::vector<std::byte> withoutThermalRemainders(const std::vector<std::byte>& current) {
|
|
REQUIRE(current.size() >= 50);
|
|
std::uint32_t chunks = 0;
|
|
std::memcpy(&chunks, current.data() + 46, sizeof(chunks));
|
|
std::vector<std::byte> legacy(current.begin(), current.begin() + 50);
|
|
std::size_t cursor = 50;
|
|
for (std::uint32_t chunk = 0; chunk < chunks; ++chunk) {
|
|
REQUIRE(current.size() - cursor >= 8);
|
|
legacy.insert(legacy.end(), current.begin() + std::ptrdiff_t(cursor),
|
|
current.begin() + std::ptrdiff_t(cursor + 8));
|
|
cursor += 8;
|
|
for (int cell = 0; cell < kPixelChunkSize * kPixelChunkSize; ++cell) {
|
|
REQUIRE(current.size() - cursor >= 7);
|
|
legacy.insert(legacy.end(), current.begin() + std::ptrdiff_t(cursor),
|
|
current.begin() + std::ptrdiff_t(cursor + 5));
|
|
cursor += 7;
|
|
}
|
|
}
|
|
REQUIRE_EQ(cursor, current.size());
|
|
return legacy;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("pixelworld.sand_falls_and_stops_on_stone") {
|
|
PixelWorld world(7);
|
|
for (int x = -2; x <= 2; ++x) world.setMaterial(x, 4, "stone");
|
|
world.setMaterial(0, 0, "sand");
|
|
for (std::uint64_t tick = 1; tick <= 6; ++tick) {
|
|
auto result = world.advance(eve::SimulationTick(tick));
|
|
REQUIRE(result.ok());
|
|
}
|
|
CHECK_EQ(world.getMaterial(0, 3), int(MaterialId::Sand));
|
|
CHECK_EQ(world.getMaterial(0, 4), int(MaterialId::Stone));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.cross_chunk_motion_is_conservative") {
|
|
PixelWorld world(11);
|
|
for (int x = 61; x <= 65; ++x) world.setMaterial(x, 65, "stone");
|
|
world.setMaterial(63, 63, "sand");
|
|
for (std::uint64_t tick = 1; tick <= 4; ++tick)
|
|
world.advance(eve::SimulationTick(tick)).expect("cross-chunk step");
|
|
CHECK_EQ(world.getMaterial(63, 64), int(MaterialId::Sand));
|
|
CHECK_EQ(world.getMaterial(63, 65), int(MaterialId::Stone));
|
|
CHECK(world.chunkCount() >= 2);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.fire_converts_water_to_steam") {
|
|
PixelWorld world(3);
|
|
world.setMaterial(10, 10, "fire");
|
|
world.setMaterial(11, 10, "water");
|
|
world.setMaterial(10, 11, "stone");
|
|
world.setMaterial(11, 11, "stone");
|
|
world.setMaterial(12, 10, "stone");
|
|
world.setMaterial(12, 11, "stone");
|
|
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("reaction step");
|
|
CHECK(stats.reactions > 0);
|
|
const bool steamPresent = world.getMaterial(11, 10) == int(MaterialId::Steam) ||
|
|
world.getMaterial(11, 9) == int(MaterialId::Steam);
|
|
CHECK(steamPresent);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.fixed_seed_and_ticks_are_deterministic") {
|
|
PixelWorld a(99), b(99);
|
|
for (int x = -8; x <= 8; ++x) {
|
|
a.setMaterial(x, 20, "stone");
|
|
b.setMaterial(x, 20, "stone");
|
|
}
|
|
a.paintCircle(0, 2, 6, "sand");
|
|
b.paintCircle(0, 2, 6, "sand");
|
|
a.paintCircle(3, 0, 3, "water");
|
|
b.paintCircle(3, 0, 3, "water");
|
|
for (std::uint64_t tick = 1; tick <= 30; ++tick) {
|
|
a.advance(eve::SimulationTick(tick)).expect("determinism a");
|
|
b.advance(eve::SimulationTick(tick)).expect("determinism b");
|
|
}
|
|
auto aBytes = a.saveSnapshot().expect("snapshot a");
|
|
auto bBytes = b.saveSnapshot().expect("snapshot b");
|
|
REQUIRE_EQ(aBytes.size(), bBytes.size());
|
|
CHECK(std::equal(aBytes.begin(), aBytes.end(), bBytes.begin()));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.snapshot_restore_is_transactional") {
|
|
PixelWorld source(123);
|
|
source.paintCircle(65, -2, 4, "oil");
|
|
source.step();
|
|
const auto bytes = source.saveSnapshot().expect("save source");
|
|
|
|
PixelWorld restored(1);
|
|
restored.restoreSnapshot(bytes).expect("restore source");
|
|
CHECK_EQ(restored.seed(), source.seed());
|
|
CHECK_EQ(restored.tickValue(), source.tickValue());
|
|
CHECK_EQ(restored.getMaterial(65, -1), source.getMaterial(65, -1));
|
|
|
|
std::vector<std::byte> malformed = bytes;
|
|
malformed.pop_back();
|
|
auto failed = restored.restoreSnapshot(malformed);
|
|
CHECK(!failed.ok());
|
|
CHECK_EQ(restored.seed(), source.seed());
|
|
CHECK_EQ(restored.tickValue(), source.tickValue());
|
|
}
|
|
|
|
TEST_CASE("pixelworld.rejects_non_monotonic_tick") {
|
|
PixelWorld world;
|
|
world.advance(eve::SimulationTick(2)).expect("first step");
|
|
auto rejected = world.advance(eve::SimulationTick(2));
|
|
CHECK(!rejected.ok());
|
|
CHECK_EQ(rejected.error()->code(), eve::DiagnosticCode::PreconditionViolation);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.changed_chunk_snapshots_are_owned_canonical_and_incremental") {
|
|
PixelWorld world(17);
|
|
world.setMaterial(70, 2, "sand");
|
|
world.setMaterial(-2, 2, "water");
|
|
const std::uint64_t baseline = world.revision();
|
|
|
|
auto initial = world.snapshotChangedChunks(0);
|
|
REQUIRE_EQ(initial.size(), std::size_t(2));
|
|
CHECK(initial[0].x < initial[1].x);
|
|
CHECK_EQ(initial[0].cells.size(), std::size_t(kPixelChunkSize * kPixelChunkSize));
|
|
const PixelCell ownedCell = initial[0].cells[2 * kPixelChunkSize + 62];
|
|
|
|
world.setMaterial(70, 3, "stone");
|
|
auto incremental = world.snapshotChangedChunks(baseline);
|
|
REQUIRE_EQ(incremental.size(), std::size_t(1));
|
|
CHECK_EQ(incremental[0].x, 1);
|
|
CHECK_EQ(initial[0].cells[2 * kPixelChunkSize + 62], ownedCell);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.revision_tracks_lifetime_only_changes") {
|
|
PixelWorld world(23);
|
|
world.setMaterial(0, 0, "fire");
|
|
const std::uint64_t before = world.revision();
|
|
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("fire lifetime step");
|
|
CHECK(stats.cellsChanged > 0);
|
|
CHECK(world.revision() > before);
|
|
const auto changed = world.snapshotChangedChunks(before);
|
|
CHECK(!changed.empty());
|
|
CHECK(std::all_of(changed.begin(), changed.end(), [before](const PixelChunkSnapshot& chunk) {
|
|
return chunk.revision > before;
|
|
}));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.material_catalog_validates_names_and_drives_custom_reactions") {
|
|
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
|
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(), builtIn.definitions().end());
|
|
const MaterialId brine = MaterialId(definitions.size());
|
|
definitions.push_back(
|
|
{brine, "brine", MaterialState::Liquid, 110, 35, 30, 90, 32767, 32767, 120});
|
|
std::vector<MaterialReactionRule> reactions{
|
|
{"brine.stone.hot-only", brine, MaterialId::Stone, MaterialId::Fire, MaterialId::Lava,
|
|
1000, 0, 200},
|
|
{"brine.stone.erode", brine, MaterialId::Stone, MaterialId::Water, MaterialId::Sand,
|
|
-32768, 5, 100},
|
|
};
|
|
MaterialCatalog catalog =
|
|
MaterialCatalog::create(std::move(definitions), std::move(reactions)).expect("custom catalog");
|
|
PixelWorld world(41, catalog);
|
|
|
|
world.setMaterialChecked(10, 10, "brine").expect("place brine");
|
|
world.setMaterial(11, 10, "stone");
|
|
world.setMaterial(9, 10, "stone");
|
|
world.setMaterial(10, 11, "stone");
|
|
world.setMaterial(9, 11, "stone");
|
|
world.setMaterial(11, 11, "stone");
|
|
const std::uint64_t beforeUnknown = world.revision();
|
|
auto unknown = world.setMaterialChecked(0, 0, "typo-material");
|
|
CHECK(!unknown.ok());
|
|
CHECK_EQ(unknown.error()->code(), eve::DiagnosticCode::NotFound);
|
|
CHECK_EQ(world.revision(), beforeUnknown);
|
|
|
|
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("custom reaction step");
|
|
CHECK(stats.reactions > 0);
|
|
const int center = world.getMaterial(10, 10);
|
|
const bool adjacentSand = world.getMaterial(9, 10) == int(MaterialId::Sand) ||
|
|
world.getMaterial(11, 10) == int(MaterialId::Sand) ||
|
|
world.getMaterial(10, 11) == int(MaterialId::Sand);
|
|
const bool adjacentWater = world.getMaterial(9, 10) == int(MaterialId::Water) ||
|
|
world.getMaterial(11, 10) == int(MaterialId::Water) ||
|
|
world.getMaterial(10, 11) == int(MaterialId::Water);
|
|
const bool productsPresent = (center == int(MaterialId::Water) && adjacentSand) ||
|
|
(center == int(MaterialId::Sand) && adjacentWater);
|
|
CHECK(productsPresent);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.material_catalog_owns_color_and_canonical_tags") {
|
|
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
|
CHECK_EQ(builtIn.definition(MaterialId::Water).displayRgba, std::uint32_t(0x1F61F2DBU));
|
|
PixelWorld world(42, builtIn);
|
|
CHECK_EQ(world.materialDisplayRgba(MaterialId::Acid), std::uint32_t(0x4DEB3DE0U));
|
|
|
|
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
|
|
builtIn.definitions().end());
|
|
definitions[std::size_t(MaterialId::Sand)].tags = {"mobile", "granular"};
|
|
MaterialCatalog canonical = MaterialCatalog::create(std::move(definitions)).expect("sorted tags");
|
|
const auto& tags = canonical.definition(MaterialId::Sand).tags;
|
|
REQUIRE_EQ(tags.size(), std::size_t(2));
|
|
CHECK_EQ(tags[0], std::string("granular"));
|
|
CHECK_EQ(tags[1], std::string("mobile"));
|
|
|
|
std::vector<MaterialDefinition> invalid(canonical.definitions().begin(),
|
|
canonical.definitions().end());
|
|
invalid[std::size_t(MaterialId::Sand)].tags = {"mobile", "mobile"};
|
|
CHECK(!MaterialCatalog::create(std::move(invalid)).ok());
|
|
}
|
|
|
|
TEST_CASE("pixelworld.material_catalog_json_roundtrips_and_rejects_unknown_schema") {
|
|
const MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
|
const std::string json = encodeMaterialCatalogJson(builtIn).expect("encode Catalog JSON");
|
|
const MaterialCatalog decoded = decodeMaterialCatalogJson(json).expect("decode Catalog JSON");
|
|
CHECK_EQ(decoded.fingerprint(), builtIn.fingerprint());
|
|
CHECK_EQ(decoded.definition(MaterialId::Fire).displayRgba,
|
|
builtIn.definition(MaterialId::Fire).displayRgba);
|
|
CHECK_EQ(decoded.reactions().size(), builtIn.reactions().size());
|
|
CHECK_EQ(decoded.phaseRules().size(), builtIn.phaseRules().size());
|
|
|
|
std::string unknownField = json;
|
|
unknownField.insert(unknownField.find('{') + 1, "\"unknown\":1,");
|
|
CHECK(!decodeMaterialCatalogJson(unknownField).ok());
|
|
std::string unknownVersion = json;
|
|
const auto version = unknownVersion.find("\"version\": 1");
|
|
REQUIRE(version != std::string::npos);
|
|
unknownVersion.replace(version, std::string("\"version\": 1").size(), "\"version\": 2");
|
|
CHECK(!decodeMaterialCatalogJson(unknownVersion).ok());
|
|
CHECK(!decodeMaterialCatalogJson("{broken").ok());
|
|
CHECK(!decodeMaterialCatalogJson(
|
|
R"({"schema":"eve.pixelworld.material-catalog","version":1,"materials":{},"reactions":[],"phaseRules":[]})")
|
|
.ok());
|
|
CHECK(!decodeMaterialCatalogJson(
|
|
R"({"schema":"eve.pixelworld.material-catalog","version":1,"materials":[7],"reactions":[],"phaseRules":[]})")
|
|
.ok());
|
|
std::string overflowingId = json;
|
|
const auto firstId = overflowingId.find("\"id\": 0");
|
|
REQUIRE(firstId != std::string::npos);
|
|
overflowingId.replace(firstId, std::string("\"id\": 0").size(), "\"id\": 70000");
|
|
CHECK(!decodeMaterialCatalogJson(overflowingId).ok());
|
|
}
|
|
|
|
TEST_CASE("pixelworld.material_catalog_hot_reload_is_paused_compatible_and_transactional") {
|
|
PixelWorld world(43);
|
|
world.setMaterial(1, 1, "water");
|
|
const auto originalLink = world.worldLink();
|
|
const std::uint64_t originalFingerprint = world.materialCatalogFingerprint();
|
|
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
|
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
|
|
builtIn.definitions().end());
|
|
definitions[std::size_t(MaterialId::Water)].displayRgba = 0x12345678U;
|
|
definitions[std::size_t(MaterialId::Water)].viscosity = 31;
|
|
definitions[std::size_t(MaterialId::Water)].density = 60;
|
|
MaterialCatalog replacement = MaterialCatalog::create(
|
|
std::move(definitions),
|
|
{builtIn.reactions().begin(), builtIn.reactions().end()},
|
|
{builtIn.phaseRules().begin(), builtIn.phaseRules().end()})
|
|
.expect("compatible Catalog");
|
|
const std::string replacementJson =
|
|
encodeMaterialCatalogJson(replacement).expect("encode replacement Catalog");
|
|
|
|
CHECK(!world.reloadMaterialCatalog(replacement, originalFingerprint).ok());
|
|
CHECK_EQ(world.materialCatalogFingerprint(), originalFingerprint);
|
|
world.setPaused(true);
|
|
CHECK(!world.reloadMaterialCatalog(replacement, originalFingerprint + 1).ok());
|
|
CHECK_EQ(world.materialCatalogFingerprint(), originalFingerprint);
|
|
const auto receipt = world.reloadMaterialCatalog(std::move(replacement), originalFingerprint)
|
|
.expect("paused Catalog reload");
|
|
CHECK_EQ(receipt.fingerprintBefore, originalFingerprint);
|
|
CHECK(receipt.fingerprintAfter != originalFingerprint);
|
|
CHECK_EQ(receipt.chunksRebuilt, std::uint32_t(1));
|
|
CHECK(receipt.replayHistoryInvalidated);
|
|
CHECK(world.worldLink().epoch > originalLink.epoch);
|
|
CHECK_EQ(world.materialDisplayRgba(MaterialId::Water), std::uint32_t(0x12345678U));
|
|
CHECK_EQ(world.getMaterial(1, 1), int(MaterialId::Water));
|
|
|
|
world.setMaterial(1, 2, "oil");
|
|
world.setMaterial(0, 1, "stone");
|
|
world.setMaterial(2, 1, "stone");
|
|
world.setMaterial(0, 2, "stone");
|
|
world.setMaterial(2, 2, "stone");
|
|
world.setPaused(false);
|
|
world.advance(eve::SimulationTick(1)).expect("advance with rebuilt displacement table");
|
|
CHECK_EQ(world.getMaterial(1, 1), int(MaterialId::Water));
|
|
CHECK_EQ(world.getMaterial(1, 2), int(MaterialId::Oil));
|
|
world.setPaused(true);
|
|
|
|
const std::uint64_t revisionBeforeNoOp = world.revision();
|
|
const auto noOp = world.reloadMaterialCatalog(
|
|
decodeMaterialCatalogJson(replacementJson)
|
|
.expect("decode replacement Catalog"),
|
|
world.materialCatalogFingerprint())
|
|
.expect("same Catalog is a no-op");
|
|
CHECK_EQ(noOp.revisionBefore, revisionBeforeNoOp);
|
|
CHECK_EQ(noOp.revisionAfter, revisionBeforeNoOp);
|
|
CHECK_EQ(noOp.worldEpoch, world.worldLink().epoch);
|
|
CHECK(!noOp.replayHistoryInvalidated);
|
|
|
|
const MaterialCatalog incompatibleBase = MaterialCatalog::builtIn();
|
|
std::vector<MaterialDefinition> incompatible(incompatibleBase.definitions().begin(),
|
|
incompatibleBase.definitions().end());
|
|
incompatible[std::size_t(MaterialId::Water)].name = "renamed-water";
|
|
auto bad = MaterialCatalog::create(std::move(incompatible)).expect("valid but incompatible");
|
|
const std::uint64_t revision = world.revision();
|
|
CHECK(!world.reloadMaterialCatalog(std::move(bad), world.materialCatalogFingerprint()).ok());
|
|
CHECK_EQ(world.revision(), revision);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.snapshot_rejects_material_catalog_mismatch_transactionally") {
|
|
PixelWorld builtInWorld(5);
|
|
builtInWorld.setMaterial(0, 0, "sand");
|
|
const auto bytes = builtInWorld.saveSnapshot().expect("built-in snapshot");
|
|
|
|
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
|
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(), builtIn.definitions().end());
|
|
const MaterialId custom = MaterialId(definitions.size());
|
|
definitions.push_back({custom, "custom", MaterialState::Powder, 150, 20, 10, 50});
|
|
PixelWorld customWorld(
|
|
9, MaterialCatalog::create(std::move(definitions)).expect("catalog with custom material"));
|
|
customWorld.setMaterialChecked(3, 3, "custom").expect("place custom material");
|
|
const std::uint64_t revisionBefore = customWorld.revision();
|
|
|
|
auto rejected = customWorld.restoreSnapshot(bytes);
|
|
CHECK(!rejected.ok());
|
|
CHECK_EQ(customWorld.revision(), revisionBefore);
|
|
CHECK_EQ(customWorld.getMaterial(3, 3), int(custom));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.snapshot_v1_migrates_only_with_builtin_catalog") {
|
|
PixelWorld source(77);
|
|
source.setMaterial(4, 5, "water");
|
|
std::vector<std::byte> legacy =
|
|
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
|
|
REQUIRE(legacy.size() > 14);
|
|
legacy[4] = std::byte{1};
|
|
legacy[5] = std::byte{0};
|
|
legacy.erase(legacy.begin() + 38, legacy.begin() + 46);
|
|
legacy.erase(legacy.begin() + 6, legacy.begin() + 14);
|
|
|
|
PixelWorld restored(1);
|
|
restored.restoreSnapshot(legacy).expect("v1 migration");
|
|
CHECK_EQ(restored.seed(), std::uint64_t(77));
|
|
CHECK_EQ(restored.getMaterial(4, 5), int(MaterialId::Water));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.snapshot_v2_migrates_with_zero_edit_sequence") {
|
|
PixelWorld source(78);
|
|
source.setMaterial(2, 3, "sand");
|
|
std::vector<std::byte> version2 =
|
|
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
|
|
REQUIRE(version2.size() > 46);
|
|
version2[4] = std::byte{2};
|
|
version2[5] = std::byte{0};
|
|
version2.erase(version2.begin() + 38, version2.begin() + 46);
|
|
|
|
PixelWorld restored(1);
|
|
restored.restoreSnapshot(version2).expect("v2 migration");
|
|
CHECK_EQ(restored.getMaterial(2, 3), int(MaterialId::Sand));
|
|
CHECK_EQ(restored.lastEditSequence(), std::uint64_t(0));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.snapshot_v3_migrates_with_zero_thermal_remainder") {
|
|
PixelWorld source(79);
|
|
source.setCell(2, 3, {MaterialId::Stone, 123, 0, 0});
|
|
std::vector<std::byte> version3 =
|
|
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
|
|
version3[4] = std::byte{3};
|
|
version3[5] = std::byte{0};
|
|
|
|
PixelWorld restored(1);
|
|
restored.restoreSnapshot(version3).expect("v3 migration");
|
|
CHECK_EQ(restored.getCell(2, 3).temperature, std::int16_t(123));
|
|
CHECK_EQ(restored.getCell(2, 3).thermalRemainder, std::uint16_t(0));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.fixed_point_heat_diffusion_is_directional_and_revisioned") {
|
|
PixelWorld world(88);
|
|
world.setCell(0, 0, {MaterialId::Stone, 1000, 0});
|
|
world.setCell(1, 0, {MaterialId::Stone, 0, 0});
|
|
const std::uint64_t before = world.revision();
|
|
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("thermal step");
|
|
CHECK(stats.temperatureTransfers > 0);
|
|
CHECK(world.getCell(0, 0).temperature < 1000);
|
|
CHECK(world.getCell(1, 0).temperature > 0);
|
|
CHECK(world.revision() > before);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.thermal_remainders_conserve_energy_and_roundtrip_authority") {
|
|
const MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
|
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
|
|
builtIn.definitions().end());
|
|
definitions[std::size_t(MaterialId::Stone)].heatCapacity = 7;
|
|
definitions[std::size_t(MaterialId::Stone)].thermalConductivity = 13;
|
|
definitions[std::size_t(MaterialId::Wood)].heatCapacity = 11;
|
|
definitions[std::size_t(MaterialId::Wood)].thermalConductivity = 13;
|
|
const MaterialCatalog catalog =
|
|
MaterialCatalog::create(std::move(definitions)).expect("thermal test Catalog");
|
|
PixelWorld world(8801, catalog);
|
|
world.setCell(0, 0, {MaterialId::Stone, 1000, 0, 3});
|
|
world.setCell(1, 0, {MaterialId::Wood, -200, 0, 5});
|
|
const auto energy = [&](const PixelWorld& value) {
|
|
const PixelCell stone = value.getCell(0, 0);
|
|
const PixelCell wood = value.getCell(1, 0);
|
|
return std::int64_t(stone.temperature) * 7 + stone.thermalRemainder +
|
|
std::int64_t(wood.temperature) * 11 + wood.thermalRemainder;
|
|
};
|
|
const std::int64_t initialEnergy = energy(world);
|
|
std::uint64_t transferred = 0;
|
|
for (std::uint64_t tick = 1; tick <= 200; ++tick) {
|
|
const auto stats = world.advance(eve::SimulationTick(tick)).expect("conservative heat step");
|
|
transferred += stats.thermalEnergyTransferred;
|
|
CHECK_EQ(stats.thermalEnergyClamped, std::uint64_t(0));
|
|
CHECK_EQ(energy(world), initialEnergy);
|
|
}
|
|
CHECK(transferred > 0);
|
|
CHECK(world.getCell(0, 0).thermalRemainder < 7);
|
|
CHECK(world.getCell(1, 0).thermalRemainder < 11);
|
|
|
|
const auto snapshot = world.saveSnapshot().expect("thermal v4 snapshot");
|
|
PixelWorld restored(2, catalog);
|
|
restored.restoreSnapshot(snapshot).expect("restore thermal v4 snapshot");
|
|
CHECK_EQ(restored.getCell(0, 0), world.getCell(0, 0));
|
|
CHECK_EQ(restored.getCell(1, 0), world.getCell(1, 0));
|
|
|
|
auto malformed = snapshot;
|
|
REQUIRE(malformed.size() >= 67);
|
|
const std::uint16_t invalidRemainder = std::numeric_limits<std::uint16_t>::max();
|
|
std::memcpy(malformed.data() + 63, &invalidRemainder, sizeof(invalidRemainder));
|
|
const auto beforeRejectedRestore = restored.saveSnapshot().expect("before bad remainder");
|
|
CHECK(!restored.restoreSnapshot(malformed).ok());
|
|
const auto afterRejectedRestore = restored.saveSnapshot().expect("after bad remainder");
|
|
CHECK(std::equal(beforeRejectedRestore.begin(), beforeRejectedRestore.end(),
|
|
afterRejectedRestore.begin()));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.phase_rules_freeze_boil_and_melt_without_hardcoded_branches") {
|
|
PixelWorld world(89);
|
|
world.setCell(10, 10, {MaterialId::Water, -20, 0});
|
|
world.setCell(20, 10, {MaterialId::Water, 130, 0});
|
|
world.setCell(30, 10, {MaterialId::Ice, 20, 0});
|
|
for (const int center : {10, 20}) {
|
|
world.setMaterial(center - 1, 10, "stone");
|
|
world.setMaterial(center + 1, 10, "stone");
|
|
world.setMaterial(center - 1, 11, "stone");
|
|
world.setMaterial(center, 11, "stone");
|
|
world.setMaterial(center + 1, 11, "stone");
|
|
}
|
|
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("phase step");
|
|
CHECK(stats.phaseChanges >= 3);
|
|
CHECK_EQ(world.getMaterial(10, 10), int(MaterialId::Ice));
|
|
CHECK_EQ(world.getMaterial(20, 10), int(MaterialId::Steam));
|
|
CHECK_EQ(world.getMaterial(30, 10), int(MaterialId::Water));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.explosion_uses_resistance_heat_and_atomic_sequence_receipt") {
|
|
PixelWorld world(90);
|
|
world.setMaterial(0, 0, "wood");
|
|
world.setMaterial(1, 0, "stone");
|
|
world.setMaterial(-1, 0, "gunpowder");
|
|
PixelEditCommand command;
|
|
command.sequence = 1;
|
|
command.kind = PixelEditKind::Explosion;
|
|
command.radius = 4;
|
|
command.strength = 120;
|
|
command.temperatureDelta = 400;
|
|
const PixelEditReceipt receipt = world.applyEdit(command).expect("explosion command");
|
|
|
|
CHECK_EQ(receipt.sequence, std::uint64_t(1));
|
|
CHECK(receipt.cellsRemoved >= 2);
|
|
CHECK_EQ(world.getMaterial(0, 0), int(MaterialId::Air));
|
|
CHECK_EQ(world.getMaterial(-1, 0), int(MaterialId::Air));
|
|
CHECK_EQ(world.getMaterial(1, 0), int(MaterialId::Stone));
|
|
CHECK(world.getCell(1, 0).temperature > 20);
|
|
CHECK_EQ(world.lastEditSequence(), std::uint64_t(1));
|
|
CHECK(receipt.revisionAfter > receipt.revisionBefore);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.edit_sequence_gap_rejects_without_partial_mutation_and_roundtrips") {
|
|
PixelWorld world(91);
|
|
world.setMaterial(5, 5, "wood");
|
|
world.explode(5, 5, 2, 100, 100);
|
|
const auto before = world.saveSnapshot().expect("before rejected edit");
|
|
|
|
PixelEditCommand gap;
|
|
gap.sequence = 3;
|
|
gap.kind = PixelEditKind::HeatCircle;
|
|
gap.centerX = 5;
|
|
gap.centerY = 5;
|
|
gap.radius = 2;
|
|
gap.temperatureDelta = 500;
|
|
auto rejected = world.applyEdit(gap);
|
|
CHECK(!rejected.ok());
|
|
const auto after = world.saveSnapshot().expect("after rejected edit");
|
|
REQUIRE_EQ(before.size(), after.size());
|
|
CHECK(std::equal(before.begin(), before.end(), after.begin()));
|
|
|
|
PixelWorld restored(1);
|
|
restored.restoreSnapshot(before).expect("restore edit sequence");
|
|
CHECK_EQ(restored.lastEditSequence(), std::uint64_t(1));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.unsupported_solids_detach_canonically_and_rasterize_atomically") {
|
|
PixelWorld world(92);
|
|
for (int x = -2; x <= 2; ++x) world.setMaterial(x, 10, "stone");
|
|
world.setMaterial(-1, 2, "wood");
|
|
world.setMaterial(0, 2, "wood");
|
|
world.setMaterial(0, 3, "wood");
|
|
world.setMaterial(4, 9, "stone");
|
|
world.setMaterial(4, 10, "stone");
|
|
|
|
auto fragments = world.extractUnsupportedFragments({-4, 0, 6, 10}, 10, 2)
|
|
.expect("extract unsupported component");
|
|
REQUIRE_EQ(fragments.size(), std::size_t(1));
|
|
const PixelFragment& fragment = fragments.front();
|
|
CHECK_EQ(fragment.originX, -1);
|
|
CHECK_EQ(fragment.originY, 2);
|
|
CHECK_EQ(fragment.width, 2);
|
|
CHECK_EQ(fragment.height, 2);
|
|
CHECK_EQ(fragment.solidCellCount, std::uint32_t(3));
|
|
CHECK_EQ(world.getMaterial(-1, 2), int(MaterialId::Air));
|
|
CHECK_EQ(world.getMaterial(0, 3), int(MaterialId::Air));
|
|
CHECK_EQ(world.getMaterial(4, 9), int(MaterialId::Stone));
|
|
|
|
world.setMaterial(20, 20, "stone");
|
|
const auto beforeConflict = world.saveSnapshot().expect("before fragment conflict");
|
|
auto conflict = world.rasterizeFragment(fragment, 20, 20);
|
|
CHECK(!conflict.ok());
|
|
const auto afterConflict = world.saveSnapshot().expect("after fragment conflict");
|
|
CHECK(std::equal(beforeConflict.begin(), beforeConflict.end(), afterConflict.begin()));
|
|
|
|
const auto receipt = world.rasterizeFragment(fragment, 30, 30).expect("rasterize fragment");
|
|
CHECK_EQ(receipt.cellsPlaced, std::uint32_t(3));
|
|
CHECK_EQ(world.getMaterial(30, 30), int(MaterialId::Wood));
|
|
CHECK_EQ(world.getMaterial(31, 31), int(MaterialId::Wood));
|
|
}
|
|
|
|
TEST_CASE("pixelworld.fragment_link_is_stale_after_restore_or_clear") {
|
|
PixelWorld world(93);
|
|
world.setMaterial(0, 0, "wood");
|
|
const auto checkpoint = world.saveSnapshot().expect("fragment checkpoint");
|
|
auto fragment = world.extractUnsupportedFragments({-1, -1, 1, 1}, 10)
|
|
.expect("extract before restore").front();
|
|
world.restoreSnapshot(checkpoint).expect("restore invalidates links");
|
|
CHECK(!world.rasterizeFragment(fragment, 5, 5).ok());
|
|
|
|
auto second = world.extractUnsupportedFragments({-1, -1, 1, 1}, 10)
|
|
.expect("extract before clear").front();
|
|
world.clear();
|
|
CHECK(!world.rasterizeFragment(second, 5, 5).ok());
|
|
}
|
|
|
|
TEST_CASE("pixelworld.empty_chunks_sleep_with_hysteresis_and_emit_removal_tombstones") {
|
|
PixelWorld world(94);
|
|
world.setMaterial(3, 4, "stone");
|
|
const auto populatedRevision = world.revision();
|
|
world.setMaterial(3, 4, "air");
|
|
CHECK_EQ(world.chunkCount(), 1);
|
|
|
|
for (std::uint64_t tick = 1; tick < kPixelSleepHysteresisTicks; ++tick) {
|
|
const auto stats = world.advance(eve::SimulationTick(tick)).expect("idle hysteresis tick");
|
|
CHECK_EQ(stats.chunksReclaimed, std::uint32_t(0));
|
|
CHECK_EQ(world.chunkCount(), 1);
|
|
}
|
|
const auto reclaimed = world.advance(eve::SimulationTick(kPixelSleepHysteresisTicks))
|
|
.expect("chunk reclaim tick");
|
|
CHECK_EQ(reclaimed.chunksReclaimed, std::uint32_t(1));
|
|
CHECK_EQ(world.chunkCount(), 0);
|
|
|
|
const auto changed = world.snapshotChangedChunks(populatedRevision);
|
|
REQUIRE_EQ(changed.size(), std::size_t(1));
|
|
CHECK(changed.front().removed);
|
|
CHECK(changed.front().cells.empty());
|
|
CHECK(changed.front().revision > populatedRevision);
|
|
}
|
|
|
|
TEST_CASE("pixelworld.authoritative_chunk_batch_replaces_and_removes_transactionally") {
|
|
PixelWorld authority(95);
|
|
authority.setMaterial(2, 3, "wood");
|
|
const std::uint64_t populatedRevision = authority.revision();
|
|
PixelChunkBatch populated;
|
|
populated.catalogFingerprint = authority.materialCatalogFingerprint();
|
|
populated.sourceSeed = authority.seed();
|
|
populated.sourceRevision = populatedRevision;
|
|
populated.sourceTick = eve::SimulationTick(authority.tickValue());
|
|
populated.sourceLastEditSequence = authority.lastEditSequence();
|
|
populated.chunks = authority.snapshotChangedChunks(0);
|
|
|
|
PixelWorld replica(95);
|
|
const auto applied = replica.applyChunkBatch(populated, 0).expect("apply populated Chunk");
|
|
CHECK_EQ(applied.chunksReplaced, std::uint32_t(1));
|
|
CHECK_EQ(replica.getMaterial(2, 3), int(MaterialId::Wood));
|
|
CHECK_EQ(replica.revision(), authority.revision());
|
|
|
|
auto fragment = replica.extractUnsupportedFragments({0, 0, 5, 5}, 20)
|
|
.expect("extract replica fragment")
|
|
.front();
|
|
authority.setMaterial(2, 3, "air");
|
|
for (std::uint64_t tick = 1; tick <= kPixelSleepHysteresisTicks; ++tick)
|
|
authority.advance(eve::SimulationTick(tick)).expect("reclaim authority Chunk");
|
|
PixelChunkBatch removed;
|
|
removed.catalogFingerprint = authority.materialCatalogFingerprint();
|
|
removed.sourceSeed = authority.seed();
|
|
removed.sourceRevision = authority.revision();
|
|
removed.sourceTick = eve::SimulationTick(authority.tickValue());
|
|
removed.sourceLastEditSequence = authority.lastEditSequence();
|
|
removed.chunks = authority.snapshotChangedChunks(populatedRevision);
|
|
const auto removedReceipt = replica.applyChunkBatch(removed, replica.revision()).expect("apply tombstone");
|
|
CHECK_EQ(removedReceipt.chunksRemoved, std::uint32_t(1));
|
|
CHECK_EQ(replica.chunkCount(), 0);
|
|
CHECK(!replica.rasterizeFragment(fragment, 10, 10).ok());
|
|
}
|
|
|
|
TEST_CASE("pixelworld.authoritative_chunk_batch_rejects_stale_or_malformed_without_mutation") {
|
|
PixelWorld world(96);
|
|
world.setMaterial(0, 0, "stone");
|
|
const auto before = world.saveSnapshot().expect("before bad correction");
|
|
|
|
PixelChunkBatch stale;
|
|
stale.catalogFingerprint = world.materialCatalogFingerprint();
|
|
stale.sourceSeed = world.seed();
|
|
stale.sourceRevision = world.revision();
|
|
stale.sourceTick = eve::SimulationTick(world.tickValue());
|
|
CHECK(!world.applyChunkBatch(stale, world.revision() - 1).ok());
|
|
|
|
PixelChunkSnapshot malformed;
|
|
malformed.revision = world.revision();
|
|
malformed.cells.resize(1);
|
|
stale.chunks.push_back(std::move(malformed));
|
|
CHECK(!world.applyChunkBatch(stale, world.revision()).ok());
|
|
const auto after = world.saveSnapshot().expect("after bad correction");
|
|
REQUIRE_EQ(before.size(), after.size());
|
|
CHECK(std::equal(before.begin(), before.end(), after.begin()));
|
|
}
|