While a Tunnel Entry or Exit (building or construction site) is selected, mark its connection green in the game world — the entry tile, exit tile, and tiles between — reusing the tunnel_preview color from the placement preview. Unpaired tunnels show no green. - Add core findTunnelPartner(): the matching end of an existing tunnel tile, reusing firstTunnelFacing so the pairing scan stays single-sourced; unit-tested in TunnelCompletionTest. - GameWorldView: extract the built+site tunnel index into collectTunnelTiles() (shared with updateTunnelGhost) and add drawSelectedTunnelConnections(), called from drawOverlays. Tiles are deduped via a set so a connection selected from both ends fills once. - Also commits the REQ-BLD-TUNNEL-SELECT-HIGHLIGHT requirement text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
249 lines
9.6 KiB
C++
249 lines
9.6 KiB
C++
#include "catch.hpp"
|
|
|
|
#include <map>
|
|
#include <optional>
|
|
#include <utility>
|
|
|
|
#include <QPoint>
|
|
#include <QVector2D>
|
|
|
|
#include "BuildingType.h"
|
|
#include "Rotation.h"
|
|
#include "TunnelCompletion.h"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
namespace
|
|
{
|
|
|
|
using TunnelMap = std::map<std::pair<int, int>, TunnelTileInfo>;
|
|
|
|
TunnelLookup makeLookup(const TunnelMap& tunnels)
|
|
{
|
|
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
|
|
{
|
|
const TunnelMap::const_iterator it = tunnels.find({tile.x(), tile.y()});
|
|
if (it == tunnels.end()) { return std::nullopt; }
|
|
return it->second;
|
|
};
|
|
}
|
|
|
|
// Cursor at the centre of a tile.
|
|
QVector2D tileCentre(QPoint tile)
|
|
{
|
|
return QVector2D(static_cast<float>(tile.x()) + 0.5f,
|
|
static_cast<float>(tile.y()) + 0.5f);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// No match
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("No tunnels: ghost defaults to a Tunnel Entry with no partner")
|
|
{
|
|
const TunnelMap tunnels;
|
|
const TunnelCompletion result = resolveTunnelCompletion(
|
|
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
|
|
|
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
|
REQUIRE(!result.partnerTile.has_value());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry-completion: an existing exit ahead keeps the ghost an entry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Existing exit ahead completes as an entry")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
|
|
|
const TunnelCompletion result = resolveTunnelCompletion(
|
|
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
|
|
|
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
|
REQUIRE(result.partnerTile.has_value());
|
|
REQUIRE(*result.partnerTile == QPoint(8, 5));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exit-completion: an existing entry behind flips the ghost to an exit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Existing entry behind completes as an exit")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
|
|
|
const TunnelCompletion result = resolveTunnelCompletion(
|
|
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
|
|
|
REQUIRE(result.resolvedType == BuildingType::TunnelExit);
|
|
REQUIRE(result.partnerTile.has_value());
|
|
REQUIRE(*result.partnerTile == QPoint(2, 5));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Both matches: the sub-tile cursor position breaks the tie
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Both matches resolve to the partner nearer the sub-tile cursor")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{3, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // behind
|
|
tunnels[{7, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // ahead
|
|
|
|
const TunnelLookup lookup = makeLookup(tunnels);
|
|
|
|
SECTION("cursor near the left edge completes the entry (ghost becomes exit)")
|
|
{
|
|
const QVector2D cursor(5.1f, 5.5f);
|
|
const TunnelCompletion result =
|
|
resolveTunnelCompletion(lookup, QPoint(5, 5), Rotation::East, 10, cursor);
|
|
REQUIRE(result.resolvedType == BuildingType::TunnelExit);
|
|
REQUIRE(*result.partnerTile == QPoint(3, 5));
|
|
}
|
|
|
|
SECTION("cursor near the right edge completes the exit (ghost stays entry)")
|
|
{
|
|
const QVector2D cursor(5.9f, 5.5f);
|
|
const TunnelCompletion result =
|
|
resolveTunnelCompletion(lookup, QPoint(5, 5), Rotation::East, 10, cursor);
|
|
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
|
REQUIRE(*result.partnerTile == QPoint(7, 5));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A same-direction tunnel between the ghost and a candidate blocks the pairing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("A same-direction entry between the ghost and an exit blocks completion")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{7, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // blocker
|
|
tunnels[{9, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
|
|
|
const TunnelCompletion result = resolveTunnelCompletion(
|
|
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
|
|
|
// The forward scan stops at the same-direction entry, which is not an exit, so no
|
|
// entry-completion. Nothing behind, so no exit-completion either.
|
|
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
|
REQUIRE(!result.partnerTile.has_value());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Differently-facing tunnels are skipped by the scan
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("A differently-facing tunnel ahead is skipped, not treated as a match")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::North}; // skipped
|
|
tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // match
|
|
|
|
const TunnelCompletion result = resolveTunnelCompletion(
|
|
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
|
|
|
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
|
REQUIRE(*result.partnerTile == QPoint(8, 5));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Distance limit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("A candidate beyond the max distance is not matched")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // distance 3
|
|
|
|
const TunnelCompletion result = resolveTunnelCompletion(
|
|
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 2, tileCentre(QPoint(5, 5)));
|
|
|
|
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
|
REQUIRE(!result.partnerTile.has_value());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// findTunnelPartner: the matching end of an existing selected tunnel
|
|
// (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("findTunnelPartner: a selected entry finds its exit ahead")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
|
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
|
|
|
const std::optional<QPoint> partner = findTunnelPartner(
|
|
makeLookup(tunnels), QPoint(2, 5), BuildingType::TunnelEntry, Rotation::East, 10);
|
|
|
|
REQUIRE(partner.has_value());
|
|
REQUIRE(*partner == QPoint(6, 5));
|
|
}
|
|
|
|
TEST_CASE("findTunnelPartner: a selected exit finds its entry behind")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
|
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
|
|
|
const std::optional<QPoint> partner = findTunnelPartner(
|
|
makeLookup(tunnels), QPoint(6, 5), BuildingType::TunnelExit, Rotation::East, 10);
|
|
|
|
REQUIRE(partner.has_value());
|
|
REQUIRE(*partner == QPoint(2, 5));
|
|
}
|
|
|
|
TEST_CASE("findTunnelPartner: an unpaired tunnel has no partner")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
|
|
|
REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5),
|
|
BuildingType::TunnelEntry, Rotation::East, 10).has_value());
|
|
}
|
|
|
|
TEST_CASE("findTunnelPartner: a same-direction tunnel between blocks the pairing")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // selected
|
|
tunnels[{4, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // blocker
|
|
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
|
|
|
// The scan from the selected entry stops at the same-direction entry, which is not
|
|
// an exit, so the exit beyond it is not paired.
|
|
REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5),
|
|
BuildingType::TunnelEntry, Rotation::East, 10).has_value());
|
|
}
|
|
|
|
TEST_CASE("findTunnelPartner: a differently-facing tunnel is skipped")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
|
tunnels[{4, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::North}; // skipped
|
|
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // match
|
|
|
|
const std::optional<QPoint> partner = findTunnelPartner(
|
|
makeLookup(tunnels), QPoint(2, 5), BuildingType::TunnelEntry, Rotation::East, 10);
|
|
|
|
REQUIRE(partner.has_value());
|
|
REQUIRE(*partner == QPoint(6, 5));
|
|
}
|
|
|
|
TEST_CASE("findTunnelPartner: a partner beyond the max distance is not matched")
|
|
{
|
|
TunnelMap tunnels;
|
|
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
|
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // distance 4
|
|
|
|
REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5),
|
|
BuildingType::TunnelEntry, Rotation::East, 3).has_value());
|
|
}
|