Compare commits

...

4 Commits

13 changed files with 1226 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
[[arena]]
name = "Interceptors vs Interceptors"
height_tiles = 60
player_buffer_width = 10
contest_zone_width = 40
enemy_buffer_width = 10
[[arena.team]]
name = "Alpha"
[[arena.team.ship]]
schematic = "interceptor"
level = 1
count = 5
[[arena.team]]
name = "Beta"
[[arena.team.ship]]
schematic = "interceptor"
level = 1
count = 5
[[arena]]
name = "Few Destroyers vs Many Interceptors"
height_tiles = 60
player_buffer_width = 10
contest_zone_width = 60
enemy_buffer_width = 10
[[arena.team]]
name = "Destroyers"
[[arena.team.ship]]
schematic = "destroyer"
level = 5
count = 2
[[arena.team]]
name = "Interceptors"
[[arena.team.ship]]
schematic = "interceptor"
level = 1
count = 10
[[arena]]
name = "Stations and Ships"
height_tiles = 60
player_buffer_width = 15
contest_zone_width = 40
enemy_buffer_width = 15
[[arena.team]]
name = "Fortified"
[[arena.team.ship]]
schematic = "interceptor"
level = 1
count = 3
[[arena.team.station]]
type = "player_station"
x = 8
y = 15
level = 1
[[arena.team.station]]
type = "player_station"
x = 8
y = 45
level = 1
[[arena.team]]
name = "Swarm"
[[arena.team.ship]]
schematic = "interceptor"
level = 1
count = 8

View File

@@ -251,3 +251,39 @@ The screen is divided into three vertical sections:
- REQ-UI-BLUEPRINT-SAVE: A "Save" button is shown at the bottom of the blueprint panel. Clicking it serializes all current blueprints to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. If writing fails, a modal error dialog is shown describing the failure.
- REQ-UI-BLUEPRINT-LOAD: A "Load" button is shown at the bottom of the blueprint panel, to the right of the "Save" button. Clicking it shows a confirmation dialog ("Load blueprints? This will replace all current blueprints.") with Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm reads `blueprints.toml` from the same directory as the application executable, replaces all current blueprints with those from the file (in the order they appear in the file), and exits any active blueprint-related mode (blueprint placement mode, delete mode). If the file does not exist or cannot be parsed, a modal error dialog is shown describing the failure and the current blueprint list is left unchanged.
## Balancing Tool
A separate executable target (`balancing`) that links against `lib` but contains no main-game UI code. It provides an automated arena-based ship balancing tool. Code written exclusively for this target does not go into `lib`.
### Config
- REQ-BAL-CONFIG: The balancing tool reads arena definitions from a `balancing.toml` file. The file is read once at startup. If parsing fails or required fields are missing, the tool aborts with a clear error message.
- REQ-BAL-CONFIG-GAME: Ship stats are read from `ships.toml` and defence station stats are read from `stations.toml`, using the same config loading as the main game. Formula evaluation uses the levels specified in the arena config.
### Arena Definition
- REQ-BAL-ARENA: Each arena in `balancing.toml` defines:
- A human-readable **arena name**.
- **Region widths** (in tiles): player buffer width, contest zone width, enemy buffer width. The arena world is pure space — no asteroid region.
- **World height** (in tiles).
- Exactly **two teams**, each with a human-readable **team name**.
- REQ-BAL-TEAM: Each team defines:
- A list of **ship entries**, each specifying: ship schematic (type), level, and count.
- An optional list of **defence station entries**, each specifying: station type (`player_station` or `enemy_station` from `stations.toml`), level, and tile position (x, y).
- REQ-BAL-HQ: Each team has an HQ placed automatically at the vertical center of the arena at the far end of that team's buffer zone. HQ stats are read from `stations.toml [hq]` at level 1. Team 1's HQ is at the left edge; team 2's HQ is at the right edge.
- REQ-BAL-SPAWN: Team 1's ships spawn in team 1's buffer zone (left side); team 2's ships spawn in team 2's buffer zone (right side). Spawn positions are uniformly random within the respective buffer zone.
### Simulation
- REQ-BAL-SIM-ENV: Each arena simulates a pure-space environment using the same tick-based simulation as the main game. There is no asteroid, no buildings, no belts, no wave system, and no threat accumulation. Only ships, HQs, defence stations, and combat are active.
- REQ-BAL-SIM-AI: Ships use the same AI and stats as in the main game. All ships use aggressive stance and closest-target priority. Ships with no target in sensor range advance toward the enemy team's HQ. Ships that detect an enemy in sensor range engage it as in the normal game (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI).
- REQ-BAL-SIM-SPEED: Each arena runs its simulation at maximum tick rate (as many ticks per second as the hardware allows), with no rendering.
- REQ-BAL-SIM-PARALLEL: All arenas are simulated in parallel, each on its own thread.
- REQ-BAL-SIM-END: An arena fight ends when either team's HQ is destroyed or all ships and defence stations of one team have been destroyed. If a team has no defence stations, destroying all its ships is sufficient. When the fight ends, the simulation for that arena stops.
### UI
- REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a dynamically generated vertical list of arena buttons, one per arena defined in `balancing.toml`.
- REQ-BAL-UI-BUTTON: Each arena button displays the arena name and two columns (one per team). Each column shows the team name as a header, followed by a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName Llevel` — for example `2/3 Fighter L5` or `1/1 HQ L1`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`.
- REQ-BAL-UI-BUTTON-BORDER: While an arena's simulation is running, the button border is blue. When the fight ends, the border changes to green.

