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:
@@ -6,10 +6,12 @@
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <QColor>
|
||||
#include <QCursor>
|
||||
#include <QDir>
|
||||
#include <QFont>
|
||||
#include <QKeyEvent>
|
||||
#include <QMessageBox>
|
||||
@@ -24,6 +26,10 @@
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "Command.h"
|
||||
#include "ReplayPlayer.h"
|
||||
#include "ReplayReader.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "DemolishModeChangedEvent.h"
|
||||
#include "EntityHitTest.h"
|
||||
#include "EntitySelectedEvent.h"
|
||||
@@ -112,11 +118,13 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
|
||||
|
||||
|
||||
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
const VisualsConfig* visuals, QWidget* parent)
|
||||
const VisualsConfig* visuals, const std::string& configDir,
|
||||
const ParsedReplay* replay, QWidget* parent)
|
||||
: QOpenGLWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
, m_visuals(visuals)
|
||||
, m_commandManager(*sim)
|
||||
, m_gameSpeedMultiplier(1.0)
|
||||
, m_prevNonZeroSpeed(1.0)
|
||||
, m_scrollXTiles(0.0f)
|
||||
@@ -143,6 +151,25 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
m_frameTimer.start();
|
||||
|
||||
registerForEvents();
|
||||
|
||||
if (replay)
|
||||
{
|
||||
// View-only playback: ignore live input and drive ticks from the recorded
|
||||
// stream. No recorder (we are not creating a new run).
|
||||
m_commandManager.setReplayMode(true);
|
||||
m_replayPlayer = std::make_unique<ReplayPlayer>(*sim, replay->entries);
|
||||
m_replayPlayer->start(); // process tick-0 entries before the first tick
|
||||
}
|
||||
else
|
||||
{
|
||||
// Record every run to disk. Replays live under <data>/replays, alongside
|
||||
// the config dir that was loaded. Attaching the recorder opens the first
|
||||
// file and writes the header for the initial run.
|
||||
QDir replayDir(QString::fromStdString(configDir));
|
||||
replayDir.cdUp();
|
||||
m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
|
||||
configDir, replayDir.filePath("replays").toStdString()));
|
||||
}
|
||||
}
|
||||
|
||||
GameWorldView::~GameWorldView()
|
||||
@@ -159,13 +186,40 @@ void GameWorldView::onFrame()
|
||||
{
|
||||
const qint64 elapsed = m_frameTimer.restart();
|
||||
|
||||
// Advance simulation
|
||||
if (m_replayPlayer)
|
||||
{
|
||||
// Playback: apply recorded commands at their ticks and verify checksums.
|
||||
// Manual speed/pause still works; playback only moves forward.
|
||||
const int ticks = m_tickDriver.advance(
|
||||
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
||||
for (int i = 0; i < ticks; ++i)
|
||||
{
|
||||
if (m_replayPlayer->isFinished()) { break; }
|
||||
m_sim->tick();
|
||||
m_replayPlayer->advanceTo(m_sim->currentTick());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Drain queued player commands once per frame, before the tick batch. This
|
||||
// runs even at 0x so a paused player sees placed construction sites
|
||||
// immediately, while staying deterministic (see docs/replay_design.md).
|
||||
m_commandManager.drain();
|
||||
|
||||
// A drained Reset reinitialized the simulation; reset the view to match.
|
||||
if (m_viewResetPending)
|
||||
{
|
||||
m_viewResetPending = false;
|
||||
resetForNewGame();
|
||||
}
|
||||
|
||||
const int ticks = m_tickDriver.advance(
|
||||
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
||||
for (int i = 0; i < ticks; ++i)
|
||||
{
|
||||
m_sim->tick();
|
||||
// Periodic checksum (every 30 ticks) for replay desync detection.
|
||||
m_commandManager.recordTickCheckpoint();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,25 +293,31 @@ void GameWorldView::onFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// Schematic choice available
|
||||
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
||||
// Sim-state polls are input sources for the live game; in replay these are
|
||||
// gated off (the recorded ApplySchematicChoice resolves the choice with no UI,
|
||||
// and game-over becomes the passive "replay ended" state below).
|
||||
if (!m_replayPlayer)
|
||||
{
|
||||
m_schematicChoiceShown = true;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
|
||||
}
|
||||
if (!m_sim->hasSchematicChoicesPending())
|
||||
{
|
||||
m_schematicChoiceShown = false;
|
||||
}
|
||||
// Schematic choice available
|
||||
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
||||
{
|
||||
m_schematicChoiceShown = true;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
|
||||
}
|
||||
if (!m_sim->hasSchematicChoicesPending())
|
||||
{
|
||||
m_schematicChoiceShown = false;
|
||||
}
|
||||
|
||||
// Game over check
|
||||
if (m_sim->isGameOver() && !m_gameOverShown)
|
||||
{
|
||||
m_gameOverShown = true;
|
||||
m_gameSpeedMultiplier = 0.0;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<GameOverEvent>());
|
||||
// Game over check
|
||||
if (m_sim->isGameOver() && !m_gameOverShown)
|
||||
{
|
||||
m_gameOverShown = true;
|
||||
m_gameSpeedMultiplier = 0.0;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<GameOverEvent>());
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
@@ -283,6 +343,7 @@ void GameWorldView::paintGL()
|
||||
drawBeams(painter);
|
||||
drawOverlays(painter);
|
||||
drawScreenSpace(painter);
|
||||
drawReplayOverlay(painter);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -515,12 +576,22 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
|
||||
if (rotateTarget.has_value())
|
||||
{
|
||||
m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation);
|
||||
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
|
||||
std::make_shared<RotateInPlaceCommand>();
|
||||
rotateCommand->id = *rotateTarget;
|
||||
rotateCommand->newRotation = bb.rotation;
|
||||
enqueueCommand(rotateCommand);
|
||||
continue;
|
||||
}
|
||||
|
||||
const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
|
||||
if (id == kInvalidBuildingId) { continue; }
|
||||
// Place-and-configure is one atomic command: commands apply at a deferred
|
||||
// tick boundary, so the caller never sees the new BuildingId. Unlock
|
||||
// gating stays here (UI-side pre-filter); only fields that should apply
|
||||
// are set on the command.
|
||||
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||||
command->type = bb.type;
|
||||
command->anchor = anchor;
|
||||
command->rotation = bb.rotation;
|
||||
|
||||
if (!bb.recipeId.empty())
|
||||
{
|
||||
@@ -528,7 +599,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
{
|
||||
if (m_sim->isSchematicUnlocked(bb.recipeId))
|
||||
{
|
||||
m_sim->buildings().setRecipe(id, bb.recipeId);
|
||||
command->recipeId = bb.recipeId;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -537,15 +608,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
|| bb.type == BuildingType::Assembler;
|
||||
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
|
||||
{
|
||||
m_sim->buildings().setRecipe(id, bb.recipeId);
|
||||
command->recipeId = bb.recipeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bb.shipLayout.has_value())
|
||||
{
|
||||
m_sim->buildings().setShipLayout(id, *bb.shipLayout);
|
||||
}
|
||||
command->shipLayout = bb.shipLayout;
|
||||
|
||||
if (bb.type == BuildingType::Splitter
|
||||
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
|
||||
@@ -553,11 +621,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
// The splitter is still a construction site, so the filters carry
|
||||
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
|
||||
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
|
||||
m_sim->buildings().setSiteSplitterFilters(
|
||||
id,
|
||||
filterUnlockedItems(bb.splitterFilterA, *m_sim),
|
||||
filterUnlockedItems(bb.splitterFilterB, *m_sim));
|
||||
command->hasSplitterFilters = true;
|
||||
command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
|
||||
command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
|
||||
}
|
||||
|
||||
enqueueCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,49 +647,50 @@ void GameWorldView::placeAtTile(QPoint tile)
|
||||
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
|
||||
if (rotateTarget.has_value())
|
||||
{
|
||||
m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation);
|
||||
std::shared_ptr<RotateInPlaceCommand> command =
|
||||
std::make_shared<RotateInPlaceCommand>();
|
||||
command->id = *rotateTarget;
|
||||
command->newRotation = m_ghostRotation;
|
||||
enqueueCommand(command);
|
||||
return;
|
||||
}
|
||||
|
||||
// For placements whose UI follow-up depends on success (belt-drag bookkeeping,
|
||||
// tunnel entry/exit toggle), pre-validate occupancy + affordability so the
|
||||
// optimistic UI update matches what the deferred command will do — isValidPlacement
|
||||
// (above) already covered terrain/bounds.
|
||||
if (type == BuildingType::Belt)
|
||||
{
|
||||
if (m_beltDragTiles.count(tile) > 0)
|
||||
{
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!m_sim->buildings().isTileOccupied(tile))
|
||||
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
|
||||
{
|
||||
const BuildingId id = m_sim->tryPlaceBuilding(
|
||||
type, tile, m_ghostRotation);
|
||||
if (id != kInvalidBuildingId)
|
||||
{
|
||||
m_beltDragTiles.insert(tile);
|
||||
}
|
||||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||
m_beltDragTiles.insert(tile);
|
||||
}
|
||||
}
|
||||
else if (type == BuildingType::Splitter
|
||||
|| type == BuildingType::TunnelEntry
|
||||
|| type == BuildingType::TunnelExit)
|
||||
{
|
||||
if (!m_sim->buildings().isTileOccupied(tile))
|
||||
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
|
||||
{
|
||||
const BuildingId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
|
||||
if (id != kInvalidBuildingId)
|
||||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||
if (type == BuildingType::TunnelEntry)
|
||||
{
|
||||
if (type == BuildingType::TunnelEntry)
|
||||
{
|
||||
m_builderType = BuildingType::TunnelExit;
|
||||
}
|
||||
else if (type == BuildingType::TunnelExit)
|
||||
{
|
||||
m_builderType = BuildingType::TunnelEntry;
|
||||
}
|
||||
m_builderType = BuildingType::TunnelExit;
|
||||
}
|
||||
else if (type == BuildingType::TunnelExit)
|
||||
{
|
||||
m_builderType = BuildingType::TunnelEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
|
||||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1209,6 +1279,45 @@ void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
|
||||
{
|
||||
}
|
||||
|
||||
void GameWorldView::drawReplayOverlay(QPainter& painter)
|
||||
{
|
||||
if (!m_replayPlayer) { return; }
|
||||
|
||||
painter.save();
|
||||
|
||||
QFont tag = painter.font();
|
||||
tag.setPixelSize(16);
|
||||
tag.setBold(true);
|
||||
painter.setFont(tag);
|
||||
painter.setPen(QColor(255, 220, 80));
|
||||
painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY"));
|
||||
|
||||
if (m_replayPlayer->isFinished())
|
||||
{
|
||||
// Dim the world so the end message reads clearly over it.
|
||||
painter.fillRect(rect(), QColor(0, 0, 0, 140));
|
||||
|
||||
const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
|
||||
QString message;
|
||||
if (desync.has_value())
|
||||
{
|
||||
message = tr("Desync at tick %1").arg(static_cast<qlonglong>(*desync));
|
||||
painter.setPen(QColor(255, 90, 90));
|
||||
}
|
||||
else
|
||||
{
|
||||
message = tr("Replay ended");
|
||||
painter.setPen(QColor(255, 255, 255));
|
||||
}
|
||||
QFont big = painter.font();
|
||||
big.setPixelSize(28);
|
||||
painter.setFont(big);
|
||||
painter.drawText(rect(), Qt::AlignCenter, message);
|
||||
}
|
||||
|
||||
painter.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1369,7 +1478,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
const bool isProtected = b && b->type == BuildingType::Hq;
|
||||
if (!isProtected)
|
||||
{
|
||||
m_sim->demolish(hovered);
|
||||
std::shared_ptr<DemolishCommand> command =
|
||||
std::make_shared<DemolishCommand>();
|
||||
command->id = hovered;
|
||||
enqueueCommand(command);
|
||||
m_demolishHoverBuildingId = kInvalidBuildingId;
|
||||
}
|
||||
}
|
||||
@@ -1743,3 +1855,35 @@ void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent>
|
||||
{
|
||||
setGameSpeed(event->multiplier);
|
||||
}
|
||||
|
||||
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
|
||||
{
|
||||
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this
|
||||
// event; GameWorldView owns the CommandManager and enqueues them.
|
||||
if (event->command && event->command->kind == CommandKind::Reset)
|
||||
{
|
||||
m_viewResetPending = true;
|
||||
}
|
||||
enqueueCommand(event->command);
|
||||
}
|
||||
|
||||
void GameWorldView::enqueueCommand(std::shared_ptr<const Command> command)
|
||||
{
|
||||
m_commandManager.enqueue(std::move(command));
|
||||
}
|
||||
|
||||
void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
|
||||
{
|
||||
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||||
command->type = type;
|
||||
command->anchor = anchor;
|
||||
command->rotation = rotation;
|
||||
enqueueCommand(command);
|
||||
}
|
||||
|
||||
bool GameWorldView::canAfford(BuildingType type) const
|
||||
{
|
||||
const BuildingDef* def = findBuildingDef(type);
|
||||
if (!def) { return false; }
|
||||
return m_sim->buildingBlocksStock() >= def->cost;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user