implement tunnels

This commit is contained in:
2026-04-26 17:15:50 +02:00
parent 63c7df5b7f
commit 89005d6bb7
13 changed files with 819 additions and 18 deletions

View File

@@ -12,6 +12,21 @@ player_placeable = true
construction_time_seconds = 1
surface_mask = ["<A>"]
[[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

View File

@@ -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
#

View File

@@ -1,5 +1,6 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
@@ -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<std::string> surfaceMask;
std::optional<int> tunnelMaxDistance;
};
struct BuildingsConfig

View File

@@ -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<int64_t> tmd = mt["tunnel_max_distance"].value<int64_t>())
{
def.tunnelMaxDistance = static_cast<int>(*tmd);
}
const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
if (!parsedType)
{

View File

@@ -10,6 +10,8 @@ std::optional<BuildingType> 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";

View File

@@ -16,6 +16,8 @@ enum class BuildingType
SalvageBay,
Belt,
Splitter,
TunnelEntry,
TunnelExit,
Hq,
PlayerDefenceStation,
EnemyDefenceStation,

View File

@@ -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::SplitterInfo> BeltSystem::getSplitterInfo(QPoint tile)
};
}
// ---------------------------------------------------------------------------
// Tunnel pairing
// ---------------------------------------------------------------------------
void BeltSystem::reevaluateTunnelPairing()
{
std::vector<TunnelLink> oldLinks;
std::swap(oldLinks, m_tunnelLinks);
for (const std::pair<const std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, 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<double>(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<std::pair<int, int>, BeltTile>::iterator it = m_belts.find(key(tile));
if (it == m_belts.end())
const std::map<std::pair<int, int>, BeltTile>::iterator bIt = m_belts.find(key(tile));
if (bIt != m_belts.end())
{
return tryPlaceOnBelt(tile, item);
}
const std::map<std::pair<int, int>, 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<Item> BeltSystem::tryTakeItem(Port port)
@@ -158,6 +267,20 @@ std::optional<Item> BeltSystem::tryTakeItem(Port port)
}
}
const std::map<std::pair<int, int>, 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<ItemType> BeltSystem::peekItem(Port port) const
}
}
const std::map<std::pair<int, int>, 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<QPoint>& tiles)
sIt->second.frontA = std::nullopt;
sIt->second.frontB = std::nullopt;
}
const std::map<std::pair<int, int>, 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<std::pair<int, int>, 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<QPoint>& tiles)
void BeltSystem::tick()
{
advanceProgress();
advanceTunnelProgress();
moveItemsToNextTile();
moveTunnelItems();
routeSplitterItems();
}
@@ -304,6 +470,98 @@ void BeltSystem::advanceProgress()
}
}
void BeltSystem::advanceTunnelProgress()
{
for (std::map<std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, 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<const std::pair<int, int>, 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<const std::pair<int, int>, 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);
}
}
}

View File

@@ -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<int, int> key(QPoint tile);
static QPoint adjacentTile(QPoint tile, Rotation dir);
@@ -126,9 +139,41 @@ private:
std::optional<BeltItemSlot> frontB; // progress [0, 1]; routed to outputB
};
struct TunnelEntryTile
{
Rotation direction;
int maxDistance;
std::optional<BeltItemSlot> front;
std::optional<BeltItemSlot> back;
};
struct TunnelExitTile
{
Rotation direction;
std::optional<BeltItemSlot> front;
std::optional<BeltItemSlot> back;
};
struct TunnelTransitItem
{
Item item;
double progress;
};
struct TunnelLink
{
QPoint entryTile;
QPoint exitTile;
double length;
std::vector<TunnelTransitItem> items; // front (highest progress) to back
};
double m_progressPerTick; // beltSpeedTilesPerSecond / kTickRateHz
std::map<std::pair<int, int>, BeltTile> m_belts;
std::map<std::pair<int, int>, SplitterTile> m_splitters;
std::map<std::pair<int, int>, TunnelEntryTile> m_tunnelEntries;
std::map<std::pair<int, int>, TunnelExitTile> m_tunnelExits;
std::vector<TunnelLink> m_tunnelLinks;
};

View File

@@ -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);
}

View File

@@ -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<Item> 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<Item> 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<Item> 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<Item> taken2 = bs.tryTakeItem(Port{exit, Rotation::East});
REQUIRE(taken2.has_value());
REQUIRE(taken2->type.id == "item2");
}

View File

@@ -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))
{

View File

@@ -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)

View File

@@ -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