View File

@@ -4,6 +4,7 @@ set(TARGET_APP_NAME "${TARGET_BASE_NAME}")
set(TARGET_LIB_NAME "${TARGET_BASE_NAME}_lib")
set(TARGET_UI_NAME "${TARGET_BASE_NAME}_ui")
set(TARGET_TEST_NAME "${TARGET_BASE_NAME}_test")
set(TARGET_BALANCING_NAME "${TARGET_BASE_NAME}_balancing")
set(TARGET_LIB_INCLUDE_DIRS
"${CMAKE_CURRENT_SOURCE_DIR}/lib"
@@ -11,6 +12,7 @@ set(TARGET_LIB_INCLUDE_DIRS
)
set(TARGET_UI_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/ui")
set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test")
set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing")
set(SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
@@ -188,6 +190,73 @@ unset(HDRS)
unset(SRCS)
# ============================================================
# balancing — ship balancing tool; depends on lib + QtWidgets
# ============================================================
if (WIN32)
COPY_QT_BINARIES("${OUTPUT_ROOT_PATH}/Debug/balancing/" True)
COPY_QT_BINARIES("${OUTPUT_ROOT_PATH}/Release/balancing/" False)
execute_process(
COMMAND "cmd.exe" "/k" "rmdir" "${BACKSLASHED_OUTPUT_ROOT_PATH}\\Debug\\balancing\\data" & "mklink" "/d" "/j" "${BACKSLASHED_OUTPUT_ROOT_PATH}\\Debug\\balancing\\data" "${BACKSLASHED_CMAKE_SOURCE_DIR}\\bin\\balancing\\data" & exit
COMMAND "cmd.exe" "/k" "rmdir" "${BACKSLASHED_OUTPUT_ROOT_PATH}\\Release\\balancing\\data" & "mklink" "/d" "/j" "${BACKSLASHED_OUTPUT_ROOT_PATH}\\Release\\balancing\\data" "${BACKSLASHED_CMAKE_SOURCE_DIR}\\bin\\balancing\\data" & exit
)
endif ()
set(HDRS)
set(SRCS)
add_subdirectory(balancing)
set(RELATIVE_HDRS)
foreach (_file ${HDRS})
file(RELATIVE_PATH _relPath "${SRC_DIR}" "${_file}")
list(APPEND RELATIVE_HDRS "${_relPath}")
endforeach()
set(RELATIVE_SRCS)
foreach (_file ${SRCS})
file(RELATIVE_PATH _relPath "${SRC_DIR}" "${_file}")
list(APPEND RELATIVE_SRCS "${_relPath}")
endforeach()
add_files(BALANCING_FILES ${RELATIVE_HDRS} ${RELATIVE_SRCS})
add_executable(${TARGET_BALANCING_NAME} ${BALANCING_FILES})
create_source_groups(${BALANCING_FILES})
foreach (OUTPUTCONFIG ${CMAKE_CONFIGURATION_TYPES})
string(TOUPPER ${OUTPUTCONFIG} OUTPUTCONFIG)
set_target_properties(${TARGET_BALANCING_NAME} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_${OUTPUTCONFIG} "${OUTPUT_ROOT_PATH}/${OUTPUTCONFIG}/balancing/"
)
endforeach ()
set_target_properties(${TARGET_BALANCING_NAME} PROPERTIES
AUTOMOC ON
CXX_STANDARD 17
VS_DEBUGGER_WORKING_DIRECTORY "${OUTPUT_ROOT_PATH}/$(Configuration)/balancing/"
)
target_include_directories(${TARGET_BALANCING_NAME} PRIVATE
"${TARGET_BALANCING_INCLUDE_DIRS}"
"${TARGET_LIB_INCLUDE_DIRS}"
"${LIB_INCLUDE_PATH}"
)
target_compile_definitions(${TARGET_BALANCING_NAME} PRIVATE
CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/app/data/config"
BALANCING_CONFIG="${CMAKE_SOURCE_DIR}/bin/balancing/data/balancing.toml"
TOML_FLOAT_CHARCONV=0
)
target_link_libraries(${TARGET_BALANCING_NAME} ${TARGET_LIB_NAME} Qt5::Widgets)
unset(BALANCING_FILES)
unset(RELATIVE_HDRS)
unset(RELATIVE_SRCS)
unset(HDRS)
unset(SRCS)
# ============================================================
# tests — Catch2 tests; links against lib only (no QtWidgets)
# ============================================================
@@ -218,3 +287,4 @@ target_link_libraries(${TARGET_TEST_NAME} ${TARGET_LIB_NAME})
set(TARGET_LIB_NAME "${TARGET_LIB_NAME}" PARENT_SCOPE)
set(TARGET_UI_NAME "${TARGET_UI_NAME}" PARENT_SCOPE)
set(TARGET_APP_NAME "${TARGET_APP_NAME}" PARENT_SCOPE)
set(TARGET_BALANCING_NAME "${TARGET_BALANCING_NAME}" PARENT_SCOPE)

View File

@@ -0,0 +1,95 @@
#include "ArenaButton.h"
#include <QHBoxLayout>
#include <QVBoxLayout>
ArenaButton::ArenaButton(const std::string& arenaName, QWidget* parent)
: QFrame(parent)
, m_wasFinished(false)
{
buildLayout(arenaName);
setFrameStyle(QFrame::Box | QFrame::Plain);
setLineWidth(2);
setStyleSheet("ArenaButton { border: 2px solid #3366ff; padding: 8px; }");
}
void ArenaButton::buildLayout(const std::string& arenaName)
{
QVBoxLayout* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(8, 8, 8, 8);
outerLayout->setSpacing(4);
m_titleLabel = new QLabel(QString::fromStdString(arenaName), this);
QFont titleFont = m_titleLabel->font();
titleFont.setBold(true);
titleFont.setPointSize(titleFont.pointSize() + 2);
m_titleLabel->setFont(titleFont);
outerLayout->addWidget(m_titleLabel);
QHBoxLayout* teamsLayout = new QHBoxLayout();
teamsLayout->setSpacing(16);
// Team 1 column.
QVBoxLayout* team1Layout = new QVBoxLayout();
m_team1Header = new QLabel(this);
QFont headerFont = m_team1Header->font();
headerFont.setBold(true);
m_team1Header->setFont(headerFont);
team1Layout->addWidget(m_team1Header);
m_team1Content = new QLabel(this);
team1Layout->addWidget(m_team1Content);
team1Layout->addStretch();
teamsLayout->addLayout(team1Layout);
// Team 2 column.
QVBoxLayout* team2Layout = new QVBoxLayout();
m_team2Header = new QLabel(this);
m_team2Header->setFont(headerFont);
team2Layout->addWidget(m_team2Header);
m_team2Content = new QLabel(this);
team2Layout->addWidget(m_team2Content);
team2Layout->addStretch();
teamsLayout->addLayout(team2Layout);
outerLayout->addLayout(teamsLayout);
}
void ArenaButton::updateStatus(const ArenaStatus& status)
{
for (int ti = 0; ti < 2; ++ti)
{
const ArenaStatus::TeamStatus& team = status.teams[ti];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti)
{
header->setText("[WON] " + QString::fromStdString(team.name));
}
else
{
header->setText(QString::fromStdString(team.name));
}
QString lines;
for (const ArenaStatus::Entry& entry : team.entries)
{
if (!lines.isEmpty())
{
lines += "\n";
}
lines += QString("%1/%2 %3 L%4")
.arg(entry.surviving)
.arg(entry.total)
.arg(QString::fromStdString(entry.displayName))
.arg(entry.level);
}
content->setText(lines);
}
if (status.finished && !m_wasFinished)
{
m_wasFinished = true;
setStyleSheet("ArenaButton { border: 2px solid #33cc33; padding: 8px; }");
}
}

View File

@@ -0,0 +1,29 @@
#pragma once
#include <string>
#include <vector>
#include <QFrame>
#include <QLabel>
#include "ArenaSimulation.h"
class ArenaButton : public QFrame
{
Q_OBJECT
public:
explicit ArenaButton(const std::string& arenaName, QWidget* parent = nullptr);
void updateStatus(const ArenaStatus& status);
private:
void buildLayout(const std::string& arenaName);
QLabel* m_titleLabel;
QLabel* m_team1Header;
QLabel* m_team2Header;
QLabel* m_team1Content;
QLabel* m_team2Content;
bool m_wasFinished;
};

View File

@@ -0,0 +1,456 @@
#include "ArenaSimulation.h"
#include <algorithm>
#include <cassert>
#include <QVector2D>
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "CombatSystem.h"
#include "ScrapSystem.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "ShipsConfig.h"
#include "StationsConfig.h"
#include "SurfaceMask.h"
ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
ArenaConfig arenaConfig,
unsigned int seed)
: m_gameConfig(gameConfig)
, m_arenaConfig(std::move(arenaConfig))
, m_rng(seed)
, m_currentTick(0)
, m_nextId(1)
, m_beltSystem(1.0)
, m_team1HqId(kInvalidEntityId)
, m_team2HqId(kInvalidEntityId)
, m_finished(false)
, m_winnerTeam(-1)
, m_stopRequested(false)
{
m_buildingSystem = std::make_unique<BuildingSystem>(
m_gameConfig,
m_beltSystem,
[this]() { return allocateId(); },
[](int) {},
[](const std::string&, QVector2D) {},
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(
m_gameConfig, [this]() { return allocateId(); });
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); });
placeStructures();
spawnShips();
m_shipSystem->triggerRallyDeparture();
updateStatus();
}
ArenaSimulation::~ArenaSimulation() = default;
EntityId ArenaSimulation::allocateId()
{
return m_nextId++;
}
void ArenaSimulation::placeStructures()
{
const int totalWidth = m_arenaConfig.playerBufferWidth
+ m_arenaConfig.contestZoneWidth
+ m_arenaConfig.enemyBufferWidth;
const int midY = m_arenaConfig.heightTiles / 2;
// Team 1 HQ at left edge, placed as Hq (enemy ships target Hq).
{
const ParsedSurfaceMask hqParsed =
parseSurfaceMask(m_gameConfig.stations.hq.surfaceMask, Rotation::East);
const int anchorX = 0;
const int anchorY = midY - hqParsed.footprint.height() / 2;
const float hp = static_cast<float>(
m_gameConfig.stations.hq.hpFormula.evaluate(1.0));
m_team1HqId = m_buildingSystem->placeImmediate(
BuildingType::Hq,
m_gameConfig.stations.hq.surfaceMask,
QPoint(anchorX, anchorY),
Rotation::East, hp, hp);
}
// Team 2 HQ at right edge, placed as EnemyDefenceStation (player ships target these).
// No weapon — it's just a destructible target.
{
const ParsedSurfaceMask hqParsed =
parseSurfaceMask(m_gameConfig.stations.hq.surfaceMask, Rotation::West);
const int anchorX = totalWidth - hqParsed.footprint.width();
const int anchorY = midY - hqParsed.footprint.height() / 2;
const float hp = static_cast<float>(
m_gameConfig.stations.hq.hpFormula.evaluate(1.0));
m_team2HqId = m_buildingSystem->placeImmediate(
BuildingType::EnemyDefenceStation,
m_gameConfig.stations.hq.surfaceMask,
QPoint(anchorX, anchorY),
Rotation::West, hp, hp);
}
// Team 1 defence stations (PlayerDefenceStation — targeted by team 2).
for (const ArenaStationEntry& entry : m_arenaConfig.teams[0].stations)
{
float hp = 0.0f;
StationWeapon weapon;
weapon.cooldownTicks = 0.0f;
const double lv = static_cast<double>(entry.level);
if (entry.stationType == "player_station")
{
hp = static_cast<float>(
m_gameConfig.stations.playerStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.playerStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv));
}
else
{
hp = static_cast<float>(
m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv));
}
const EntityId stationId = m_buildingSystem->placeImmediate(
BuildingType::PlayerDefenceStation,
m_gameConfig.stations.playerStation.surfaceMask,
entry.position,
Rotation::East, hp, hp);
m_buildingSystem->initStationWeapon(stationId, weapon);
}
// Team 2 defence stations (EnemyDefenceStation — targeted by team 1).
for (const ArenaStationEntry& entry : m_arenaConfig.teams[1].stations)
{
float hp = 0.0f;
StationWeapon weapon;
weapon.cooldownTicks = 0.0f;
const double lv = static_cast<double>(entry.level);
if (entry.stationType == "player_station")
{
hp = static_cast<float>(
m_gameConfig.stations.playerStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.playerStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv));
}
else
{
hp = static_cast<float>(
m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv));
weapon.damage = static_cast<float>(
m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv));
weapon.range = static_cast<float>(
m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv));
weapon.fireRateHz = static_cast<float>(
m_gameConfig.stations.enemyStation.fireRateFormula.evaluate(lv));
}
const EntityId stationId = m_buildingSystem->placeImmediate(
BuildingType::EnemyDefenceStation,
m_gameConfig.stations.enemyStation.surfaceMask,
entry.position,
Rotation::East, hp, hp);
m_buildingSystem->initStationWeapon(stationId, weapon);
}
}
void ArenaSimulation::spawnShips()
{
const int contestStart = m_arenaConfig.playerBufferWidth;
const int team2Start = contestStart + m_arenaConfig.contestZoneWidth;
const int totalWidth = team2Start + m_arenaConfig.enemyBufferWidth;
std::uniform_real_distribution<float> yDist(0.0f,
static_cast<float>(m_arenaConfig.heightTiles));
// Team 1: isEnemy=false, spawn in player buffer zone.
{
std::uniform_real_distribution<float> xDist(0.0f,
static_cast<float>(m_arenaConfig.playerBufferWidth));
for (const ArenaShipEntry& entry : m_arenaConfig.teams[0].ships)
{
for (int i = 0; i < entry.count; ++i)
{
const QVector2D pos(xDist(m_rng), yDist(m_rng));
m_shipSystem->spawn(entry.schematicId, entry.level, pos, false);
}
}
}
// Team 2: isEnemy=true, spawn in enemy buffer zone.
{
std::uniform_real_distribution<float> xDist(
static_cast<float>(team2Start),
static_cast<float>(totalWidth));
for (const ArenaShipEntry& entry : m_arenaConfig.teams[1].ships)
{
for (int i = 0; i < entry.count; ++i)
{
const QVector2D pos(xDist(m_rng), yDist(m_rng));
m_shipSystem->spawn(entry.schematicId, entry.level, pos, true);
}
}
}
}
void ArenaSimulation::run()
{
while (!m_finished && !m_stopRequested.load(std::memory_order_relaxed))
{
tick();
}
{
std::lock_guard<std::mutex> lock(m_statusMutex);
m_status.finished = true;
}
}
void ArenaSimulation::requestStop()
{
m_stopRequested.store(true, std::memory_order_relaxed);
}
ArenaStatus ArenaSimulation::status() const
{
std::lock_guard<std::mutex> lock(m_statusMutex);
return m_status;
}
void ArenaSimulation::tick()
{
// Ship behavior systems (tick step 7).
m_shipSystem->clearMovementIntents();
m_shipSystem->tickHomeReturn();
m_shipSystem->tickThreatResponse(*m_buildingSystem);
m_shipSystem->tickRepairBehavior(*m_buildingSystem);
m_shipSystem->tickScrapCollector(*m_scrapSystem, *m_buildingSystem);
// Combat resolution (tick step 8).
std::vector<FireEvent> fireEvents;
m_combatSystem->tick(m_currentTick, *m_shipSystem, *m_buildingSystem, fireEvents);
m_combatSystem->applyPendingDamage(m_currentTick, *m_shipSystem, *m_buildingSystem);
// Deaths (tick step 9, simplified).
tickDeaths();
// Movement (tick step 10).
m_shipSystem->tickMovement();
// Scrap despawn (tick step 11).
m_scrapSystem->tickDespawn(m_currentTick);
++m_currentTick;
if (m_currentTick % 30 == 0)
{
updateStatus();
}
}
void ArenaSimulation::tickDeaths()
{
// Dead ships.
std::vector<EntityId> deadShipIds;
m_shipSystem->forEach([&deadShipIds](Ship& s)
{
if (s.hp <= 0.0f)
{
deadShipIds.push_back(s.id);
}
});
for (EntityId deadId : deadShipIds)
{
const Ship* s = m_shipSystem->findShip(deadId);
if (!s)
{
continue;
}
for (const ShipDef& def : m_gameConfig.ships.ships)
{
if (def.id == s->schematicId && def.loot.scrapDrop > 0)
{
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_gameConfig.world.scrapDespawnSeconds);
m_scrapSystem->spawn(s->position, def.loot.scrapDrop, despawnAt);
break;
}
}
m_shipSystem->despawn(deadId);
}
// Dead buildings (HQ and defence stations).
std::vector<EntityId> deadBuildingIds;
for (const Building& b : m_buildingSystem->allBuildings())
{
if (b.hp <= 0.0f
&& (b.type == BuildingType::Hq
|| b.type == BuildingType::PlayerDefenceStation
|| b.type == BuildingType::EnemyDefenceStation))
{
deadBuildingIds.push_back(b.id);
}
}
for (EntityId deadId : deadBuildingIds)
{
m_buildingSystem->removeBuilding(deadId);
}
// Check end conditions.
const bool team1HqGone =
(m_buildingSystem->findBuilding(m_team1HqId) == nullptr);
const bool team2HqGone =
(m_buildingSystem->findBuilding(m_team2HqId) == nullptr);
if (team1HqGone || team2HqGone)
{
m_finished = true;
m_winnerTeam = team1HqGone ? 1 : 0;
updateStatus();
return;
}
// Check if all ships and defence stations of one team are destroyed.
bool team1HasUnits = false;
bool team2HasUnits = false;
m_shipSystem->forEach([&team1HasUnits, &team2HasUnits](Ship& s)
{
if (s.isEnemy)
{
team2HasUnits = true;
}
else
{
team1HasUnits = true;
}
});
for (const Building& b : m_buildingSystem->allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation)
{
team1HasUnits = true;
}
else if (b.type == BuildingType::EnemyDefenceStation
&& b.id != m_team2HqId)
{
team2HasUnits = true;
}
}
if (!team1HasUnits || !team2HasUnits)
{
m_finished = true;
m_winnerTeam = team1HasUnits ? 0 : 1;
updateStatus();
}
}
void ArenaSimulation::updateStatus()
{
ArenaStatus newStatus;
newStatus.finished = m_finished;
newStatus.winnerTeam = m_winnerTeam;
for (int ti = 0; ti < 2; ++ti)
{
ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti];
teamStatus.name = m_arenaConfig.teams[ti].name;
// HQ entry (always first).
{
ArenaStatus::Entry hqEntry;
hqEntry.displayName = "HQ";
hqEntry.level = 1;
hqEntry.total = 1;
const EntityId hqId = (ti == 0) ? m_team1HqId : m_team2HqId;
hqEntry.surviving = (m_buildingSystem->findBuilding(hqId) != nullptr) ? 1 : 0;
teamStatus.entries.push_back(hqEntry);
}
// Ship entries.
for (const ArenaShipEntry& shipEntry : m_arenaConfig.teams[ti].ships)
{
ArenaStatus::Entry entry;
entry.displayName = shipEntry.schematicId;
entry.level = shipEntry.level;
entry.total = shipEntry.count;
int surviving = 0;
const bool isEnemyTeam = (ti == 1);
m_shipSystem->forEach(
[&surviving, &shipEntry, isEnemyTeam](Ship& s)
{
if (s.isEnemy == isEnemyTeam
&& s.schematicId == shipEntry.schematicId
&& s.level == shipEntry.level
&& s.hp > 0.0f)
{
++surviving;
}
});
entry.surviving = surviving;
teamStatus.entries.push_back(entry);
}
// Station entries.
for (std::size_t si = 0; si < m_arenaConfig.teams[ti].stations.size(); ++si)
{
const ArenaStationEntry& stationEntry = m_arenaConfig.teams[ti].stations[si];
ArenaStatus::Entry entry;
entry.displayName = "Station";
entry.level = stationEntry.level;
entry.total = 1;
// Count surviving stations of this team at this position.
const BuildingType expectedType = (ti == 0)
? BuildingType::PlayerDefenceStation
: BuildingType::EnemyDefenceStation;
int surviving = 0;
for (const Building& b : m_buildingSystem->allBuildings())
{
if (b.type == expectedType && b.anchor == stationEntry.position)
{
surviving = 1;
break;
}
}
entry.surviving = surviving;
teamStatus.entries.push_back(entry);
}
}
std::lock_guard<std::mutex> lock(m_statusMutex);
m_status = newStatus;
}

