Files
dota_factory/src/ui/MainWindow.cpp
Malte Langkabel 97b6f0d8fd replay: record every run to a replay file (Phase 2)
Hooks a recorder into the command chokepoint so each run is written to disk as
it plays, ready for playback in Phase 3.

- ReplayRecorder (lib): line-oriented append-friendly file — keyed header
  (version, build, seed, config_hash, timestamp), then '---', then tick-tagged
  command lines interleaved with "# checksum <tick> <hex>" RNG fingerprints.
  Each line is flushed so a crash leaves a valid partial file. Config hash is a
  64-bit FNV over the config dir's *.toml files; build tag is __DATE__/__TIME__.
- CommandSerializer (lib): per-command text (length-prefixed variable parts;
  ship layouts and splitter filters serialized inline). Reset is a file
  boundary, never a stream entry.
- CommandManager owns an optional ReplayRecorder: drain() records each applied
  command + a post-apply checksum; a drained Reset rolls to a new file;
  recordTickCheckpoint() (called per tick from onFrame) writes a checksum every
  30 ticks.
- Random seed generated in main and on restart (std::random_device); Simulation
  retains it via getSeed() for the header.
- GameWorldView attaches the recorder at construction (first file + tick-0
  checksum); replays land in <data>/replays named <timestamp>_<seed>.replay.

ReplayRecorderTest covers serialization, file well-formedness, file rolling,
and the CommandManager drain->record integration. Full suite green
(346 cases / 3377 assertions); app, tests, and balancing all build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
2026-06-30 20:08:04 +02:00

346 lines
12 KiB
C++

