diff --git a/CMakeLists.txt b/CMakeLists.txt index 1400e6d..1f0e45b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -322,6 +322,8 @@ set(FABRIC_SIMULATION_SOURCE_FILES src/recurse/simulation/EssenceAssigner.cc src/recurse/simulation/ChangeVelocityTracker.cc src/recurse/simulation/ProjectionRuleTable.cc + src/recurse/simulation/WorldRuleEngine.cc + src/recurse/simulation/TransformationPass.cc ) # Utils library components diff --git a/include/recurse/simulation/MaterialRegistry.hh b/include/recurse/simulation/MaterialRegistry.hh index c3cc504..1437015 100644 --- a/include/recurse/simulation/MaterialRegistry.hh +++ b/include/recurse/simulation/MaterialRegistry.hh @@ -11,7 +11,7 @@ class MaterialRegistry { MaterialRegistry(); const MaterialDef& get(MaterialId id) const { - assert(id < material_ids::COUNT && "MaterialId out of range"); + assert(id < material_ids::REGISTRY_SIZE && "MaterialId out of range"); return materials_[id]; } @@ -20,9 +20,10 @@ class MaterialRegistry { } MaterialId count() const { return material_ids::COUNT; } + MaterialId registrySize() const { return material_ids::REGISTRY_SIZE; } private: - std::array materials_{}; + std::array materials_{}; }; } // namespace recurse::simulation diff --git a/include/recurse/simulation/TransformationPass.hh b/include/recurse/simulation/TransformationPass.hh new file mode 100644 index 0000000..9f12a06 --- /dev/null +++ b/include/recurse/simulation/TransformationPass.hh @@ -0,0 +1,59 @@ +#pragma once +#include "fabric/platform/JobScheduler.hh" +#include "recurse/simulation/ChunkActivityTracker.hh" +#include "recurse/simulation/GhostCells.hh" +#include "recurse/simulation/MaterialRegistry.hh" +#include "recurse/simulation/SimulationGrid.hh" +#include "recurse/simulation/WorldRuleEngine.hh" +#include +#include +#include +#include + +namespace recurse::simulation { + +struct ActiveChunkEntry; // forward declare + +struct SubRegionActivation { + ChunkCoord pos; + int lx, ly, lz; +}; + +class TransformationPass { + public: + struct Config { + float diffusionRate = 0.25f; + int maxTransformsPerChunk = K_MAX_TRANSFORMS_PER_CHUNK; + }; + + TransformationPass(const WorldRuleEngine& rules, const MaterialRegistry& registry, SimulationGrid& grid, + const GhostCellManager& ghosts, ChunkActivityTracker& tracker); + + /// Execute Phase 3c across all active chunks. + void execute(const std::vector& active, fabric::JobScheduler& scheduler, int64_t worldSeed, + uint64_t frameIndex); + + /// Per-chunk execution (single-threaded; flushes activations to tracker directly). + void executeChunk(ChunkCoord pos, std::mt19937& rng); + + Config& config() { return config_; } + + /// Stats from last execute() call. + int totalTransforms() const { return totalTransforms_.load(std::memory_order_relaxed); } + + private: + const WorldRuleEngine& rules_; + const MaterialRegistry& registry_; + SimulationGrid& grid_; + const GhostCellManager& ghosts_; + ChunkActivityTracker& tracker_; + Config config_; + std::atomic totalTransforms_{0}; + + void thermalKernel(ChunkCoord pos); + void executeChunk(ChunkCoord pos, std::mt19937& rng, std::vector& activations); + int ruleEvaluation(ChunkCoord pos, std::mt19937& rng, std::vector& activations); + VoxelCell readCell(ChunkCoord pos, int lx, int ly, int lz) const; +}; + +} // namespace recurse::simulation diff --git a/include/recurse/simulation/VoxelMaterial.hh b/include/recurse/simulation/VoxelMaterial.hh index 846602f..1bfb2fd 100644 --- a/include/recurse/simulation/VoxelMaterial.hh +++ b/include/recurse/simulation/VoxelMaterial.hh @@ -14,17 +14,25 @@ inline constexpr MaterialId DIRT = 2; inline constexpr MaterialId SAND = 3; inline constexpr MaterialId WATER = 4; inline constexpr MaterialId GRAVEL = 5; -inline constexpr MaterialId COUNT = 6; +inline constexpr MaterialId COUNT = 6; // Base materials (worldgen, meshing) + +// Synthetic essences produced by transformation rules +inline constexpr MaterialId ICE = 6; +inline constexpr MaterialId GLASS = 10; +inline constexpr MaterialId MAGMA = 11; +inline constexpr MaterialId REGISTRY_SIZE = 12; // Must be > max known ID } // namespace material_ids /// Broad matter mode for a voxel cell. -/// Values 5-7 reserved for future use (Growth, Unstable, etc.). +/// Values 5-6 reserved for future use (Growth, Unstable, etc.). +/// Value 7 is the "unchanged" sentinel for WorldRule result fields. enum class Phase : uint8_t { Empty = 0, Solid = 1, Powder = 2, Liquid = 3, - Gas = 4 + Gas = 4, + Unchanged = 7 }; enum class MoveType : uint8_t { diff --git a/include/recurse/simulation/VoxelSimulationSystem.hh b/include/recurse/simulation/VoxelSimulationSystem.hh index 4e9193e..335ac67 100644 --- a/include/recurse/simulation/VoxelSimulationSystem.hh +++ b/include/recurse/simulation/VoxelSimulationSystem.hh @@ -8,6 +8,8 @@ #include "recurse/simulation/MaterialRegistry.hh" #include "recurse/simulation/ProjectionRuleTable.hh" #include "recurse/simulation/SimulationGrid.hh" +#include "recurse/simulation/TransformationPass.hh" +#include "recurse/simulation/WorldRuleEngine.hh" #include #include #include @@ -69,6 +71,8 @@ class VoxelSimulationSystem { ChunkActivityTracker tracker_; GhostCellManager ghosts_; FallingSandSystem sandSystem_; + WorldRuleEngine ruleEngine_; + TransformationPass transformPass_; fabric::JobScheduler scheduler_; uint64_t frameIndex_ = 0; int64_t worldSeed_ = 0; @@ -85,6 +89,10 @@ class VoxelSimulationSystem { const FallingSandSystem& fallingSandSystem() const { return sandSystem_; } GhostCellManager& ghostCellManager() { return ghosts_; } const GhostCellManager& ghostCellManager() const { return ghosts_; } + WorldRuleEngine& ruleEngine() { return ruleEngine_; } + const WorldRuleEngine& ruleEngine() const { return ruleEngine_; } + TransformationPass& transformPass() { return transformPass_; } + const TransformationPass& transformPass() const { return transformPass_; } int64_t worldSeed() const { return worldSeed_; } ChangeVelocityTracker& velocityTracker(); diff --git a/include/recurse/simulation/WorldRuleEngine.hh b/include/recurse/simulation/WorldRuleEngine.hh new file mode 100644 index 0000000..6a6350b --- /dev/null +++ b/include/recurse/simulation/WorldRuleEngine.hh @@ -0,0 +1,69 @@ +#pragma once +#include "recurse/simulation/VoxelMaterial.hh" +#include +#include +#include + +namespace recurse::simulation { + +/// Flat 14-byte rule for world simulation behaviors. +/// Locked design: Decision 2 (2026-03-22). No nested types. +/// Tag is plain uint8_t for debugging (0=Thermal, 1=Contact, 2=Gravity). +/// +/// Sentinel conventions for result fields: +/// resultEssenceA/B = 255: leave essence unchanged +/// resultPhaseA/B = Phase::Unchanged: leave phase unchanged +/// resultTempA/B = 0: leave temperature unchanged (temp cannot be set to literal zero) +struct WorldRule { + uint8_t essenceIdxA; // self (255 = wildcard) + uint8_t essenceIdxB; // neighbor (255 = self-transform) + Phase requiredPhaseA{Phase::Unchanged}; + uint8_t temperatureMin{0}; + uint8_t temperatureMax{255}; + uint8_t resultEssenceA{255}; // 255 = unchanged + uint8_t resultEssenceB{255}; // 255 = unchanged + Phase resultPhaseA{Phase::Unchanged}; + Phase resultPhaseB{Phase::Unchanged}; + uint8_t resultTempA{0}; // 0 = unchanged + uint8_t resultTempB{0}; // 0 = unchanged + uint8_t probability{255}; // 0-255, 255 = always fire + uint8_t priority{128}; // higher = evaluated first + uint8_t tag{0}; // system origin for debugging +}; +static_assert(sizeof(WorldRule) == 14, "WorldRule must be exactly 14 bytes"); + +/// Configurable max transformations per chunk per tick. +inline constexpr int K_MAX_TRANSFORMS_PER_CHUNK = 64; + +class WorldRuleEngine { + public: + WorldRuleEngine(); + + /// Add a rule to the engine. Rules are kept sorted by priority (descending). + void addRule(WorldRule rule); + + /// Query matching rules for a cell pair at a given temperature. + /// Appends matching rules (sorted by priority, highest first) to caller-owned buffer. + /// selfEssence: essenceIdx of the cell being evaluated + /// neighborEssence: essenceIdx of the adjacent cell (255 for self-transform queries) + /// selfPhase: phase of the cell being evaluated + /// temperature: temperature byte of the cell + /// results: caller-owned output buffer (cleared then filled) + void query(uint8_t selfEssence, uint8_t neighborEssence, Phase selfPhase, uint8_t temperature, + std::vector& results) const; + + /// Number of registered rules. + size_t ruleCount() const; + + /// Access to all rules (for testing/debugging). + std::span allRules() const; + + private: + std::vector rules_; + + void sortByPriority(); + bool matches(const WorldRule& rule, uint8_t selfEssence, uint8_t neighborEssence, Phase selfPhase, + uint8_t temperature) const; +}; + +} // namespace recurse::simulation diff --git a/src/recurse/simulation/MaterialRegistry.cc b/src/recurse/simulation/MaterialRegistry.cc index 6e9f7d8..7003362 100644 --- a/src/recurse/simulation/MaterialRegistry.cc +++ b/src/recurse/simulation/MaterialRegistry.cc @@ -10,7 +10,7 @@ MaterialRegistry::MaterialRegistry() { air.baseColor = 0x00000000; air.meltPoint = 0; air.boilPoint = 0; - air.thermalConductivity = 255; // fast conductor (convection) + air.thermalConductivity = 25; // poor conductor (insulator) // Stone: heavy static block auto& stone = materials_[material_ids::STONE]; @@ -82,6 +82,33 @@ MaterialRegistry::MaterialRegistry() { gravel.meltPoint = 190; gravel.boilPoint = 0; gravel.thermalConductivity = 70; // moderate conductor + + // ICE: frozen water, lighter than liquid water + auto& ice = materials_[material_ids::ICE]; + ice.moveType = MoveType::Static; + ice.density = 90; + ice.baseColor = 0xFFD0E8FF; + ice.meltPoint = 91; + ice.boilPoint = 0; + ice.thermalConductivity = 100; + + // GLASS: vitrified sand, good insulator + auto& glass = materials_[material_ids::GLASS]; + glass.moveType = MoveType::Static; + glass.density = 160; + glass.baseColor = 0xFFA08040; + glass.meltPoint = 179; + glass.boilPoint = 0; + glass.thermalConductivity = 40; + + // MAGMA: molten stone, heavy liquid + auto& magma = materials_[material_ids::MAGMA]; + magma.moveType = MoveType::Liquid; + magma.density = 210; + magma.baseColor = 0xFFFF4400; + magma.meltPoint = 0; + magma.boilPoint = 0; + magma.thermalConductivity = 200; } } // namespace recurse::simulation diff --git a/src/recurse/simulation/ProjectionRuleTable.cc b/src/recurse/simulation/ProjectionRuleTable.cc index e37efda..74c3e8d 100644 --- a/src/recurse/simulation/ProjectionRuleTable.cc +++ b/src/recurse/simulation/ProjectionRuleTable.cc @@ -21,7 +21,7 @@ void ProjectionRuleTable::setRule(uint8_t essenceIdx, Phase phase, const Project void ProjectionRuleTable::populateFromRegistry(const MaterialRegistry& registry) { // During migration, MaterialId maps 1:1 to essenceIdx for registered materials. - const MaterialId limit = std::min(registry.count(), static_cast(K_MAX_ESSENCE)); + const MaterialId limit = std::min(registry.registrySize(), static_cast(K_MAX_ESSENCE)); for (MaterialId id = 0; id < limit; ++id) { const auto& def = registry.get(id); @@ -59,6 +59,19 @@ void ProjectionRuleTable::populateFromRegistry(const MaterialRegistry& registry) setRule(static_cast(id), phase, projected); } + + // Display-only overrides for synthetic essences. + // density, baseColor, and moveType are already set by the loop above. + // Only displayName and reductionTiebreak need manual values. + auto applyDisplayOverride = [&](uint8_t essenceIdx, Phase phase, std::string_view name, uint8_t tiebreak) { + size_t idx = index(essenceIdx, phase); + table_[idx].displayName = name; + table_[idx].reductionTiebreak = tiebreak; + }; + + applyDisplayOverride(material_ids::ICE, Phase::Solid, "ice", 110); + applyDisplayOverride(material_ids::GLASS, Phase::Solid, "glass", 130); + applyDisplayOverride(material_ids::MAGMA, Phase::Liquid, "magma", 200); } } // namespace recurse::simulation diff --git a/src/recurse/simulation/TransformationPass.cc b/src/recurse/simulation/TransformationPass.cc new file mode 100644 index 0000000..ed8855d --- /dev/null +++ b/src/recurse/simulation/TransformationPass.cc @@ -0,0 +1,263 @@ +#include "recurse/simulation/TransformationPass.hh" +#include "fabric/log/Log.hh" +#include "fabric/utils/Profiler.hh" +#include "recurse/simulation/CellAccessors.hh" +#include "recurse/simulation/VoxelSimulationSystem.hh" +#include +#include + +namespace recurse::simulation { + +TransformationPass::TransformationPass(const WorldRuleEngine& rules, const MaterialRegistry& registry, + SimulationGrid& grid, const GhostCellManager& ghosts, + ChunkActivityTracker& tracker) + : rules_(rules), registry_(registry), grid_(grid), ghosts_(ghosts), tracker_(tracker) { + FABRIC_LOG_DEBUG("TransformationPass initialized: diffusionRate={}, maxTransforms={}", config_.diffusionRate, + config_.maxTransformsPerChunk); +} + +void TransformationPass::execute(const std::vector& active, fabric::JobScheduler& scheduler, + int64_t worldSeed, uint64_t frameIndex) { + FABRIC_ZONE_SCOPED_N("phase_3c_transform"); + totalTransforms_.store(0, std::memory_order_relaxed); + + size_t workerSlots = scheduler.workerCount() + 1; + std::vector> activationsPerWorker(workerSlots); + + scheduler.parallelFor(active.size(), "phase_3c_transform", [&](size_t jobIdx, size_t workerIdx) { + const auto& pos = active[jobIdx].pos; + std::mt19937 rng(static_cast(worldSeed ^ spatialHash(pos) ^ static_cast(frameIndex))); + executeChunk(pos, rng, activationsPerWorker[workerIdx]); + }); + + // Flush collected activations to tracker on main thread + for (auto& workerActivations : activationsPerWorker) { + for (const auto& act : workerActivations) { + tracker_.markSubRegionActive(act.pos, act.lx, act.ly, act.lz); + } + } + + int transforms = totalTransforms_.load(std::memory_order_relaxed); + if (transforms > 0) { + FABRIC_LOG_DEBUG("Phase 3c: {} chunks, {} transforms", active.size(), transforms); + } +} + +void TransformationPass::executeChunk(ChunkCoord pos, std::mt19937& rng) { + std::vector activations; + executeChunk(pos, rng, activations); + for (const auto& act : activations) { + tracker_.markSubRegionActive(act.pos, act.lx, act.ly, act.lz); + } +} + +void TransformationPass::executeChunk(ChunkCoord pos, std::mt19937& rng, + std::vector& activations) { + thermalKernel(pos); + int count = ruleEvaluation(pos, rng, activations); + if (count > 0) { + totalTransforms_.fetch_add(count, std::memory_order_relaxed); + } +} + +VoxelCell TransformationPass::readCell(ChunkCoord pos, int lx, int ly, int lz) const { + if (lx >= 0 && lx < K_CHUNK_SIZE && ly >= 0 && ly < K_CHUNK_SIZE && lz >= 0 && lz < K_CHUNK_SIZE) { + int wx = pos.x * K_CHUNK_SIZE + lx; + int wy = pos.y * K_CHUNK_SIZE + ly; + int wz = pos.z * K_CHUNK_SIZE + lz; + return grid_.readFromWriteBuffer(wx, wy, wz); + } + return ghosts_.readGhost(pos, lx, ly, lz); +} + +void TransformationPass::thermalKernel(ChunkCoord pos) { + FABRIC_ZONE_SCOPED_N("thermalKernel"); + + static constexpr int offsets[6][3] = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}; + + for (int ly = 0; ly < K_CHUNK_SIZE; ++ly) { + for (int lz = 0; lz < K_CHUNK_SIZE; ++lz) { + for (int lx = 0; lx < K_CHUNK_SIZE; ++lx) { + int wx = pos.x * K_CHUNK_SIZE + lx; + int wy = pos.y * K_CHUNK_SIZE + ly; + int wz = pos.z * K_CHUNK_SIZE + lz; + + VoxelCell cell = grid_.readFromWriteBuffer(wx, wy, wz); + if (isEmpty(cell)) + continue; + + uint8_t selfTemp = cellTemperature(cell); + uint8_t selfCond = registry_.get(static_cast(cell.essenceIdx)).thermalConductivity; + + // Read 6 face-neighbor temperatures and conductivities + float neighborTempSum = 0.0f; + float neighborCondSum = 0.0f; + int neighborCount = 0; + bool allEqual = true; + + for (const auto& off : offsets) { + VoxelCell neighbor = readCell(pos, lx + off[0], ly + off[1], lz + off[2]); + if (isEmpty(neighbor)) + continue; // Air gaps are thermal insulators + + uint8_t nTemp = cellTemperature(neighbor); + if (nTemp != selfTemp) + allEqual = false; + neighborTempSum += static_cast(nTemp); + neighborCondSum += static_cast( + registry_.get(static_cast(neighbor.essenceIdx)).thermalConductivity); + ++neighborCount; + } + + // Skip if isolated in air or thermally stable + if (neighborCount == 0 || allEqual) + continue; + + float neighborAvgTemp = neighborTempSum / static_cast(neighborCount); + float neighborAvgCond = neighborCondSum / static_cast(neighborCount); + + // Min-of-pair conductivity model, normalized to 0.0-1.0 + float effCond = std::min(static_cast(selfCond), neighborAvgCond) / 255.0f; + + float newTempF = static_cast(selfTemp) + + (neighborAvgTemp - static_cast(selfTemp)) * effCond * config_.diffusionRate; + + // Clamp to [0, 255] + uint8_t newTemp = static_cast(std::clamp(newTempF, 0.0f, 255.0f)); + + if (newTemp != selfTemp) { + setCellTemperature(cell, newTemp); + grid_.writeCell(wx, wy, wz, cell); + } + } + } + } +} + +int TransformationPass::ruleEvaluation(ChunkCoord pos, std::mt19937& rng, + std::vector& activations) { + FABRIC_ZONE_SCOPED_N("ruleEvaluation"); + + int transformCount = 0; + std::vector matchBuffer; + + for (int ly = 0; ly < K_CHUNK_SIZE; ++ly) { + for (int lz = 0; lz < K_CHUNK_SIZE; ++lz) { + for (int lx = 0; lx < K_CHUNK_SIZE; ++lx) { + if (transformCount >= config_.maxTransformsPerChunk) + return transformCount; + + int wx = pos.x * K_CHUNK_SIZE + lx; + int wy = pos.y * K_CHUNK_SIZE + ly; + int wz = pos.z * K_CHUNK_SIZE + lz; + + VoxelCell cell = grid_.readFromWriteBuffer(wx, wy, wz); + if (isEmpty(cell)) + continue; + + uint8_t temp = cellTemperature(cell); + bool transformed = false; + + // Self-transform rules (neighborEssence = 255) + rules_.query(cell.essenceIdx, 255, cell.phase(), temp, matchBuffer); + for (const auto& rule : matchBuffer) { + if (transformed) + break; + uint8_t roll = static_cast(rng() & 0xFF); + if (rule.probability == 255 || roll < rule.probability) { + if (rule.resultEssenceA != 255) + cell.essenceIdx = rule.resultEssenceA; + if (rule.resultPhaseA != Phase::Unchanged) + cell.setPhase(rule.resultPhaseA); + if (rule.resultTempA != 0) + setCellTemperature(cell, rule.resultTempA); + // Update displacement rank from registry for new essence + if (rule.resultEssenceA != 255) { + cell.displacementRank = registry_.get(static_cast(rule.resultEssenceA)).density; + } + grid_.writeCell(wx, wy, wz, cell); + if (cell.phase() == Phase::Liquid || cell.phase() == Phase::Powder) + activations.push_back({pos, lx, ly, lz}); + ++transformCount; + transformed = true; + } + } + + if (transformed) + continue; + + // Contact rules: check 6 face-neighbors + static constexpr int offsets[6][3] = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, + {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}; + for (const auto& off : offsets) { + if (transformed) + break; + + int nlx = lx + off[0]; + int nly = ly + off[1]; + int nlz = lz + off[2]; + + VoxelCell neighbor = readCell(pos, nlx, nly, nlz); + if (isEmpty(neighbor)) + continue; + + rules_.query(cell.essenceIdx, neighbor.essenceIdx, cell.phase(), temp, matchBuffer); + for (const auto& rule : matchBuffer) { + if (transformed) + break; + uint8_t roll = static_cast(rng() & 0xFF); + if (rule.probability == 255 || roll < rule.probability) { + // Apply to self + if (rule.resultEssenceA != 255) + cell.essenceIdx = rule.resultEssenceA; + if (rule.resultPhaseA != Phase::Unchanged) + cell.setPhase(rule.resultPhaseA); + if (rule.resultTempA != 0) + setCellTemperature(cell, rule.resultTempA); + if (rule.resultEssenceA != 255) { + cell.displacementRank = + registry_.get(static_cast(rule.resultEssenceA)).density; + } + grid_.writeCell(wx, wy, wz, cell); + if (cell.phase() == Phase::Liquid || cell.phase() == Phase::Powder) + activations.push_back({pos, lx, ly, lz}); + + // Apply to neighbor (skip cross-chunk writes) + bool neighborInBounds = nlx >= 0 && nlx < K_CHUNK_SIZE && nly >= 0 && nly < K_CHUNK_SIZE && + nlz >= 0 && nlz < K_CHUNK_SIZE; + if (!neighborInBounds) { + FABRIC_LOG_DEBUG("cross-chunk contact skip: ({},{},{}) -> neighbor out of bounds", + pos.x, pos.y, pos.z); + } else if (rule.resultEssenceB != 255 || rule.resultPhaseB != Phase::Unchanged || + rule.resultTempB != 0) { + int nwx = pos.x * K_CHUNK_SIZE + nlx; + int nwy = pos.y * K_CHUNK_SIZE + nly; + int nwz = pos.z * K_CHUNK_SIZE + nlz; + VoxelCell nCell = grid_.readFromWriteBuffer(nwx, nwy, nwz); + if (rule.resultEssenceB != 255) + nCell.essenceIdx = rule.resultEssenceB; + if (rule.resultPhaseB != Phase::Unchanged) + nCell.setPhase(rule.resultPhaseB); + if (rule.resultTempB != 0) + setCellTemperature(nCell, rule.resultTempB); + if (rule.resultEssenceB != 255) { + nCell.displacementRank = + registry_.get(static_cast(rule.resultEssenceB)).density; + } + grid_.writeCell(nwx, nwy, nwz, nCell); + if (nCell.phase() == Phase::Liquid || nCell.phase() == Phase::Powder) + activations.push_back({pos, nlx, nly, nlz}); + } + + ++transformCount; + transformed = true; + } + } + } + } + } + } + return transformCount; +} + +} // namespace recurse::simulation diff --git a/src/recurse/simulation/VoxelSimulationSystem.cc b/src/recurse/simulation/VoxelSimulationSystem.cc index 0093a85..293f713 100644 --- a/src/recurse/simulation/VoxelSimulationSystem.cc +++ b/src/recurse/simulation/VoxelSimulationSystem.cc @@ -5,7 +5,8 @@ namespace recurse::simulation { -VoxelSimulationSystem::VoxelSimulationSystem() : sandSystem_(registry_) { +VoxelSimulationSystem::VoxelSimulationSystem() + : sandSystem_(registry_), transformPass_(ruleEngine_, registry_, grid_, ghosts_, tracker_) { projectionTable_.populateFromRegistry(registry_); } @@ -111,6 +112,9 @@ void VoxelSimulationSystem::tick() { drainBoundaryWrites(boundaryQueues); } + // Phase 3c: Transformation pass (thermal diffusion + rule evaluation) + { transformPass_.execute(active, scheduler_, worldSeed_, frameIndex_); } + // Phase 4: Advance epoch (swap read/write buffers) { FABRIC_ZONE_SCOPED_N("phase_4_epoch_advance"); diff --git a/src/recurse/simulation/WorldRuleEngine.cc b/src/recurse/simulation/WorldRuleEngine.cc new file mode 100644 index 0000000..84c4902 --- /dev/null +++ b/src/recurse/simulation/WorldRuleEngine.cc @@ -0,0 +1,185 @@ +#include "recurse/simulation/WorldRuleEngine.hh" +#include "fabric/log/Log.hh" +#include + +namespace recurse::simulation { + +WorldRuleEngine::WorldRuleEngine() { + // Material essenceIdx values (1:1 with MaterialId during migration): + // AIR=0, STONE=1, DIRT=2, SAND=3, WATER=4, GRAVEL=5, ICE=6, GLASS=10, MAGMA=11 + + // R1: Water freeze (temp <= 90, 50%) + rules_.push_back({.essenceIdxA = 4, + .essenceIdxB = 255, + .requiredPhaseA = Phase::Liquid, + .temperatureMin = 0, + .temperatureMax = 90, + .resultEssenceA = 6, + .resultEssenceB = 255, + .resultPhaseA = Phase::Solid, + .resultPhaseB = Phase::Unchanged, + .resultTempA = 0, + .resultTempB = 0, + .probability = 128, + .priority = 200, + .tag = 0}); + + // R2: Ice thaw (temp >= 92, 75%) + rules_.push_back({.essenceIdxA = 6, + .essenceIdxB = 255, + .requiredPhaseA = Phase::Solid, + .temperatureMin = 92, + .temperatureMax = 255, + .resultEssenceA = 4, + .resultEssenceB = 255, + .resultPhaseA = Phase::Liquid, + .resultPhaseB = Phase::Unchanged, + .resultTempA = 0, + .resultTempB = 0, + .probability = 192, + .priority = 200, + .tag = 0}); + + // R3: Water boil (temp >= 125, 25%) + rules_.push_back({.essenceIdxA = 4, + .essenceIdxB = 255, + .requiredPhaseA = Phase::Liquid, + .temperatureMin = 125, + .temperatureMax = 255, + .resultEssenceA = 0, + .resultEssenceB = 255, + .resultPhaseA = Phase::Empty, + .resultPhaseB = Phase::Unchanged, + .resultTempA = 0, + .resultTempB = 0, + .probability = 64, + .priority = 190, + .tag = 0}); + + // R4: Sand vitrify (temp >= 180, 12%) + rules_.push_back({.essenceIdxA = 3, + .essenceIdxB = 255, + .requiredPhaseA = Phase::Powder, + .temperatureMin = 180, + .temperatureMax = 255, + .resultEssenceA = 10, + .resultEssenceB = 255, + .resultPhaseA = Phase::Solid, + .resultPhaseB = Phase::Unchanged, + .resultTempA = 0, + .resultTempB = 0, + .probability = 32, + .priority = 180, + .tag = 0}); + + // R5: Stone melt (temp >= 196, 6%) + rules_.push_back({.essenceIdxA = 1, + .essenceIdxB = 255, + .requiredPhaseA = Phase::Solid, + .temperatureMin = 196, + .temperatureMax = 255, + .resultEssenceA = 11, + .resultEssenceB = 255, + .resultPhaseA = Phase::Liquid, + .resultPhaseB = Phase::Unchanged, + .resultTempA = 0, + .resultTempB = 0, + .probability = 16, + .priority = 180, + .tag = 0}); + + // R6: Magma cool (temp <= 194, 50%) + rules_.push_back({.essenceIdxA = 11, + .essenceIdxB = 255, + .requiredPhaseA = Phase::Liquid, + .temperatureMin = 0, + .temperatureMax = 194, + .resultEssenceA = 1, + .resultEssenceB = 255, + .resultPhaseA = Phase::Solid, + .resultPhaseB = Phase::Unchanged, + .resultTempA = 0, + .resultTempB = 0, + .probability = 128, + .priority = 200, + .tag = 0}); + + // R7: Water + Magma contact (any temp, 100%) + rules_.push_back({.essenceIdxA = 4, + .essenceIdxB = 11, + .requiredPhaseA = Phase::Liquid, + .temperatureMin = 0, + .temperatureMax = 255, + .resultEssenceA = 1, + .resultEssenceB = 1, + .resultPhaseA = Phase::Solid, + .resultPhaseB = Phase::Solid, + .resultTempA = 150, + .resultTempB = 150, + .probability = 255, + .priority = 220, + .tag = 1}); + + // R8: Near-heat evaporation (temp >= 141, 50%) + rules_.push_back({.essenceIdxA = 4, + .essenceIdxB = 255, + .requiredPhaseA = Phase::Liquid, + .temperatureMin = 141, + .temperatureMax = 255, + .resultEssenceA = 0, + .resultEssenceB = 255, + .resultPhaseA = Phase::Empty, + .resultPhaseB = Phase::Unchanged, + .resultTempA = 0, + .resultTempB = 0, + .probability = 128, + .priority = 185, + .tag = 1}); + + sortByPriority(); + + FABRIC_LOG_INFO("WorldRuleEngine initialized: {} rules", rules_.size()); +} + +void WorldRuleEngine::addRule(WorldRule rule) { + rules_.push_back(rule); + sortByPriority(); +} + +void WorldRuleEngine::query(uint8_t selfEssence, uint8_t neighborEssence, Phase selfPhase, uint8_t temperature, + std::vector& results) const { + results.clear(); + for (const auto& rule : rules_) { + if (matches(rule, selfEssence, neighborEssence, selfPhase, temperature)) { + results.push_back(rule); + } + } +} + +size_t WorldRuleEngine::ruleCount() const { + return rules_.size(); +} + +std::span WorldRuleEngine::allRules() const { + return rules_; +} + +void WorldRuleEngine::sortByPriority() { + std::sort(rules_.begin(), rules_.end(), + [](const WorldRule& a, const WorldRule& b) { return a.priority > b.priority; }); +} + +bool WorldRuleEngine::matches(const WorldRule& rule, uint8_t selfEssence, uint8_t neighborEssence, Phase selfPhase, + uint8_t temperature) const { + if (rule.essenceIdxA != selfEssence && rule.essenceIdxA != 255) + return false; + if (rule.essenceIdxB != neighborEssence) + return false; + if (rule.requiredPhaseA != Phase::Unchanged && rule.requiredPhaseA != selfPhase) + return false; + if (temperature < rule.temperatureMin || temperature > rule.temperatureMax) + return false; + return true; +} + +} // namespace recurse::simulation diff --git a/tests/unit/simulation/CMakeLists.txt b/tests/unit/simulation/CMakeLists.txt index 56173bb..c25496b 100644 --- a/tests/unit/simulation/CMakeLists.txt +++ b/tests/unit/simulation/CMakeLists.txt @@ -16,6 +16,8 @@ target_sources(UnitTests BoundaryDrainTest.cc MatterStateTest.cc ProjectionRuleTableTest.cc + WorldRuleEngineTest.cc + TransformationPassTest.cc CellFactoryTest.cc VisualMergeTableTest.cc ) diff --git a/tests/unit/simulation/MaterialRegistryTest.cc b/tests/unit/simulation/MaterialRegistryTest.cc index 9f978f2..16e9246 100644 --- a/tests/unit/simulation/MaterialRegistryTest.cc +++ b/tests/unit/simulation/MaterialRegistryTest.cc @@ -173,3 +173,38 @@ TEST_F(MaterialRegistryTest, ResolveVoxelSemanticsDoesNotTreatEssenceIndexAsCano EXPECT_FLOAT_EQ(resolved.material.intrinsicEssence.order, 0.3f); EXPECT_FLOAT_EQ(resolved.material.intrinsicEssence.life, 0.5f); } + +TEST_F(MaterialRegistryTest, RegistrySizeCoversAllKnownEssences) { + EXPECT_EQ(registry.registrySize(), material_ids::REGISTRY_SIZE); + EXPECT_GE(registry.registrySize(), 12u); +} + +TEST_F(MaterialRegistryTest, IceProperties) { + const auto& ice = registry.get(material_ids::ICE); + EXPECT_EQ(ice.moveType, MoveType::Static); + EXPECT_EQ(ice.density, 90); + EXPECT_EQ(ice.thermalConductivity, 100); + EXPECT_EQ(ice.baseColor, 0xFFD0E8FFu); + EXPECT_EQ(ice.meltPoint, 91); +} + +TEST_F(MaterialRegistryTest, GlassProperties) { + const auto& glass = registry.get(material_ids::GLASS); + EXPECT_EQ(glass.moveType, MoveType::Static); + EXPECT_EQ(glass.density, 160); + EXPECT_EQ(glass.thermalConductivity, 40); + EXPECT_EQ(glass.baseColor, 0xFFA08040u); + EXPECT_EQ(glass.meltPoint, 179); +} + +TEST_F(MaterialRegistryTest, MagmaProperties) { + const auto& magma = registry.get(material_ids::MAGMA); + EXPECT_EQ(magma.moveType, MoveType::Liquid); + EXPECT_EQ(magma.density, 210); + EXPECT_EQ(magma.thermalConductivity, 200); + EXPECT_EQ(magma.baseColor, 0xFFFF4400u); +} + +TEST_F(MaterialRegistryTest, AirConductivityIsLow) { + EXPECT_EQ(registry.get(material_ids::AIR).thermalConductivity, 25) << "AIR should be a poor thermal conductor"; +} diff --git a/tests/unit/simulation/ProjectionRuleTableTest.cc b/tests/unit/simulation/ProjectionRuleTableTest.cc index 3089f13..48f4d56 100644 --- a/tests/unit/simulation/ProjectionRuleTableTest.cc +++ b/tests/unit/simulation/ProjectionRuleTableTest.cc @@ -166,6 +166,38 @@ TEST(ProjectionRuleTableTest, PopulateFromRegistryAir) { EXPECT_EQ(air.density, 0); } +// -- Synthetic density matches registry --------------------------------------- + +TEST(ProjectionRuleTableTest, SyntheticDensityMatchesRegistry) { + MaterialRegistry registry; + ProjectionRuleTable table; + table.populateFromRegistry(registry); + + // Density must come from the registry, not hardcoded in ProjectionRuleTable + EXPECT_EQ(table.lookup(material_ids::ICE, Phase::Solid).density, registry.get(material_ids::ICE).density) + << "ICE projection density must match registry"; + EXPECT_EQ(table.lookup(material_ids::GLASS, Phase::Solid).density, registry.get(material_ids::GLASS).density) + << "GLASS projection density must match registry"; + EXPECT_EQ(table.lookup(material_ids::MAGMA, Phase::Liquid).density, registry.get(material_ids::MAGMA).density) + << "MAGMA projection density must match registry"; +} + +// -- Synthetic display overrides ---------------------------------------------- + +TEST(ProjectionRuleTableTest, SyntheticDisplayOverrides) { + MaterialRegistry registry; + ProjectionRuleTable table; + table.populateFromRegistry(registry); + + EXPECT_EQ(table.lookup(material_ids::ICE, Phase::Solid).displayName, "ice"); + EXPECT_EQ(table.lookup(material_ids::GLASS, Phase::Solid).displayName, "glass"); + EXPECT_EQ(table.lookup(material_ids::MAGMA, Phase::Liquid).displayName, "magma"); + + EXPECT_EQ(table.lookup(material_ids::ICE, Phase::Solid).reductionTiebreak, 110); + EXPECT_EQ(table.lookup(material_ids::GLASS, Phase::Solid).reductionTiebreak, 130); + EXPECT_EQ(table.lookup(material_ids::MAGMA, Phase::Liquid).reductionTiebreak, 200); +} + // -- Overwrite after populate ------------------------------------------------- TEST(ProjectionRuleTableTest, SetRuleOverridesPopulated) { diff --git a/tests/unit/simulation/TransformationPassTest.cc b/tests/unit/simulation/TransformationPassTest.cc new file mode 100644 index 0000000..e6e2b4e --- /dev/null +++ b/tests/unit/simulation/TransformationPassTest.cc @@ -0,0 +1,385 @@ +#include "recurse/simulation/TransformationPass.hh" +#include "recurse/simulation/CellAccessors.hh" +#include "recurse/simulation/ProjectionRuleTable.hh" +#include "recurse/simulation/VoxelSimulationSystem.hh" +#include + +using namespace recurse::simulation; + +class TransformationPassTest : public ::testing::Test { + protected: + VoxelSimulationSystem sim; + + void SetUp() override { + sim.scheduler().disableForTesting(); + sim.grid().fillChunk(0, 0, 0, VoxelCell{}); + sim.grid().materializeChunk(0, 0, 0); + sim.activityTracker().setState(ChunkCoord{0, 0, 0}, ChunkState::Active); + markAllSubRegions(ChunkCoord{0, 0, 0}); + } + + void markAllSubRegions(ChunkCoord pos) { + for (int lz = 0; lz < K_CHUNK_SIZE; lz += 8) + for (int ly = 0; ly < K_CHUNK_SIZE; ly += 8) + for (int lx = 0; lx < K_CHUNK_SIZE; lx += 8) + sim.activityTracker().markSubRegionActive(pos, lx, ly, lz); + } + + void syncGhostsForOrigin() { + std::vector positions = {ChunkCoord{0, 0, 0}}; + sim.ghostCellManager().syncAll(positions, sim.grid()); + } +}; + +// 1. Thermal diffusion moves heat from a hot cell toward cooler neighbors. +TEST_F(TransformationPassTest, ThermalDiffusionBasic) { + // Fill a 5x5x5 block of ambient stone so neighbors have uniform context. + VoxelCell ambient = makeCell(1, Phase::Solid, 200); + setCellTemperature(ambient, 100); + for (int x = 14; x <= 18; ++x) + for (int y = 14; y <= 18; ++y) + for (int z = 14; z <= 18; ++z) + sim.grid().writeCell(x, y, z, ambient); + + // Place a hot cell at the center + VoxelCell hot = makeCell(1, Phase::Solid, 200); + setCellTemperature(hot, 200); + sim.grid().writeCell(16, 16, 16, hot); + sim.grid().advanceEpoch(); + + syncGhostsForOrigin(); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(42); + pass.executeChunk(ChunkCoord{0, 0, 0}, rng); + + // Hot cell should have cooled + VoxelCell result = sim.grid().readFromWriteBuffer(16, 16, 16); + EXPECT_LT(cellTemperature(result), 200) << "Hot cell should cool toward neighbors"; + + // At least one direct neighbor should have warmed above 100 + static constexpr int offsets[6][3] = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}; + bool anyWarmed = false; + for (const auto& off : offsets) { + VoxelCell n = sim.grid().readFromWriteBuffer(16 + off[0], 16 + off[1], 16 + off[2]); + if (cellTemperature(n) > 100) { + anyWarmed = true; + break; + } + } + EXPECT_TRUE(anyWarmed) << "At least one neighbor should warm due to diffusion"; +} + +// 2. Thermally uniform chunk with uniform ghost cells is fully skipped. +TEST_F(TransformationPassTest, ThermalSkipStable) { + // Fill chunk and all 6 face-neighbor chunks with uniform temperature stone + // so ghost cells also read temp=100 and no cell has a temperature gradient. + VoxelCell uniform = makeCell(1, Phase::Solid, 200); + setCellTemperature(uniform, 100); + + // Fill origin chunk + for (int x = 0; x < K_CHUNK_SIZE; ++x) + for (int y = 0; y < K_CHUNK_SIZE; ++y) + for (int z = 0; z < K_CHUNK_SIZE; ++z) + sim.grid().writeCell(x, y, z, uniform); + + // Fill 6 face-neighbor chunks (just the boundary slice facing origin) + static constexpr int neighborOffsets[6][3] = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}; + for (const auto& off : neighborOffsets) { + int ncx = off[0], ncy = off[1], ncz = off[2]; + sim.grid().fillChunk(ncx, ncy, ncz, uniform); + sim.grid().materializeChunk(ncx, ncy, ncz); + // Write to write buffer so syncChunkBuffers copies it + for (int x = 0; x < K_CHUNK_SIZE; ++x) + for (int y = 0; y < K_CHUNK_SIZE; ++y) + for (int z = 0; z < K_CHUNK_SIZE; ++z) + sim.grid().writeCell(ncx * K_CHUNK_SIZE + x, ncy * K_CHUNK_SIZE + y, ncz * K_CHUNK_SIZE + z, + uniform); + } + sim.grid().advanceEpoch(); + + // Sync ghost cells so boundary reads see uniform temp + std::vector positions = {ChunkCoord{0, 0, 0}}; + sim.ghostCellManager().syncAll(positions, sim.grid()); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(42); + pass.executeChunk(ChunkCoord{0, 0, 0}, rng); + + // Interior AND boundary cells should all be unchanged + VoxelCell inner = sim.grid().readFromWriteBuffer(16, 16, 16); + EXPECT_EQ(cellTemperature(inner), 100) << "Interior cell in fully uniform chunk should not change"; + + VoxelCell boundary = sim.grid().readFromWriteBuffer(0, 0, 0); + EXPECT_EQ(cellTemperature(boundary), 100) << "Boundary cell with uniform ghost cells should not change"; +} + +// 3. Water below freeze threshold triggers ice rule. +TEST_F(TransformationPassTest, RuleFiringBasic) { + // Place water cell at temp=80 (below freeze threshold of 90) + VoxelCell water = makeCell(4, Phase::Liquid, 100); + setCellTemperature(water, 80); + sim.grid().writeCell(16, 16, 16, water); + sim.grid().advanceEpoch(); + + syncGhostsForOrigin(); + + // Run rule evaluation multiple times (50% probability per attempt) + bool frozen = false; + for (int attempt = 0; attempt < 50 && !frozen; ++attempt) { + // Reset cell to water each attempt + sim.grid().writeCell(16, 16, 16, water); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(static_cast(attempt * 7 + 13)); + pass.executeChunk(ChunkCoord{0, 0, 0}, rng); + + VoxelCell result = sim.grid().readFromWriteBuffer(16, 16, 16); + if (result.essenceIdx == 6 && result.phase() == Phase::Solid) { + frozen = true; + } + } + EXPECT_TRUE(frozen) << "Water at temp=80 should eventually freeze (50% probability per tick)"; +} + +// 4. Budget cap limits transformations per chunk. +TEST_F(TransformationPassTest, BudgetCap) { + // Fill chunk with water below freeze threshold + VoxelCell water = makeCell(4, Phase::Liquid, 100); + setCellTemperature(water, 80); + + int placed = 0; + for (int y = 0; y < K_CHUNK_SIZE && placed < 200; ++y) + for (int z = 0; z < K_CHUNK_SIZE && placed < 200; ++z) + for (int x = 0; x < K_CHUNK_SIZE && placed < 200; ++x) { + sim.grid().writeCell(x, y, z, water); + ++placed; + } + sim.grid().advanceEpoch(); + + syncGhostsForOrigin(); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(1); + pass.executeChunk(ChunkCoord{0, 0, 0}, rng); + + EXPECT_LE(pass.totalTransforms(), K_MAX_TRANSFORMS_PER_CHUNK) + << "Transformations per chunk must not exceed budget cap"; +} + +// 5. Integration test: tick() runs Phase 3c and produces transformations. +TEST_F(TransformationPassTest, ExecuteIntegration) { + VoxelCell stone = makeCell(1, Phase::Solid, 200); + setCellTemperature(stone, 50); + VoxelCell water = makeCell(4, Phase::Liquid, 100); + setCellTemperature(water, 50); + + // Build a stone containment box so FallingSand doesn't move water away + // Floor + for (int x = 14; x <= 18; ++x) + for (int z = 14; z <= 18; ++z) + sim.grid().writeCell(x, 0, z, stone); + // Walls (y=1..3) + for (int y = 1; y <= 3; ++y) { + for (int x = 14; x <= 18; ++x) { + sim.grid().writeCell(x, y, 14, stone); + sim.grid().writeCell(x, y, 18, stone); + } + for (int z = 15; z <= 17; ++z) { + sim.grid().writeCell(14, y, z, stone); + sim.grid().writeCell(18, y, z, stone); + } + } + // Place water inside the box + for (int x = 15; x <= 17; ++x) + for (int z = 15; z <= 17; ++z) + sim.grid().writeCell(x, 1, z, water); + sim.grid().advanceEpoch(); + + bool frozen = false; + for (int i = 0; i < 100 && !frozen; ++i) { + sim.activityTracker().setState(ChunkCoord{0, 0, 0}, ChunkState::Active); + markAllSubRegions(ChunkCoord{0, 0, 0}); + sim.tick(); + + // Check all water positions inside the box + for (int x = 15; x <= 17 && !frozen; ++x) + for (int z = 15; z <= 17 && !frozen; ++z) { + VoxelCell cell = sim.grid().readCell(x, 1, z); + if (cell.essenceIdx == 6 && cell.phase() == Phase::Solid) + frozen = true; + } + } + EXPECT_TRUE(frozen) << "Contained water at temp=50 should freeze via Phase 3c"; +} + +// 6. A cell surrounded by empty air retains its temperature. +TEST_F(TransformationPassTest, ThermalIsolationInAir) { + VoxelCell hot = makeCell(1, Phase::Solid, 200); + setCellTemperature(hot, 200); + sim.grid().writeCell(16, 16, 16, hot); + sim.grid().advanceEpoch(); + + syncGhostsForOrigin(); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(42); + pass.executeChunk(ChunkCoord{0, 0, 0}, rng); + + VoxelCell result = sim.grid().readFromWriteBuffer(16, 16, 16); + EXPECT_EQ(cellTemperature(result), 200) << "Cell surrounded by empty air should retain temperature"; +} + +// 7. Thawing ice activates the sub-region for FallingSand processing. +TEST_F(TransformationPassTest, ThawActivatesSubRegion) { + // Place ice at temp=130 (above thaw threshold of 92, R2 probability=75%) + VoxelCell ice = makeCell(6, Phase::Solid, 90); + setCellTemperature(ice, 130); + sim.grid().advanceEpoch(); + + syncGhostsForOrigin(); + + // Run until ice thaws (75% probability per attempt) + bool thawed = false; + for (int attempt = 0; attempt < 50 && !thawed; ++attempt) { + sim.grid().writeCell(16, 16, 16, ice); + sim.activityTracker().clearSubRegionMask(ChunkCoord{0, 0, 0}); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(static_cast(attempt * 11 + 7)); + pass.executeChunk(ChunkCoord{0, 0, 0}, rng); + + VoxelCell result = sim.grid().readFromWriteBuffer(16, 16, 16); + if (result.essenceIdx == 4 && result.phase() == Phase::Liquid) { + thawed = true; + uint64_t mask = sim.activityTracker().getSubRegionMask(ChunkCoord{0, 0, 0}); + EXPECT_NE(mask, 0u) << "Sub-region must be activated when ice thaws to water"; + } + } + EXPECT_TRUE(thawed) << "Ice at temp=130 should thaw (75% probability per tick)"; +} + +// 8. A rule with probability=255 fires 100% of the time (sentinel: always fire). +TEST_F(TransformationPassTest, Probability255AlwaysFires) { + // R7: water + magma contact has probability=255 (must fire every time). + // Place water at (16,16,16) and magma neighbor at (17,16,16). + VoxelCell water = makeCell(4, Phase::Liquid, 100); + setCellTemperature(water, 100); + VoxelCell magma = makeCell(11, Phase::Liquid, 200); + setCellTemperature(magma, 200); + + int fires = 0; + int trials = 256; + for (int seed = 0; seed < trials; ++seed) { + // Reset cells each trial + sim.grid().writeCell(16, 16, 16, water); + sim.grid().writeCell(17, 16, 16, magma); + sim.grid().advanceEpoch(); + syncGhostsForOrigin(); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(static_cast(seed)); + pass.executeChunk(ChunkCoord{0, 0, 0}, rng); + + VoxelCell result = sim.grid().readFromWriteBuffer(16, 16, 16); + // R7 transforms water(4) -> stone(1) Solid + if (result.essenceIdx == 1 && result.phase() == Phase::Solid) { + ++fires; + } + } + EXPECT_EQ(fires, trials) << "probability=255 must fire 256/256 times (100%)"; +} + +// 9. R7 quench sets temperature below R5 melt threshold, preventing intra-tick re-melt. +TEST_F(TransformationPassTest, R7QuenchCoolsBelowMeltThreshold) { + // Water at (16,16,16), magma neighbor at (17,16,16) with temp >= 196 + VoxelCell water = makeCell(4, Phase::Liquid, 100); + setCellTemperature(water, 100); + VoxelCell magma = makeCell(11, Phase::Liquid, 200); + setCellTemperature(magma, 210); + + sim.grid().writeCell(16, 16, 16, water); + sim.grid().writeCell(17, 16, 16, magma); + sim.grid().advanceEpoch(); + syncGhostsForOrigin(); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(42); + pass.executeChunk(ChunkCoord{0, 0, 0}, rng); + + // Both cells should be stone (essenceIdx=1, Phase::Solid) + VoxelCell resultA = sim.grid().readFromWriteBuffer(16, 16, 16); + VoxelCell resultB = sim.grid().readFromWriteBuffer(17, 16, 16); + EXPECT_EQ(resultA.essenceIdx, 1) << "Water cell should become stone after R7"; + EXPECT_EQ(resultA.phase(), Phase::Solid); + EXPECT_EQ(resultB.essenceIdx, 1) << "Magma cell should become stone after R7"; + EXPECT_EQ(resultB.phase(), Phase::Solid); + + // Both cells should have temperature 150, safely below R5 melt threshold (196) + EXPECT_EQ(cellTemperature(resultA), 150) << "Quenched water-side stone must be cooled to 150"; + EXPECT_EQ(cellTemperature(resultB), 150) << "Quenched magma-side stone must be cooled to 150"; + EXPECT_LT(cellTemperature(resultA), 196) << "Must be below R5 melt threshold"; + EXPECT_LT(cellTemperature(resultB), 196) << "Must be below R5 melt threshold"; +} + +// 10. Different frame indices produce different stochastic outcomes for the same chunk. +TEST_F(TransformationPassTest, DifferentFrameIndicesProduceDifferentOutcomes) { + // Place water at temp=80 (below freeze threshold 90, R1 probability=50%) + VoxelCell water = makeCell(4, Phase::Liquid, 100); + setCellTemperature(water, 80); + + const int64_t worldSeed = 12345; + ChunkCoord pos{0, 0, 0}; + uint64_t hash = spatialHash(pos); + + // Simulate what execute() does internally for different frame indices: + // seed = worldSeed ^ spatialHash(pos) ^ frameIndex + int frozenCount = 0; + int unfrozenCount = 0; + constexpr int K_FRAMES = 64; + + for (uint64_t frame = 0; frame < K_FRAMES; ++frame) { + sim.grid().writeCell(16, 16, 16, water); + + TransformationPass pass(sim.ruleEngine(), sim.materials(), sim.grid(), sim.ghostCellManager(), + sim.activityTracker()); + std::mt19937 rng(static_cast(worldSeed ^ hash ^ frame)); + pass.executeChunk(pos, rng); + + VoxelCell result = sim.grid().readFromWriteBuffer(16, 16, 16); + if (result.essenceIdx == 6 && result.phase() == Phase::Solid) { + ++frozenCount; + } else { + ++unfrozenCount; + } + } + + // With 50% probability over 64 frames, both outcomes must occur + EXPECT_GT(frozenCount, 0) << "Some frames should freeze water (50% probability)"; + EXPECT_GT(unfrozenCount, 0) << "Some frames should not freeze water (50% probability)"; +} + +// 11. ProjectionRuleTable contains projected appearances for ice, glass, magma. +TEST_F(TransformationPassTest, ProjectionTablePopulated) { + const auto& table = sim.projectionTable(); + + const auto& ice = table.lookup(6, Phase::Solid); + EXPECT_EQ(ice.baseColor, 0xFFD0E8FFu) << "ICE should have blue-white color"; + EXPECT_EQ(ice.moveType, MoveType::Static); + + const auto& glass = table.lookup(10, Phase::Solid); + EXPECT_EQ(glass.baseColor, 0xFFA08040u) << "GLASS should have amber color"; + EXPECT_EQ(glass.moveType, MoveType::Static); + + const auto& magma = table.lookup(11, Phase::Liquid); + EXPECT_EQ(magma.baseColor, 0xFFFF4400u) << "MAGMA should have orange-red color"; + EXPECT_EQ(magma.moveType, MoveType::Liquid); +} diff --git a/tests/unit/simulation/WorldRuleEngineTest.cc b/tests/unit/simulation/WorldRuleEngineTest.cc new file mode 100644 index 0000000..510eb01 --- /dev/null +++ b/tests/unit/simulation/WorldRuleEngineTest.cc @@ -0,0 +1,203 @@ +#include "recurse/simulation/WorldRuleEngine.hh" +#include + +using namespace recurse::simulation; + +class WorldRuleEngineTest : public ::testing::Test { + protected: + WorldRuleEngine engine; +}; + +// 1. Engine has 8 rules after construction. +TEST_F(WorldRuleEngineTest, DefaultRuleCount) { + EXPECT_EQ(engine.ruleCount(), 8u); +} + +// 2. Query with WATER essence, self-transform, Liquid phase, temp=80 returns freeze rule. +TEST_F(WorldRuleEngineTest, WaterFreezeLookup) { + std::vector results; + engine.query(4, 255, Phase::Liquid, 80, results); + ASSERT_FALSE(results.empty()); + // Find the freeze rule (resultEssenceA == 6, ICE) + bool found = false; + for (const auto& r : results) { + if (r.resultEssenceA == 6 && r.resultPhaseA == Phase::Solid) { + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +// 3. Query with WATER at temp=95 does NOT return freeze rule (R1 requires temp <= 90). +TEST_F(WorldRuleEngineTest, WaterNoFreezeAboveThreshold) { + std::vector results; + engine.query(4, 255, Phase::Liquid, 95, results); + for (const auto& r : results) { + // No rule should produce ICE at this temperature + EXPECT_NE(r.resultEssenceA, 6) << "Freeze rule should not match at temp=95 (R1 requires temp <= 90)"; + } +} + +// 4. Query with ICE, self-transform, Solid, temp=100 returns thaw rule. +TEST_F(WorldRuleEngineTest, IceThawLookup) { + std::vector results; + engine.query(6, 255, Phase::Solid, 100, results); + ASSERT_FALSE(results.empty()); + bool found = false; + for (const auto& r : results) { + if (r.resultEssenceA == 4 && r.resultPhaseA == Phase::Liquid) { + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +// 5. Query with WATER + MAGMA contact returns R7. +TEST_F(WorldRuleEngineTest, WaterMagmaContact) { + std::vector results; + engine.query(4, 11, Phase::Liquid, 100, results); + ASSERT_FALSE(results.empty()); + bool found = false; + for (const auto& r : results) { + if (r.essenceIdxB == 11 && r.resultEssenceA == 1 && r.resultEssenceB == 1) { + found = true; + EXPECT_EQ(r.resultPhaseA, Phase::Solid); + EXPECT_EQ(r.resultPhaseB, Phase::Solid); + EXPECT_EQ(r.probability, 255); + break; + } + } + EXPECT_TRUE(found); +} + +// 6. SAND at temp=150 returns no rules; at temp=200 returns vitrify rule. +TEST_F(WorldRuleEngineTest, TemperatureGating) { + std::vector belowThreshold; + engine.query(3, 255, Phase::Powder, 150, belowThreshold); + EXPECT_TRUE(belowThreshold.empty()); + + std::vector aboveThreshold; + engine.query(3, 255, Phase::Powder, 200, aboveThreshold); + ASSERT_FALSE(aboveThreshold.empty()); + bool found = false; + for (const auto& r : aboveThreshold) { + if (r.resultEssenceA == 10 && r.resultPhaseA == Phase::Solid) { + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +// 7. When multiple rules match, results are sorted by priority descending. +TEST_F(WorldRuleEngineTest, PriorityOrdering) { + // WATER at temp=145 matches R3 (boil, priority=190, tMin=125) and R8 (near-heat, priority=185, tMin=141) + std::vector results; + engine.query(4, 255, Phase::Liquid, 145, results); + ASSERT_GE(results.size(), 2u); + for (size_t i = 1; i < results.size(); ++i) { + EXPECT_GE(results[i - 1].priority, results[i].priority) << "Results must be sorted by priority descending"; + } +} + +// 8. Add a custom rule, verify ruleCount increases and query returns it. +TEST_F(WorldRuleEngineTest, AddCustomRule) { + size_t before = engine.ruleCount(); + WorldRule custom{}; + custom.essenceIdxA = 2; // DIRT + custom.essenceIdxB = 255; + custom.requiredPhaseA = Phase::Solid; + custom.temperatureMin = 200; + custom.temperatureMax = 255; + custom.resultEssenceA = 3; // -> SAND + custom.resultPhaseA = Phase::Powder; + custom.probability = 255; + custom.priority = 150; + custom.tag = 0; + + engine.addRule(custom); + EXPECT_EQ(engine.ruleCount(), before + 1); + + std::vector results; + engine.query(2, 255, Phase::Solid, 210, results); + ASSERT_FALSE(results.empty()); + bool found = false; + for (const auto& r : results) { + if (r.essenceIdxA == 2 && r.resultEssenceA == 3 && r.resultPhaseA == Phase::Powder) { + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +// 9. A rule with essenceIdxA=255 matches any self essence. +TEST_F(WorldRuleEngineTest, WildcardMatch) { + WorldRule wildcard{}; + wildcard.essenceIdxA = 255; + wildcard.essenceIdxB = 255; + wildcard.requiredPhaseA = Phase::Unchanged; // don't care + wildcard.temperatureMin = 0; + wildcard.temperatureMax = 255; + wildcard.resultEssenceA = 0; + wildcard.resultPhaseA = Phase::Empty; + wildcard.probability = 255; + wildcard.priority = 100; + wildcard.tag = 0; + + engine.addRule(wildcard); + + // Should match any essence + std::vector r1; + engine.query(1, 255, Phase::Solid, 100, r1); + bool found1 = false; + for (const auto& r : r1) { + if (r.essenceIdxA == 255 && r.priority == 100) { + found1 = true; + break; + } + } + EXPECT_TRUE(found1); + + std::vector r2; + engine.query(99, 255, Phase::Liquid, 50, r2); + bool found2 = false; + for (const auto& r : r2) { + if (r.essenceIdxA == 255 && r.priority == 100) { + found2 = true; + break; + } + } + EXPECT_TRUE(found2); +} + +// 10. Self-transform rules (essenceIdxB=255) do not match contact queries. +TEST_F(WorldRuleEngineTest, SelfTransformDoesNotMatchContact) { + // Query water touching magma (neighborEssence=11) + std::vector results; + engine.query(4, 11, Phase::Liquid, 80, results); + for (const auto& r : results) { + EXPECT_NE(r.essenceIdxB, 255) + << "Self-transform rule (essenceIdxB=255) must not match contact query with neighborEssence=11"; + } +} + +// 11. Contact rules (essenceIdxB=specific) do not match self-transform queries. +TEST_F(WorldRuleEngineTest, ContactDoesNotMatchSelfTransform) { + // Query water self-transform (neighborEssence=255) + std::vector results; + engine.query(4, 255, Phase::Liquid, 80, results); + for (const auto& r : results) { + if (r.essenceIdxB != 255) { + ADD_FAILURE() << "Contact rule (essenceIdxB=" << static_cast(r.essenceIdxB) + << ") must not match self-transform query with neighborEssence=255"; + } + } +} + +// 12. Verify K_MAX_TRANSFORMS_PER_CHUNK == 64. +TEST_F(WorldRuleEngineTest, BudgetCapConstant) { + EXPECT_EQ(K_MAX_TRANSFORMS_PER_CHUNK, 64); +}