View File

@@ -0,0 +1,86 @@
#pragma once
#include <atomic>
#include <memory>
#include <mutex>
#include <random>
#include <string>
#include <vector>
#include "BalancingConfig.h"
#include "BeltSystem.h"
#include "EntityId.h"
#include "FireEvent.h"
#include "GameConfig.h"
#include "Tick.h"
class BuildingSystem;
class CombatSystem;
class ShipSystem;
class ScrapSystem;
struct ArenaStatus
{
struct Entry
{
std::string displayName;
int level;
int total;
int surviving;
};
struct TeamStatus
{
std::string name;
std::vector<Entry> entries; // HQ first, then ships, then stations
};
TeamStatus teams[2];
bool finished = false;
int winnerTeam = -1; // 0 or 1 when finished; -1 while running
};
class ArenaSimulation
{
public:
ArenaSimulation(const GameConfig& gameConfig,
ArenaConfig arenaConfig,
unsigned int seed = 0);
~ArenaSimulation();
void run();
void requestStop();
ArenaStatus status() const;
private:
EntityId allocateId();
void placeStructures();
void spawnShips();
void tick();
void tickDeaths();
void updateStatus();
const GameConfig& m_gameConfig;
ArenaConfig m_arenaConfig;
std::mt19937 m_rng;
Tick m_currentTick;
EntityId m_nextId;
BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem;
std::unique_ptr<ShipSystem> m_shipSystem;
std::unique_ptr<CombatSystem> m_combatSystem;
std::unique_ptr<ScrapSystem> m_scrapSystem;
EntityId m_team1HqId;
EntityId m_team2HqId;
bool m_finished;
int m_winnerTeam;
std::atomic<bool> m_stopRequested;
mutable std::mutex m_statusMutex;
ArenaStatus m_status;
};

