Replay: deterministic record & playback (#4)

Add deterministic record/playback for a run.

Recording captures `(seed, config hash, ordered tick-tagged commands)` and re-simulates on playback — no state snapshots. `DotaFactory.exe --replay <file>` re-plays a recorded run view-only with manual speed/pause.

Reviewed-on: #4
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
2026-07-01 19:20:08 +00:00
committed by mlangkabel
parent cf68ac2862
commit d74ba5bfad
40 changed files with 3385 additions and 129 deletions

View File

@@ -1,6 +1,7 @@
#include "MainWindow.h"
#include <map>
#include <random>
#include <set>
#include <QApplication>
@@ -15,7 +16,10 @@
#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"
@@ -27,18 +31,21 @@
#include "Tick.h"
#include "VisualsLoader.h"
MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent)
MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
std::shared_ptr<ParsedReplay> replay, QWidget* parent)
: QWidget(parent)
, m_configDir(configDir)
, m_visuals(VisualsLoader::load(configDir + "/visuals.toml"))
, m_sim(sim)
, m_replay(std::move(replay))
{
setWindowTitle(tr("Dota Factory"));
resize(1280, 768);
m_headerBar = new HeaderBar(this);
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, this);
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir,
m_replay.get(), this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
@@ -138,7 +145,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
SchematicChoiceDialog dialog(event->choices, this);
dialog.exec();
m_sim->applySchematicChoice(dialog.getChosenIndex());
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();
@@ -160,12 +171,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
{
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_sim->reset(std::move(newConfig));
}
catch (const std::exception& e)
{
@@ -175,7 +187,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
m_gameWorldView->resetFrameTimer();
return;
}
m_gameWorldView->resetForNewGame();
// 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)
{
@@ -234,7 +252,12 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
{
m_sim->buildings().setShipLayout(event->shipyardId, *dialog.result());
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);
@@ -268,7 +291,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
RecipeSelectionDialog dialog(options, title, this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{
m_sim->buildings().setRecipe(event->buildingId, *dialog.getChosenId());
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);
@@ -293,12 +320,13 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
if (box.clickedButton() == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
{
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_sim->reset(std::move(newConfig));
}
catch (const std::exception& e)
{
@@ -306,7 +334,12 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
tr("Failed to reload config:\n%1").arg(e.what()));
return;
}
m_gameWorldView->resetForNewGame();
// 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
{