#include "MainWindow.h"
#include <map>
#include <random>
#include <set>
#include <QApplication>
#include <QCloseEvent>
#include <QFile>
#include <QMessageBox>
#include <QPushButton>
#include <QResizeEvent>
#include <QVBoxLayout>
#include "BlueprintPanel.h"
#include "BuildButtonGrid.h"
#include "BuildingBlocksChangedEvent.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "ConfigLoader.h"
#include "EventManager.h"
#include "GameWorldView.h"
#include "RecipeSelectionDialog.h"
#include "SchematicChoiceDialog.h"
#include "HeaderBar.h"
#include "SelectedBuildingPanel.h"
#include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h"
#include "Simulation.h"
#include "Tick.h"
#include "VisualsLoader.h"
MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent)
: QWidget(parent)
, m_configDir(configDir)
, m_visuals(VisualsLoader::load(configDir + "/visuals.toml"))
, m_sim(sim)
{
setWindowTitle(tr("Dota Factory"));
resize(1280, 768);
m_headerBar = new HeaderBar(this);
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
sideLayout->setContentsMargins(0, 0, 0, 0);
sideLayout->setSpacing(0);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->config(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(&sim->config(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->config(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1);
sideLayout->addWidget(m_buildButtonGrid, 1);
sideLayout->addWidget(m_blueprintPanel, 1);
m_gameWorldView->setFocus();
connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) {
if (newWidget && newWidget != m_gameWorldView && !QApplication::activeModalWidget())
{
m_gameWorldView->setFocus();
}
});
// Load layout blueprints from disk. Missing file is silently ignored.
const QString bpPath = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
QFile bpFile(bpPath);
if (bpFile.open(QIODevice::ReadOnly | QIODevice::Text))
{
try
{
m_layoutBlueprints = ShipLayoutBlueprintSerializer::deserialize(
bpFile.readAll().toStdString());
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Load Error"),
tr("Failed to load ship_layouts.toml:\n%1").arg(e.what()));
m_layoutBlueprints.clear();
}
}
registerForEvents();
}
MainWindow::~MainWindow()
{
unregisterForEvents();
}
void MainWindow::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
layoutPanels();
}
void MainWindow::closeEvent(QCloseEvent* event)
{
const QString path = QCoreApplication::applicationDirPath() + "/ship_layouts.toml";
QFile file(path);
if (file.open(QIODevice::WriteOnly | QIODevice::Text))
{
try
{
const std::string content =
ShipLayoutBlueprintSerializer::serialize(m_layoutBlueprints);
file.write(QByteArray::fromStdString(content));
}
catch (...) {}
}
QWidget::closeEvent(event);
}
void MainWindow::layoutPanels()
{
const int totalW = width();
const int totalH = height();
const int headerH = m_headerBar->sizeHint().height();
if (headerH <= 0) { return; }
const int mainW = totalW * 75 / 100;
const int sideW = totalW - mainW;
m_headerBar->setGeometry(0, 0, mainW, headerH);
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
m_sidePanel->setGeometry(mainW, 0, sideW, totalH);
}
void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
{
m_buildButtonGrid->updateAffordability(event->blocks);
}
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0);
SchematicChoiceDialog dialog(event->choices, this);
dialog.exec();
std::shared_ptr<ApplySchematicChoiceCommand> command =
std::make_shared<ApplySchematicChoiceCommand>();
command->choiceIndex = dialog.getChosenIndex();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0);
QMessageBox box(this);
box.setWindowTitle(tr("Paused"));
QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole);
QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::ResetRole);
QPushButton* quitBtn = box.addButton(tr("Quit"), QMessageBox::DestructiveRole);
box.setEscapeButton(continueBtn);
box.exec();
QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
{
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Config Error"),
tr("Failed to reload config:\n%1").arg(e.what()));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return;
}
// Restart is a command boundary; the view resets when the drain applies
// it (see GameWorldView::onFrame). A fresh random seed starts a new run.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else if (clicked == quitBtn)
{
close();
}
else
{
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
}
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0);
// A construction site has no Building yet; fall back to its site record so
// the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG).
const Building* b = m_sim->buildings().findBuilding(event->shipyardId);
const ConstructionSite* s =
b ? nullptr : m_sim->buildings().findSite(event->shipyardId);
if (!b && !s)
{
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return;
}
const std::string& schematicId = b ? b->recipeId : s->recipeId;
const std::optional<ShipLayoutConfig>& layoutOpt =
b ? b->shipLayout : s->shipLayout;
ShipLayoutConfig currentLayout;
if (layoutOpt.has_value())
{
currentLayout = *layoutOpt;
}
std::set<std::string> unlockedModuleIds;
std::map<std::string, int> moduleLevels;
for (const ModuleDef& def : m_sim->config().modules.modules)
{
if (m_sim->isModuleSchematicUnlocked(def.id))
{
unlockedModuleIds.insert(def.id);
}
moduleLevels[def.id] = m_sim->moduleSchematicLevel(def.id);
}
ShipLayoutDialog dialog(&m_sim->config(), schematicId, currentLayout,
m_layoutBlueprints,
std::move(unlockedModuleIds),
std::move(moduleLevels),
m_gameWorldView->isDebugDrawEnabled(),
this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
{
std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = event->shipyardId;
command->layout = *dialog.result();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0);
// A construction site has no Building yet; fall back to its site record so
// the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG).
const Building* b = m_sim->buildings().findBuilding(event->buildingId);
const ConstructionSite* s =
b ? nullptr : m_sim->buildings().findSite(event->buildingId);
if (!b && !s)
{
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return;
}
const BuildingType type = b ? b->type : s->type;
const std::vector<RecipeSelectionOption> options =
buildRecipeSelectionOptions(type, *m_sim, m_sim->config());
const QString title = (type == BuildingType::Shipyard)
? tr("Select Schematic")
: tr("Select Recipe");
RecipeSelectionDialog dialog(options, title, this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
command->id = event->buildingId;
command->recipeId = *dialog.getChosenId();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
{
const Tick tick = m_sim->currentTick();
const int totalSeconds = static_cast<int>(ticksToSeconds(tick));
const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60;
QMessageBox box(this);
box.setWindowTitle(tr("Game Over"));
box.setText(tr("HQ destroyed!\nSurvival time: %1:%2")
.arg(minutes, 2, 10, QChar('0'))
.arg(seconds, 2, 10, QChar('0')));
QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole);
box.addButton(tr("Quit"), QMessageBox::RejectRole);
box.exec();
if (box.clickedButton() == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
{
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Config Error"),
tr("Failed to reload config:\n%1").arg(e.what()));
return;
}
// Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{
close();
}
}