View File

@@ -0,0 +1,166 @@
#include "BalancingConfig.h"
#include <stdexcept>
#include <string>
#include "toml.hpp"
namespace
{
std::runtime_error makeError(const std::string& path, const std::string& why)
{
return std::runtime_error("balancing.toml: '" + path + "' " + why);
}
template <typename NodeView>
int64_t requireInt(NodeView node, const std::string& path)
{
const std::optional<int64_t> value = node.template value<int64_t>();
if (!value)
{
throw makeError(path, "missing or not an integer");
}
return *value;
}
template <typename NodeView>
std::string requireString(NodeView node, const std::string& path)
{
const std::optional<std::string> value = node.template value<std::string>();
if (!value)
{
throw makeError(path, "missing or not a string");
}
return *value;
}
} // namespace
BalancingConfig loadBalancingConfig(const std::string& path)
{
toml::table root;
try
{
root = toml::parse_file(path);
}
catch (const toml::parse_error& e)
{
throw std::runtime_error(std::string("balancing.toml: parse error: ") + e.what());
}
const toml::array* arenaArray = root["arena"].as_array();
if (!arenaArray)
{
throw makeError("arena", "missing or not an array of tables");
}
BalancingConfig config;
for (std::size_t ai = 0; ai < arenaArray->size(); ++ai)
{
const toml::table* arenaTbl = (*arenaArray)[ai].as_table();
if (!arenaTbl)
{
throw makeError("arena[" + std::to_string(ai) + "]", "not a table");
}
const std::string prefix = "arena[" + std::to_string(ai) + "]";
ArenaConfig arena;
arena.name = requireString((*arenaTbl)["name"], prefix + ".name");
arena.heightTiles = static_cast<int>(
requireInt((*arenaTbl)["height_tiles"], prefix + ".height_tiles"));
arena.playerBufferWidth = static_cast<int>(
requireInt((*arenaTbl)["player_buffer_width"], prefix + ".player_buffer_width"));
arena.contestZoneWidth = static_cast<int>(
requireInt((*arenaTbl)["contest_zone_width"], prefix + ".contest_zone_width"));
arena.enemyBufferWidth = static_cast<int>(
requireInt((*arenaTbl)["enemy_buffer_width"], prefix + ".enemy_buffer_width"));
const toml::array* teamArray = (*arenaTbl)["team"].as_array();
if (!teamArray || teamArray->size() != 2)
{
throw makeError(prefix + ".team", "must contain exactly 2 teams");
}
for (int ti = 0; ti < 2; ++ti)
{
const toml::table* teamTbl = (*teamArray)[static_cast<std::size_t>(ti)].as_table();
if (!teamTbl)
{
throw makeError(prefix + ".team[" + std::to_string(ti) + "]", "not a table");
}
const std::string tPrefix = prefix + ".team[" + std::to_string(ti) + "]";
ArenaTeamConfig& team = arena.teams[ti];
team.name = requireString((*teamTbl)["name"], tPrefix + ".name");
const toml::array* shipArray = (*teamTbl)["ship"].as_array();
if (shipArray)
{
for (std::size_t si = 0; si < shipArray->size(); ++si)
{
const toml::table* shipTbl = (*shipArray)[si].as_table();
if (!shipTbl)
{
throw makeError(tPrefix + ".ship[" + std::to_string(si) + "]",
"not a table");
}
const std::string sPrefix =
tPrefix + ".ship[" + std::to_string(si) + "]";
ArenaShipEntry entry;
entry.schematicId = requireString((*shipTbl)["schematic"],
sPrefix + ".schematic");
entry.level = static_cast<int>(
requireInt((*shipTbl)["level"], sPrefix + ".level"));
entry.count = static_cast<int>(
requireInt((*shipTbl)["count"], sPrefix + ".count"));
team.ships.push_back(entry);
}
}
const toml::array* stationArray = (*teamTbl)["station"].as_array();
if (stationArray)
{
for (std::size_t si = 0; si < stationArray->size(); ++si)
{
const toml::table* stationTbl = (*stationArray)[si].as_table();
if (!stationTbl)
{
throw makeError(tPrefix + ".station[" + std::to_string(si) + "]",
"not a table");
}
const std::string sPrefix =
tPrefix + ".station[" + std::to_string(si) + "]";
ArenaStationEntry entry;
entry.stationType = requireString((*stationTbl)["type"],
sPrefix + ".type");
entry.level = static_cast<int>(
requireInt((*stationTbl)["level"], sPrefix + ".level"));
entry.position = QPoint(
static_cast<int>(requireInt((*stationTbl)["x"], sPrefix + ".x")),
static_cast<int>(requireInt((*stationTbl)["y"], sPrefix + ".y")));
if (entry.stationType != "player_station"
&& entry.stationType != "enemy_station")
{
throw makeError(sPrefix + ".type",
"must be 'player_station' or 'enemy_station'");
}
team.stations.push_back(entry);
}
}
}
config.arenas.push_back(arena);
}
return config;
}

