From 89005d6bb771375cd2c29974a9b1ef72ed7a3c33 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Sun, 26 Apr 2026 17:15:50 +0200 Subject: [PATCH] implement tunnels --- bin/config/buildings.toml | 15 + bin/config/visuals.toml | 10 + src/lib/config/BuildingsConfig.h | 2 + src/lib/config/ConfigLoader.cpp | 5 + src/lib/core/BuildingType.cpp | 4 + src/lib/core/BuildingType.h | 2 + src/lib/sim/BeltSystem.cpp | 471 ++++++++++++++++++++++++++++++- src/lib/sim/BeltSystem.h | 49 +++- src/lib/sim/BuildingSystem.cpp | 16 +- src/test/BeltSystemTest.cpp | 254 +++++++++++++++++ src/ui/GameWorldView.cpp | 4 +- src/ui/SelectedBuildingPanel.cpp | 3 +- src/ui/VisualsLoader.cpp | 2 + 13 files changed, 819 insertions(+), 18 deletions(-) diff --git a/bin/config/buildings.toml b/bin/config/buildings.toml index 3826aab..ab37609 100644 --- a/bin/config/buildings.toml +++ b/bin/config/buildings.toml @@ -12,6 +12,21 @@ player_placeable = true construction_time_seconds = 1 surface_mask = [""] +[[building]] +id = "tunnel_entry" +cost = 5 +player_placeable = true +construction_time_seconds = 3 +surface_mask = ["A>"] +tunnel_max_distance = 10 + +[[building]] +id = "tunnel_exit" +cost = 5 +player_placeable = true +construction_time_seconds = 3 +surface_mask = ["A>"] + [[building]] id = "miner" cost = 15 diff --git a/bin/config/visuals.toml b/bin/config/visuals.toml index 66f3c4a..0b6282f 100644 --- a/bin/config/visuals.toml +++ b/bin/config/visuals.toml @@ -72,6 +72,16 @@ fill = "#7a7a5a" outline = "#9a9a7a" glyph = "" +[buildings.tunnel_entry] +fill = "#4a6a5a" +outline = "#8aaa9a" +glyph = "Te" + +[buildings.tunnel_exit] +fill = "#5a6a4a" +outline = "#9aaa8a" +glyph = "Tx" + # ----------------------------------------------------------------------------- # Stations # diff --git a/src/lib/config/BuildingsConfig.h b/src/lib/config/BuildingsConfig.h index c6fac17..32ea72e 100644 --- a/src/lib/config/BuildingsConfig.h +++ b/src/lib/config/BuildingsConfig.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -18,6 +19,7 @@ struct BuildingDef // Stored as raw strings here; parsing into per-cell tiles + output ports // happens when buildings are placed, not at load time. std::vector surfaceMask; + std::optional tunnelMaxDistance; }; struct BuildingsConfig diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 4dbeda7..366b3dd 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -275,6 +275,11 @@ BuildingsConfig ConfigLoader::loadBuildings(const std::string& path) def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds"); def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); + if (const std::optional tmd = mt["tunnel_max_distance"].value()) + { + def.tunnelMaxDistance = static_cast(*tmd); + } + const std::optional parsedType = parseBuildingType(def.id); if (!parsedType) { diff --git a/src/lib/core/BuildingType.cpp b/src/lib/core/BuildingType.cpp index b8ce802..7b3ed7f 100644 --- a/src/lib/core/BuildingType.cpp +++ b/src/lib/core/BuildingType.cpp @@ -10,6 +10,8 @@ std::optional parseBuildingType(const std::string& id) if (id == "salvage_bay") { return BuildingType::SalvageBay; } if (id == "belt") { return BuildingType::Belt; } if (id == "splitter") { return BuildingType::Splitter; } + if (id == "tunnel_entry") { return BuildingType::TunnelEntry; } + if (id == "tunnel_exit") { return BuildingType::TunnelExit; } if (id == "hq") { return BuildingType::Hq; } if (id == "player_defence_station") { return BuildingType::PlayerDefenceStation; } if (id == "enemy_defence_station") { return BuildingType::EnemyDefenceStation; } @@ -28,6 +30,8 @@ std::string buildingTypeId(BuildingType type) case BuildingType::SalvageBay: return "salvage_bay"; case BuildingType::Belt: return "belt"; case BuildingType::Splitter: return "splitter"; + case BuildingType::TunnelEntry: return "tunnel_entry"; + case BuildingType::TunnelExit: return "tunnel_exit"; case BuildingType::Hq: return "hq"; case BuildingType::PlayerDefenceStation: return "player_defence_station"; case BuildingType::EnemyDefenceStation: return "enemy_defence_station"; diff --git a/src/lib/core/BuildingType.h b/src/lib/core/BuildingType.h index 93da8ea..885a9c0 100644 --- a/src/lib/core/BuildingType.h +++ b/src/lib/core/BuildingType.h @@ -16,6 +16,8 @@ enum class BuildingType SalvageBay, Belt, Splitter, + TunnelEntry, + TunnelExit, Hq, PlayerDefenceStation, EnemyDefenceStation, diff --git a/src/lib/sim/BeltSystem.cpp b/src/lib/sim/BeltSystem.cpp index 5eefa50..9d18416 100644 --- a/src/lib/sim/BeltSystem.cpp +++ b/src/lib/sim/BeltSystem.cpp @@ -70,10 +70,39 @@ void BeltSystem::placeSplitter(QPoint tile, Rotation outputA, Rotation outputB) m_splitters[key(tile)] = st; } -void BeltSystem::removeTile(QPoint tile) +void BeltSystem::placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance) { m_belts.erase(key(tile)); m_splitters.erase(key(tile)); + m_tunnelExits.erase(key(tile)); + TunnelEntryTile te; + te.direction = direction; + te.maxDistance = maxDistance; + m_tunnelEntries[key(tile)] = te; + reevaluateTunnelPairing(); +} + +void BeltSystem::placeTunnelExit(QPoint tile, Rotation direction) +{ + m_belts.erase(key(tile)); + m_splitters.erase(key(tile)); + m_tunnelEntries.erase(key(tile)); + TunnelExitTile tx; + tx.direction = direction; + m_tunnelExits[key(tile)] = tx; + reevaluateTunnelPairing(); +} + +void BeltSystem::removeTile(QPoint tile) +{ + const bool wasTunnel = (m_tunnelEntries.erase(key(tile)) > 0) + | (m_tunnelExits.erase(key(tile)) > 0); + m_belts.erase(key(tile)); + m_splitters.erase(key(tile)); + if (wasTunnel) + { + reevaluateTunnelPairing(); + } } void BeltSystem::setSplitterFilters(QPoint tile, @@ -105,18 +134,98 @@ std::optional BeltSystem::getSplitterInfo(QPoint tile) }; } +// --------------------------------------------------------------------------- +// Tunnel pairing +// --------------------------------------------------------------------------- + +void BeltSystem::reevaluateTunnelPairing() +{ + std::vector oldLinks; + std::swap(oldLinks, m_tunnelLinks); + + for (const std::pair, TunnelEntryTile>& entry : m_tunnelEntries) + { + const QPoint entryPos(entry.first.first, entry.first.second); + const Rotation dir = entry.second.direction; + const int maxDist = entry.second.maxDistance; + + for (int d = 1; d <= maxDist; ++d) + { + QPoint probe = entryPos; + for (int step = 0; step < d; ++step) + { + probe = adjacentTile(probe, dir); + } + + // Check if a same-direction tunnel entry is here (blocks pairing) + const std::map, TunnelEntryTile>::const_iterator teIt = + m_tunnelEntries.find(key(probe)); + if (teIt != m_tunnelEntries.end() && teIt->second.direction == dir) + { + break; + } + + // Check if a same-direction tunnel exit is here (forms pair) + const std::map, TunnelExitTile>::const_iterator txIt = + m_tunnelExits.find(key(probe)); + if (txIt != m_tunnelExits.end()) + { + if (txIt->second.direction == dir) + { + TunnelLink link; + link.entryTile = entryPos; + link.exitTile = probe; + link.length = static_cast(d); + + for (const TunnelLink& old : oldLinks) + { + if (old.entryTile == entryPos && old.exitTile == probe) + { + link.items = old.items; + break; + } + } + + m_tunnelLinks.push_back(std::move(link)); + break; + } + // Different direction exit — skip, keep searching + } + } + } +} + // --------------------------------------------------------------------------- // Port interface // --------------------------------------------------------------------------- bool BeltSystem::tryPutItem(QPoint tile, Item item) { - const std::map, BeltTile>::iterator it = m_belts.find(key(tile)); - if (it == m_belts.end()) + const std::map, BeltTile>::iterator bIt = m_belts.find(key(tile)); + if (bIt != m_belts.end()) { + return tryPlaceOnBelt(tile, item); + } + + const std::map, TunnelEntryTile>::iterator teIt = + m_tunnelEntries.find(key(tile)); + if (teIt != m_tunnelEntries.end()) + { + TunnelEntryTile& te = teIt->second; + if (!te.front) + { + te.front = BeltItemSlot{item, 0.0}; + return true; + } + if (!te.back) + { + te.back = BeltItemSlot{item, 0.0}; + return true; + } return false; } - return tryPlaceOnBelt(tile, item); + + return false; } std::optional BeltSystem::tryTakeItem(Port port) @@ -158,6 +267,20 @@ std::optional BeltSystem::tryTakeItem(Port port) } } + const std::map, TunnelExitTile>::iterator txIt = + m_tunnelExits.find(key(port.tile)); + if (txIt != m_tunnelExits.end()) + { + TunnelExitTile& tx = txIt->second; + if (tx.direction == port.direction && tx.front && tx.front->progress >= 1.0) + { + const Item taken = tx.front->item; + tx.front = tx.back; + tx.back = std::nullopt; + return taken; + } + } + return std::nullopt; } @@ -194,6 +317,17 @@ std::optional BeltSystem::peekItem(Port port) const } } + const std::map, TunnelExitTile>::const_iterator txIt = + m_tunnelExits.find(key(port.tile)); + if (txIt != m_tunnelExits.end()) + { + const TunnelExitTile& tx = txIt->second; + if (tx.direction == port.direction && tx.front && tx.front->progress >= 1.0) + { + return tx.front->item.type; + } + } + return std::nullopt; } @@ -219,6 +353,36 @@ void BeltSystem::clearTiles(const std::vector& tiles) sIt->second.frontA = std::nullopt; sIt->second.frontB = std::nullopt; } + + const std::map, TunnelEntryTile>::iterator teIt = + m_tunnelEntries.find(key(tile)); + if (teIt != m_tunnelEntries.end()) + { + teIt->second.front = std::nullopt; + teIt->second.back = std::nullopt; + for (TunnelLink& link : m_tunnelLinks) + { + if (link.entryTile == tile) + { + link.items.clear(); + } + } + } + + const std::map, TunnelExitTile>::iterator txIt = + m_tunnelExits.find(key(tile)); + if (txIt != m_tunnelExits.end()) + { + txIt->second.front = std::nullopt; + txIt->second.back = std::nullopt; + for (TunnelLink& link : m_tunnelLinks) + { + if (link.exitTile == tile) + { + link.items.clear(); + } + } + } } } @@ -229,7 +393,9 @@ void BeltSystem::clearTiles(const std::vector& tiles) void BeltSystem::tick() { advanceProgress(); + advanceTunnelProgress(); moveItemsToNextTile(); + moveTunnelItems(); routeSplitterItems(); } @@ -304,6 +470,98 @@ void BeltSystem::advanceProgress() } } +void BeltSystem::advanceTunnelProgress() +{ + for (std::map, TunnelEntryTile>::iterator it = m_tunnelEntries.begin(); + it != m_tunnelEntries.end(); ++it) + { + TunnelEntryTile& te = it->second; + + if (te.front) + { + te.front->progress += m_progressPerTick; + if (te.front->progress > 1.0) + { + te.front->progress = 1.0; + } + } + + if (te.back) + { + te.back->progress += m_progressPerTick; + if (te.front && te.back->progress >= te.front->progress) + { + te.back->progress = te.front->progress - m_progressPerTick; + if (te.back->progress < 0.0) + { + te.back->progress = 0.0; + } + } + if (te.back->progress > 0.5) + { + te.back->progress = 0.5; + } + } + } + + for (std::map, TunnelExitTile>::iterator it = m_tunnelExits.begin(); + it != m_tunnelExits.end(); ++it) + { + TunnelExitTile& tx = it->second; + + if (tx.front) + { + tx.front->progress += m_progressPerTick; + if (tx.front->progress > 1.0) + { + tx.front->progress = 1.0; + } + } + + if (tx.back) + { + tx.back->progress += m_progressPerTick; + if (tx.front && tx.back->progress >= tx.front->progress) + { + tx.back->progress = tx.front->progress - m_progressPerTick; + if (tx.back->progress < 0.0) + { + tx.back->progress = 0.0; + } + } + if (tx.back->progress > 0.5) + { + tx.back->progress = 0.5; + } + } + } + + for (TunnelLink& link : m_tunnelLinks) + { + for (std::size_t i = 0; i < link.items.size(); ++i) + { + TunnelTransitItem& ti = link.items[i]; + ti.progress += m_progressPerTick; + if (ti.progress > link.length) + { + ti.progress = link.length; + } + if (i > 0) + { + const double maxProgress = link.items[i - 1].progress - 0.5; + if (ti.progress > maxProgress) + { + ti.progress = maxProgress; + if (ti.progress < 0.0) + { + ti.progress = 0.0; + } + } + } + } + } +} + void BeltSystem::moveItemsToNextTile() { // Belt items advancing into the next tile. @@ -340,11 +598,25 @@ void BeltSystem::moveItemsToNextTile() bt.front = bt.back; bt.back = std::nullopt; } - // else: splitter back occupied — item stays blocked at progress 1.0. } - // else: no tile registered (e.g. open space, or building input port). - // Items leaving into unregistered tiles are not consumed here — the - // building pull step uses tryTakeItem for that. + else + { + const std::map, TunnelEntryTile>::iterator nextEntry = + m_tunnelEntries.find(key(next)); + if (nextEntry != m_tunnelEntries.end() && !nextEntry->second.back) + { + if (!nextEntry->second.front) + { + nextEntry->second.front = BeltItemSlot{bt.front->item, 0.0}; + } + else + { + nextEntry->second.back = BeltItemSlot{bt.front->item, 0.0}; + } + bt.front = bt.back; + bt.back = std::nullopt; + } + } } // Splitter front slots advancing into downstream belt tiles. @@ -357,23 +629,89 @@ void BeltSystem::moveItemsToNextTile() if (st.frontA && st.frontA->progress >= 1.0) { const QPoint dest = adjacentTile(here, st.outputA); - if (tryPlaceOnBelt(dest, st.frontA->item)) + if (tryPushToTile(dest, st.frontA->item, st.outputA)) { st.frontA = std::nullopt; } - // else: downstream belt full or absent — item stays at progress 1.0 - // for building pickup via tryTakeItem. } if (st.frontB && st.frontB->progress >= 1.0) { const QPoint dest = adjacentTile(here, st.outputB); - if (tryPlaceOnBelt(dest, st.frontB->item)) + if (tryPushToTile(dest, st.frontB->item, st.outputB)) { st.frontB = std::nullopt; } } } + + // Tunnel exit items advancing into downstream tiles. + for (std::map, TunnelExitTile>::iterator it = m_tunnelExits.begin(); + it != m_tunnelExits.end(); ++it) + { + TunnelExitTile& tx = it->second; + if (!tx.front || tx.front->progress < 1.0) + { + continue; + } + + const QPoint here = QPoint(it->first.first, it->first.second); + const QPoint next = adjacentTile(here, tx.direction); + if (tryPushToTile(next, tx.front->item, tx.direction)) + { + tx.front = tx.back; + tx.back = std::nullopt; + } + } +} + +void BeltSystem::moveTunnelItems() +{ + for (TunnelLink& link : m_tunnelLinks) + { + // Entry front → transit + const std::map, TunnelEntryTile>::iterator teIt = + m_tunnelEntries.find(key(link.entryTile)); + if (teIt != m_tunnelEntries.end()) + { + TunnelEntryTile& te = teIt->second; + if (te.front && te.front->progress >= 1.0) + { + const bool canEnter = link.items.empty() + || link.items.back().progress >= 0.5; + if (canEnter) + { + TunnelTransitItem ti; + ti.item = te.front->item; + ti.progress = 0.0; + link.items.push_back(ti); + te.front = te.back; + te.back = std::nullopt; + } + } + } + + // Transit front → exit back + if (!link.items.empty() && link.items.front().progress >= link.length) + { + const std::map, TunnelExitTile>::iterator txIt = + m_tunnelExits.find(key(link.exitTile)); + if (txIt != m_tunnelExits.end()) + { + TunnelExitTile& tx = txIt->second; + if (!tx.back && !tx.front) + { + tx.front = BeltItemSlot{link.items.front().item, 0.0}; + link.items.erase(link.items.begin()); + } + else if (!tx.back && tx.front) + { + tx.back = BeltItemSlot{link.items.front().item, 0.0}; + link.items.erase(link.items.begin()); + } + } + } + } } void BeltSystem::routeSplitterItems() @@ -474,6 +812,65 @@ bool BeltSystem::tryPlaceOnBelt(QPoint tile, Item item) return false; // both slots occupied } +bool BeltSystem::tryPushToTile(QPoint dest, Item item, Rotation fromDir) +{ + if (tryPlaceOnBelt(dest, item)) + { + return true; + } + + const std::map, SplitterTile>::iterator splIt = + m_splitters.find(key(dest)); + if (splIt != m_splitters.end()) + { + if (!splIt->second.back) + { + splIt->second.back = BeltItemSlot{item, 0.0}; + splIt->second.backDir = fromDir; + return true; + } + return false; + } + + const std::map, TunnelEntryTile>::iterator teIt = + m_tunnelEntries.find(key(dest)); + if (teIt != m_tunnelEntries.end()) + { + TunnelEntryTile& te = teIt->second; + if (!te.front) + { + te.front = BeltItemSlot{item, 0.0}; + return true; + } + if (!te.back) + { + te.back = BeltItemSlot{item, 0.0}; + return true; + } + return false; + } + + const std::map, TunnelExitTile>::iterator txIt = + m_tunnelExits.find(key(dest)); + if (txIt != m_tunnelExits.end()) + { + TunnelExitTile& tx = txIt->second; + if (!tx.front) + { + tx.front = BeltItemSlot{item, 0.0}; + return true; + } + if (!tx.back) + { + tx.back = BeltItemSlot{item, 0.0}; + return true; + } + return false; + } + + return false; +} + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- @@ -542,4 +939,54 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles, visit(vi); } } + + for (const std::pair, TunnelEntryTile>& entry : m_tunnelEntries) + { + const QPoint tile(entry.first.first, entry.first.second); + if (!viewportTiles.contains(tile)) + { + continue; + } + + const TunnelEntryTile& te = entry.second; + if (te.front) + { + VisualItem vi; + vi.type = te.front->item.type; + vi.worldPos = slotWorldPos(tile, te.direction, te.front->progress); + visit(vi); + } + if (te.back) + { + VisualItem vi; + vi.type = te.back->item.type; + vi.worldPos = slotWorldPos(tile, te.direction, te.back->progress); + visit(vi); + } + } + + for (const std::pair, TunnelExitTile>& entry : m_tunnelExits) + { + const QPoint tile(entry.first.first, entry.first.second); + if (!viewportTiles.contains(tile)) + { + continue; + } + + const TunnelExitTile& tx = entry.second; + if (tx.front) + { + VisualItem vi; + vi.type = tx.front->item.type; + vi.worldPos = slotWorldPos(tile, tx.direction, tx.front->progress); + visit(vi); + } + if (tx.back) + { + VisualItem vi; + vi.type = tx.back->item.type; + vi.worldPos = slotWorldPos(tile, tx.direction, tx.back->progress); + visit(vi); + } + } } diff --git a/src/lib/sim/BeltSystem.h b/src/lib/sim/BeltSystem.h index 2fcfbb8..854ed89 100644 --- a/src/lib/sim/BeltSystem.h +++ b/src/lib/sim/BeltSystem.h @@ -37,6 +37,12 @@ public: // Register a new belt tile. Any items already on this tile are cleared. void placeBelt(QPoint tile, Rotation direction); + // Register a new tunnel entry tile. + void placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance); + + // Register a new tunnel exit tile. + void placeTunnelExit(QPoint tile, Rotation direction); + // Register a new splitter tile. outputA and outputB are the two exit // directions (e.g. West and East for a default-rotation splitter). // Items entering from any adjacent belt whose direction points into this @@ -87,13 +93,20 @@ public: private: void advanceProgress(); + void advanceTunnelProgress(); void moveItemsToNextTile(); + void moveTunnelItems(); void routeSplitterItems(); // Place item into back slot of an existing belt tile at progress 0. // Returns false if tile is not a belt or is full. bool tryPlaceOnBelt(QPoint tile, Item item); + // Push an item to any tile type (belt, splitter, tunnel entry, tunnel exit). + bool tryPushToTile(QPoint dest, Item item, Rotation fromDir); + + void reevaluateTunnelPairing(); + static std::pair key(QPoint tile); static QPoint adjacentTile(QPoint tile, Rotation dir); @@ -126,9 +139,41 @@ private: std::optional frontB; // progress [0, 1]; routed to outputB }; + struct TunnelEntryTile + { + Rotation direction; + int maxDistance; + std::optional front; + std::optional back; + }; + + struct TunnelExitTile + { + Rotation direction; + std::optional front; + std::optional back; + }; + + struct TunnelTransitItem + { + Item item; + double progress; + }; + + struct TunnelLink + { + QPoint entryTile; + QPoint exitTile; + double length; + std::vector items; // front (highest progress) to back + }; + double m_progressPerTick; // beltSpeedTilesPerSecond / kTickRateHz - std::map, BeltTile> m_belts; - std::map, SplitterTile> m_splitters; + std::map, BeltTile> m_belts; + std::map, SplitterTile> m_splitters; + std::map, TunnelEntryTile> m_tunnelEntries; + std::map, TunnelExitTile> m_tunnelExits; + std::vector m_tunnelLinks; }; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index b5c7104..c25fc9b 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -269,7 +269,8 @@ int BuildingSystem::demolish(EntityId id) { if (it->id == id) { - if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter) + if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter + || it->type == BuildingType::TunnelEntry || it->type == BuildingType::TunnelExit) { m_belts.removeTile(it->anchor); } @@ -424,6 +425,16 @@ void BuildingSystem::tickConstruction(Tick currentTick) mask.outputPorts[0].direction, mask.outputPorts[1].direction); } + else if (front.type == BuildingType::TunnelEntry) + { + const BuildingDef* bdef = findBuildingDef(front.type); + const int maxDist = (bdef && bdef->tunnelMaxDistance) ? *bdef->tunnelMaxDistance : 0; + m_belts.placeTunnelEntry(front.anchor, front.rotation, maxDist); + } + else if (front.type == BuildingType::TunnelExit) + { + m_belts.placeTunnelExit(front.anchor, front.rotation); + } m_buildings.push_back(std::move(building)); @@ -902,7 +913,8 @@ bool BuildingSystem::removeBuilding(EntityId id) { if (it->id == id) { - if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter) + if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter + || it->type == BuildingType::TunnelEntry || it->type == BuildingType::TunnelExit) { m_belts.removeTile(it->anchor); } diff --git a/src/test/BeltSystemTest.cpp b/src/test/BeltSystemTest.cpp index cf16203..a01006d 100644 --- a/src/test/BeltSystemTest.cpp +++ b/src/test/BeltSystemTest.cpp @@ -660,3 +660,257 @@ TEST_CASE("BeltSystem: splitter alternates between two unregistered outputs (bui bs.tick(); REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); } + +// --------------------------------------------------------------------------- +// Tunnel — pairing +// --------------------------------------------------------------------------- + +TEST_CASE("BeltSystem: tunnel pairing — basic pair within max distance", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + const QPoint entry(0, 0); + const QPoint exit(3, 0); + + bs.placeTunnelEntry(entry, Rotation::East, 10); + bs.placeTunnelExit(exit, Rotation::East); + + bs.tryPutItem(entry, makeItem("iron_ore")); + + // With kFastBeltSpeed, items cross one tile per tick. + // entry tile: 1 tick to reach front progress 1.0 + // transit: 3 tiles distance → 3 ticks + // exit tile: 1 tick to reach front progress 1.0 + // Total: 1 (entry) + 1 (entry→transit) + 3 (transit) + 1 (transit→exit) + 1 (exit advance) = ~5-7 ticks + + for (int i = 0; i < 20; ++i) + { + bs.tick(); + } + + REQUIRE(bs.peekItem(Port{exit, Rotation::East}).has_value()); + const std::optional taken = bs.tryTakeItem(Port{exit, Rotation::East}); + REQUIRE(taken.has_value()); + REQUIRE(taken->type.id == "iron_ore"); +} + +TEST_CASE("BeltSystem: tunnel pairing — wrong direction prevents pair", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10); + bs.placeTunnelExit(QPoint(3, 0), Rotation::North); + + bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore")); + + for (int i = 0; i < 20; ++i) + { + bs.tick(); + } + + // Exit faces North, not East — no pair formed, item stuck in entry. + REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::North}).has_value()); +} + +TEST_CASE("BeltSystem: tunnel pairing — beyond max distance prevents pair", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 2); + bs.placeTunnelExit(QPoint(3, 0), Rotation::East); + + bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore")); + + for (int i = 0; i < 20; ++i) + { + bs.tick(); + } + + REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value()); +} + +TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing", "[belt]") +{ + // Entry1 at (0,0) East, Entry2 at (2,0) East, Exit at (4,0) East. + // Entry2 is closer to Exit → Entry2 pairs with Exit; Entry1 is blocked by Entry2. + BeltSystem bs(kFastBeltSpeed); + + bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10); + bs.placeTunnelEntry(QPoint(2, 0), Rotation::East, 10); + bs.placeTunnelExit(QPoint(4, 0), Rotation::East); + + // Put item on Entry2 — should reach exit. + bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore")); + for (int i = 0; i < 20; ++i) + { + bs.tick(); + } + REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value()); + bs.tryTakeItem(Port{QPoint(4, 0), Rotation::East}); + + // Put item on Entry1 — should NOT reach exit (Entry1 is unpaired). + bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore")); + for (int i = 0; i < 20; ++i) + { + bs.tick(); + } + REQUIRE_FALSE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value()); +} + +TEST_CASE("BeltSystem: tunnel pairing — cross-dir entry between is ignored", "[belt]") +{ + // Entry1 at (0,0) East, Entry2 at (2,0) North (different dir), Exit at (4,0) East. + // Entry2 faces North → ignored → Entry1 pairs with Exit. + BeltSystem bs(kFastBeltSpeed); + + bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10); + bs.placeTunnelEntry(QPoint(2, 0), Rotation::North, 10); + bs.placeTunnelExit(QPoint(4, 0), Rotation::East); + + bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore")); + for (int i = 0; i < 20; ++i) + { + bs.tick(); + } + REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value()); +} + +// --------------------------------------------------------------------------- +// Tunnel — item transit +// --------------------------------------------------------------------------- + +TEST_CASE("BeltSystem: unpaired entry blocks items at front", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10); + // No exit placed — entry is unpaired. + + bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore")); + for (int i = 0; i < 10; ++i) + { + bs.tick(); + } + + // Item should not vanish — it stays in the entry. + // We can verify by placing an exit and seeing item eventually arrive. + bs.placeTunnelExit(QPoint(3, 0), Rotation::East); + for (int i = 0; i < 20; ++i) + { + bs.tick(); + } + REQUIRE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value()); +} + +TEST_CASE("BeltSystem: demolish entry discards transit items", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + const QPoint entry(0, 0); + const QPoint exit(5, 0); + + bs.placeTunnelEntry(entry, Rotation::East, 10); + bs.placeTunnelExit(exit, Rotation::East); + + bs.tryPutItem(entry, makeItem("iron_ore")); + + // Advance just enough for item to enter transit but not reach exit. + bs.tick(); // item enters entry front + bs.tick(); // entry front → transit (progress 0) + + bs.removeTile(entry); + + // Even with many more ticks, nothing arrives at exit. + for (int i = 0; i < 30; ++i) + { + bs.tick(); + } + REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value()); +} + +TEST_CASE("BeltSystem: clearTiles discards tunnel transit items", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + const QPoint entry(0, 0); + const QPoint exit(5, 0); + + bs.placeTunnelEntry(entry, Rotation::East, 10); + bs.placeTunnelExit(exit, Rotation::East); + + bs.tryPutItem(entry, makeItem("iron_ore")); + bs.tick(); + bs.tick(); + + bs.clearTiles({entry}); + + for (int i = 0; i < 30; ++i) + { + bs.tick(); + } + REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value()); +} + +TEST_CASE("BeltSystem: belt to entry to transit to exit to belt full chain", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + const QPoint beltIn(0, 0); + const QPoint entry(1, 0); + const QPoint exit(4, 0); + const QPoint beltOut(5, 0); + + bs.placeBelt(beltIn, Rotation::East); + bs.placeTunnelEntry(entry, Rotation::East, 10); + bs.placeTunnelExit(exit, Rotation::East); + bs.placeBelt(beltOut, Rotation::East); + + bs.tryPutItem(beltIn, makeItem("iron_ore")); + + for (int i = 0; i < 30; ++i) + { + bs.tick(); + } + + // Item should have arrived on beltOut. + REQUIRE(bs.peekItem(eastPort(beltOut)).has_value()); + const std::optional taken = bs.tryTakeItem(eastPort(beltOut)); + REQUIRE(taken.has_value()); + REQUIRE(taken->type.id == "iron_ore"); +} + +TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]") +{ + BeltSystem bs(kFastBeltSpeed); + + const QPoint entry(0, 0); + const QPoint exit(3, 0); + + bs.placeTunnelEntry(entry, Rotation::East, 10); + bs.placeTunnelExit(exit, Rotation::East); + + bs.tryPutItem(entry, makeItem("item1")); + bs.tick(); + bs.tick(); // item1 enters transit + + bs.tryPutItem(entry, makeItem("item2")); + + for (int i = 0; i < 30; ++i) + { + bs.tick(); + } + + // item1 should arrive first. + const std::optional taken1 = bs.tryTakeItem(Port{exit, Rotation::East}); + REQUIRE(taken1.has_value()); + REQUIRE(taken1->type.id == "item1"); + + for (int i = 0; i < 10; ++i) + { + bs.tick(); + } + + const std::optional taken2 = bs.tryTakeItem(Port{exit, Rotation::East}); + REQUIRE(taken2.has_value()); + REQUIRE(taken2->type.id == "item2"); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 353eee0..55805e5 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -481,7 +481,9 @@ void GameWorldView::placeAtTile(QPoint tile) } } } - else if (type == BuildingType::Splitter) + else if (type == BuildingType::Splitter + || type == BuildingType::TunnelEntry + || type == BuildingType::TunnelExit) { if (!m_sim->buildings().isTileOccupied(tile)) { diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index ed59102..52d04bc 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -59,7 +59,8 @@ bool isProductionBuilding(BuildingType type) bool isBeltLike(BuildingType type) { - return type == BuildingType::Belt || type == BuildingType::Splitter; + return type == BuildingType::Belt || type == BuildingType::Splitter + || type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit; } QString rotationLabel(Rotation r) diff --git a/src/ui/VisualsLoader.cpp b/src/ui/VisualsLoader.cpp index 1378728..def83f7 100644 --- a/src/ui/VisualsLoader.cpp +++ b/src/ui/VisualsLoader.cpp @@ -124,6 +124,8 @@ const BuildingEntry kBuildingEntries[] = { { "salvage_bay", BuildingType::SalvageBay }, { "belt", BuildingType::Belt }, { "splitter", BuildingType::Splitter }, + { "tunnel_entry", BuildingType::TunnelEntry }, + { "tunnel_exit", BuildingType::TunnelExit }, }; } // namespace