The controls panel needs to say what each key does right now, and a panel that keeps its own list of that is a list that goes stale. So the list moves into lib/core/ControlAction.h: which actions exist, what each is bound to, and when each does something. InputMapper stops deciding that and switches on the resolved action instead, so the panel and the key handling cannot disagree about what Q means -- there is only one place that says. The table declares; it never performs. It holds no simulation access, fires no events, and names nothing: display strings live in the ui target, which formats the bindings this hands it, so a badge is rendered from the real binding rather than typed beside it. What an action *does* stays exactly where it was. ControlContext is the snapshot the rules read, which is what keeps this testable without a world. Two of its facts come from BlueprintLibrary, which is built after the world view and so arrives by setter; one comes from the new hovered-transfer flag on BuildModeController, resolved once on mouse-move through the same classifier the click and the ghost colour already use. Behaviour is unchanged, deliberately. Ctrl still separates the chords and every other modifier is still ignored, so Shift+A pans as before; matching modifiers exactly would have silently swallowed those presses. Build hotkeys and F3/F4 stay outside the table -- the first are advertised on the build buttons and already derive their badges from the handler's own table, the second are development controls the panel must never offer. The tests are the point of putting this in lib: every row's bindings must resolve back to that row's action in that same context, which fails the moment a shown row and its handler part ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
207 lines
7.0 KiB
C++
207 lines
7.0 KiB
C++
#include "BlueprintLibrary.h"
|
|
|
|
#include <cstddef>
|
|
#include <utility>
|
|
|
|
#include <QCoreApplication>
|
|
#include <QFile>
|
|
#include <QMessageBox>
|
|
#include <QObject>
|
|
#include <QStringList>
|
|
|
|
#include "BlueprintPlacementRequestedEvent.h"
|
|
#include "BlueprintSerializer.h"
|
|
#include "EventManager.h"
|
|
#include "ExitBlueprintModeRequestedEvent.h"
|
|
|
|
#include "BuildingConfig.h"
|
|
#include "Simulation.h"
|
|
|
|
BlueprintLibrary::BlueprintLibrary(Simulation* sim, const GameConfig* config,
|
|
QWidget* dialogParent)
|
|
: m_sim(sim)
|
|
, m_config(config)
|
|
, m_dialogParent(dialogParent)
|
|
{
|
|
loadFromDisk();
|
|
registerForEvents();
|
|
}
|
|
|
|
BlueprintLibrary::~BlueprintLibrary()
|
|
{
|
|
saveToDisk();
|
|
unregisterForEvents();
|
|
}
|
|
|
|
bool BlueprintLibrary::getCanCaptureSelection() const
|
|
{
|
|
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
|
|
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
|
|
}
|
|
|
|
bool BlueprintLibrary::getHasTemporaryBlueprint() const
|
|
{
|
|
return m_temporaryBlueprint.has_value();
|
|
}
|
|
|
|
void BlueprintLibrary::saveSelectionAs(const QString& name)
|
|
{
|
|
Blueprint blueprint = createBlueprintFromSelection();
|
|
if (blueprint.buildings.empty()) { return; }
|
|
|
|
blueprint.name = name;
|
|
m_blueprints.push_back(std::move(blueprint));
|
|
}
|
|
|
|
const std::vector<Blueprint>& BlueprintLibrary::getBlueprints() const
|
|
{
|
|
return m_blueprints;
|
|
}
|
|
|
|
QString BlueprintLibrary::getContentsSummary(int index) const
|
|
{
|
|
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return QString(); }
|
|
|
|
QStringList entries;
|
|
for (const BlueprintContentEntry& entry : summarizeBlueprintContents(
|
|
m_blueprints[static_cast<std::size_t>(index)], m_config->buildings))
|
|
{
|
|
// The same "<type> x <count>" notation the building multi-selection summary
|
|
// uses (REQ-UI-MULTI-SELECTION), which also sidesteps plural forms.
|
|
entries << QObject::tr("%1 x %2")
|
|
.arg(QString::fromStdString(entry.buildingName))
|
|
.arg(entry.count);
|
|
}
|
|
return entries.join(QStringLiteral(", "));
|
|
}
|
|
|
|
int BlueprintLibrary::getCost(int index) const
|
|
{
|
|
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return 0; }
|
|
return computeBlueprintCost(m_blueprints[static_cast<std::size_t>(index)],
|
|
m_config->buildings);
|
|
}
|
|
|
|
bool BlueprintLibrary::getCanAfford(int index) const
|
|
{
|
|
return m_sim->getBuildingBlocksStock() >= getCost(index);
|
|
}
|
|
|
|
void BlueprintLibrary::remove(int index)
|
|
{
|
|
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
|
|
|
|
if (m_activeIndex == index)
|
|
{
|
|
m_activeIndex = std::nullopt;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<ExitBlueprintModeRequestedEvent>());
|
|
}
|
|
else if (m_activeIndex.has_value() && *m_activeIndex > index)
|
|
{
|
|
--*m_activeIndex;
|
|
}
|
|
m_blueprints.erase(m_blueprints.begin() + index);
|
|
}
|
|
|
|
void BlueprintLibrary::beginPlacement(int index)
|
|
{
|
|
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
|
|
|
|
m_activeIndex = index;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BlueprintPlacementRequestedEvent>(
|
|
m_blueprints[static_cast<std::size_t>(index)]));
|
|
}
|
|
|
|
void BlueprintLibrary::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
|
{
|
|
m_selectedBuildingIds = event->ids;
|
|
}
|
|
|
|
void BlueprintLibrary::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
|
|
{
|
|
// Only the saved-blueprint index is cleared. A temporary blueprint deliberately
|
|
// survives its placement mode so V can re-enter it (REQ-UI-BLUEPRINT-TEMP).
|
|
m_activeIndex = std::nullopt;
|
|
}
|
|
|
|
void BlueprintLibrary::handleEvent(
|
|
std::shared_ptr<const TemporaryBlueprintCaptureRequestedEvent> /*event*/)
|
|
{
|
|
// C (REQ-UI-BLUEPRINT-TEMP): capture the current selection and enter placement mode
|
|
// for it, without naming it, listing it, or persisting it. If nothing player-placeable
|
|
// is selected, do nothing at all -- in particular, keep the previous temporary
|
|
// blueprint, which V can still place.
|
|
Blueprint blueprint = createBlueprintFromSelection();
|
|
if (blueprint.buildings.empty()) { return; }
|
|
|
|
m_temporaryBlueprint = std::move(blueprint);
|
|
// No saved blueprint is active while a temporary one is being placed.
|
|
m_activeIndex = std::nullopt;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BlueprintPlacementRequestedEvent>(*m_temporaryBlueprint));
|
|
}
|
|
|
|
void BlueprintLibrary::handleEvent(
|
|
std::shared_ptr<const TemporaryBlueprintPlaceRequestedEvent> /*event*/)
|
|
{
|
|
// V (REQ-UI-BLUEPRINT-TEMP): re-enter placement mode for the temporary blueprint,
|
|
// capturing nothing. With none captured, nothing happens -- no mode is entered and
|
|
// whichever mode is active is left alone. Affordability is not checked here, matching
|
|
// C; cost is enforced at placement (REQ-UI-BLUEPRINT-PLACE). The blueprint is copied,
|
|
// not moved: it stays available for the next V.
|
|
if (!m_temporaryBlueprint.has_value()) { return; }
|
|
|
|
m_activeIndex = std::nullopt;
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BlueprintPlacementRequestedEvent>(*m_temporaryBlueprint));
|
|
}
|
|
|
|
void BlueprintLibrary::handleEvent(std::shared_ptr<const GameResetEvent> /*event*/)
|
|
{
|
|
// A restart begins a new run, and the temporary blueprint belongs to the old one
|
|
// (REQ-UI-BLUEPRINT-TEMP). The saved blueprints are not run state and stay.
|
|
m_temporaryBlueprint = std::nullopt;
|
|
}
|
|
|
|
Blueprint BlueprintLibrary::createBlueprintFromSelection() const
|
|
{
|
|
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
|
|
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
|
|
// REQ-UI-BLUEPRINT-STORAGE).
|
|
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
|
|
}
|
|
|
|
void BlueprintLibrary::loadFromDisk()
|
|
{
|
|
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
|
|
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
|
QFile file(path);
|
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
|
|
try
|
|
{
|
|
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
|
|
}
|
|
catch (const std::exception& e)
|
|
{
|
|
QMessageBox::critical(m_dialogParent, QObject::tr("Load Failed"),
|
|
QObject::tr("Failed to load blueprints:\n%1").arg(e.what()));
|
|
m_blueprints.clear();
|
|
}
|
|
}
|
|
|
|
void BlueprintLibrary::saveToDisk() const
|
|
{
|
|
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
|
|
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
|
QFile file(path);
|
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
|
|
try
|
|
{
|
|
const std::string content = BlueprintSerializer::serialize(m_blueprints);
|
|
file.write(QByteArray::fromStdString(content));
|
|
}
|
|
catch (...) {}
|
|
}
|