View File

@@ -0,0 +1,44 @@
#pragma once
#include <string>
#include <vector>
#include <QPoint>
struct ArenaStationEntry
{
std::string stationType; // "player_station" or "enemy_station"
int level;
QPoint position;
};
struct ArenaShipEntry
{
std::string schematicId;
int level;
int count;
};
struct ArenaTeamConfig
{
std::string name;
std::vector<ArenaShipEntry> ships;
std::vector<ArenaStationEntry> stations;
};
struct ArenaConfig
{
std::string name;
int heightTiles;
int playerBufferWidth;
int contestZoneWidth;
int enemyBufferWidth;
ArenaTeamConfig teams[2];
};
struct BalancingConfig
{
std::vector<ArenaConfig> arenas;
};
BalancingConfig loadBalancingConfig(const std::string& path);

View File

@@ -0,0 +1,80 @@
#include "BalancingWindow.h"
#include <QScrollArea>
#include <QVBoxLayout>
BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
const GameConfig& gameConfig,
QWidget* parent)
: QWidget(parent)
{
setWindowTitle("DotaFactory — Balancing Tool");
resize(800, 600);
QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
QWidget* scrollContent = new QWidget(scrollArea);
QVBoxLayout* contentLayout = new QVBoxLayout(scrollContent);
contentLayout->setSpacing(8);
contentLayout->setContentsMargins(8, 8, 8, 8);
unsigned int seed = 0;
for (const ArenaConfig& arenaConfig : balancingConfig.arenas)
{
ArenaEntry entry;
entry.simulation = std::make_unique<ArenaSimulation>(
gameConfig, arenaConfig, seed++);
entry.button = new ArenaButton(arenaConfig.name, scrollContent);
contentLayout->addWidget(entry.button);
entry.button->updateStatus(entry.simulation->status());
m_arenas.push_back(std::move(entry));
}
contentLayout->addStretch();
scrollArea->setWidget(scrollContent);
mainLayout->addWidget(scrollArea);
// Start worker threads.
for (ArenaEntry& entry : m_arenas)
{
ArenaSimulation* sim = entry.simulation.get();
entry.worker = std::thread([sim]() { sim->run(); });
}
// Poll timer at ~10 Hz to update button statuses.
m_pollTimer = new QTimer(this);
connect(m_pollTimer, &QTimer::timeout, this, &BalancingWindow::pollStatuses);
m_pollTimer->start(100);
}
BalancingWindow::~BalancingWindow()
{
m_pollTimer->stop();
for (ArenaEntry& entry : m_arenas)
{
entry.simulation->requestStop();
}
for (ArenaEntry& entry : m_arenas)
{
if (entry.worker.joinable())
{
entry.worker.join();
}
}
}
void BalancingWindow::pollStatuses()
{
for (ArenaEntry& entry : m_arenas)
{
const ArenaStatus status = entry.simulation->status();
entry.button->updateStatus(status);
}
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include <memory>
#include <thread>
#include <vector>
#include <QTimer>
#include <QWidget>
#include "ArenaButton.h"
#include "ArenaSimulation.h"
#include "BalancingConfig.h"
#include "GameConfig.h"
class BalancingWindow : public QWidget
{
Q_OBJECT
public:
BalancingWindow(const BalancingConfig& balancingConfig,
const GameConfig& gameConfig,
QWidget* parent = nullptr);
~BalancingWindow() override;
private slots:
void pollStatuses();
private:
struct ArenaEntry
{
std::unique_ptr<ArenaSimulation> simulation;
std::thread worker;
ArenaButton* button;
};
std::vector<ArenaEntry> m_arenas;
QTimer* m_pollTimer;
};

View File

@@ -0,0 +1,18 @@
SET(HDRS
${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/ArenaButton.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaSimulation.h
${CMAKE_CURRENT_SOURCE_DIR}/BalancingConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h
PARENT_SCOPE
)
SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/main.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ArenaButton.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ArenaSimulation.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BalancingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp
PARENT_SCOPE
)

34
src/balancing/main.cpp Normal file
View File

@@ -0,0 +1,34 @@
#include <memory>
#include <QApplication>
#include "BalancingConfig.h"
#include "BalancingWindow.h"
#include "ConfigLoader.h"
#include "ConsoleLogger.h"
#include "logging.h"
#include "LogManager.h"
int main(int argc, char* argv[])
{
LogManager::getInstance()->addLogger(std::make_shared<ConsoleLogger>());
LogManager::getInstance()->setLoggingEnabled(true);
LOG_INFO("Balancing tool starting");
QApplication::setApplicationName("DotaFactory Balancing");
if (QSysInfo::windowsVersion() != QSysInfo::WV_None)
{
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling, true);
}
QApplication application(argc, argv);
GameConfig gameConfig = ConfigLoader::loadFromDirectory(CONFIG_DIR);
BalancingConfig balancingConfig = loadBalancingConfig(BALANCING_CONFIG);
BalancingWindow window(balancingConfig, gameConfig);
window.show();
return application.exec();
}