From a4427f7f67d458e2e74a2cec059edc8d0420b92d Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Sun, 3 May 2026 11:17:54 +0200 Subject: [PATCH] add balancing tool target --- bin/balancing/data/balancing.toml | 74 +++++ src/CMakeLists.txt | 70 +++++ src/balancing/ArenaButton.cpp | 88 ++++++ src/balancing/ArenaButton.h | 29 ++ src/balancing/ArenaSimulation.cpp | 439 ++++++++++++++++++++++++++++++ src/balancing/ArenaSimulation.h | 84 ++++++ src/balancing/BalancingConfig.cpp | 166 +++++++++++ src/balancing/BalancingConfig.h | 44 +++ src/balancing/BalancingWindow.cpp | 80 ++++++ src/balancing/BalancingWindow.h | 38 +++ src/balancing/CMakeLists.txt | 18 ++ src/balancing/main.cpp | 34 +++ 12 files changed, 1164 insertions(+) create mode 100644 bin/balancing/data/balancing.toml create mode 100644 src/balancing/ArenaButton.cpp create mode 100644 src/balancing/ArenaButton.h create mode 100644 src/balancing/ArenaSimulation.cpp create mode 100644 src/balancing/ArenaSimulation.h create mode 100644 src/balancing/BalancingConfig.cpp create mode 100644 src/balancing/BalancingConfig.h create mode 100644 src/balancing/BalancingWindow.cpp create mode 100644 src/balancing/BalancingWindow.h create mode 100644 src/balancing/CMakeLists.txt create mode 100644 src/balancing/main.cpp diff --git a/bin/balancing/data/balancing.toml b/bin/balancing/data/balancing.toml new file mode 100644 index 0000000..31fc10a --- /dev/null +++ b/bin/balancing/data/balancing.toml @@ -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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d46a1fb..e1f5f18 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/balancing/ArenaButton.cpp b/src/balancing/ArenaButton.cpp new file mode 100644 index 0000000..5578fb7 --- /dev/null +++ b/src/balancing/ArenaButton.cpp @@ -0,0 +1,88 @@ +#include "ArenaButton.h" + +#include +#include + +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; + + 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; }"); + } +} diff --git a/src/balancing/ArenaButton.h b/src/balancing/ArenaButton.h new file mode 100644 index 0000000..c5ff02c --- /dev/null +++ b/src/balancing/ArenaButton.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +#include +#include + +#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; +}; diff --git a/src/balancing/ArenaSimulation.cpp b/src/balancing/ArenaSimulation.cpp new file mode 100644 index 0000000..778a92d --- /dev/null +++ b/src/balancing/ArenaSimulation.cpp @@ -0,0 +1,439 @@ +#include "ArenaSimulation.h" + +#include +#include + +#include + +#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_stopRequested(false) +{ + m_buildingSystem = std::make_unique( + m_gameConfig, + m_beltSystem, + [this]() { return allocateId(); }, + [](int) {}, + [](const std::string&, QVector2D) {}, + m_rng); + + m_shipSystem = std::make_unique( + m_gameConfig, [this]() { return allocateId(); }); + + m_combatSystem = std::make_unique(m_gameConfig); + m_scrapSystem = std::make_unique([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( + 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( + 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(entry.level); + + if (entry.stationType == "player_station") + { + hp = static_cast( + m_gameConfig.stations.playerStation.hpFormula.evaluate(lv)); + weapon.damage = static_cast( + m_gameConfig.stations.playerStation.damageFormula.evaluate(lv)); + weapon.range = static_cast( + m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv)); + weapon.fireRateHz = static_cast( + m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv)); + } + else + { + hp = static_cast( + m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv)); + weapon.damage = static_cast( + m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv)); + weapon.range = static_cast( + m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv)); + weapon.fireRateHz = static_cast( + 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(entry.level); + + if (entry.stationType == "player_station") + { + hp = static_cast( + m_gameConfig.stations.playerStation.hpFormula.evaluate(lv)); + weapon.damage = static_cast( + m_gameConfig.stations.playerStation.damageFormula.evaluate(lv)); + weapon.range = static_cast( + m_gameConfig.stations.playerStation.rangeFormula.evaluate(lv)); + weapon.fireRateHz = static_cast( + m_gameConfig.stations.playerStation.fireRateFormula.evaluate(lv)); + } + else + { + hp = static_cast( + m_gameConfig.stations.enemyStation.hpFormula.evaluate(lv)); + weapon.damage = static_cast( + m_gameConfig.stations.enemyStation.damageFormula.evaluate(lv)); + weapon.range = static_cast( + m_gameConfig.stations.enemyStation.rangeFormula.evaluate(lv)); + weapon.fireRateHz = static_cast( + 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 yDist(0.0f, + static_cast(m_arenaConfig.heightTiles)); + + // Team 1: isEnemy=false, spawn in player buffer zone. + { + std::uniform_real_distribution xDist(0.0f, + static_cast(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 xDist( + static_cast(team2Start), + static_cast(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 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 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 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 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 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; + updateStatus(); + return; + } + + // Check if all ships of one team are destroyed. + bool team1HasShips = false; + bool team2HasShips = false; + m_shipSystem->forEach([&team1HasShips, &team2HasShips](Ship& s) + { + if (s.isEnemy) + { + team2HasShips = true; + } + else + { + team1HasShips = true; + } + }); + + if (!team1HasShips || !team2HasShips) + { + m_finished = true; + updateStatus(); + } +} + +void ArenaSimulation::updateStatus() +{ + ArenaStatus newStatus; + newStatus.finished = m_finished; + + 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 lock(m_statusMutex); + m_status = newStatus; +} diff --git a/src/balancing/ArenaSimulation.h b/src/balancing/ArenaSimulation.h new file mode 100644 index 0000000..445e4ac --- /dev/null +++ b/src/balancing/ArenaSimulation.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#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 entries; // HQ first, then ships, then stations + }; + + TeamStatus teams[2]; + bool finished = false; +}; + +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 m_buildingSystem; + std::unique_ptr m_shipSystem; + std::unique_ptr m_combatSystem; + std::unique_ptr m_scrapSystem; + + EntityId m_team1HqId; + EntityId m_team2HqId; + + bool m_finished; + std::atomic m_stopRequested; + + mutable std::mutex m_statusMutex; + ArenaStatus m_status; +}; diff --git a/src/balancing/BalancingConfig.cpp b/src/balancing/BalancingConfig.cpp new file mode 100644 index 0000000..079cae1 --- /dev/null +++ b/src/balancing/BalancingConfig.cpp @@ -0,0 +1,166 @@ +#include "BalancingConfig.h" + +#include +#include + +#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 +int64_t requireInt(NodeView node, const std::string& path) +{ + const std::optional value = node.template value(); + if (!value) + { + throw makeError(path, "missing or not an integer"); + } + return *value; +} + +template +std::string requireString(NodeView node, const std::string& path) +{ + const std::optional value = node.template value(); + 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( + requireInt((*arenaTbl)["height_tiles"], prefix + ".height_tiles")); + arena.playerBufferWidth = static_cast( + requireInt((*arenaTbl)["player_buffer_width"], prefix + ".player_buffer_width")); + arena.contestZoneWidth = static_cast( + requireInt((*arenaTbl)["contest_zone_width"], prefix + ".contest_zone_width")); + arena.enemyBufferWidth = static_cast( + 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(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( + requireInt((*shipTbl)["level"], sPrefix + ".level")); + entry.count = static_cast( + 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( + requireInt((*stationTbl)["level"], sPrefix + ".level")); + entry.position = QPoint( + static_cast(requireInt((*stationTbl)["x"], sPrefix + ".x")), + static_cast(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; +} diff --git a/src/balancing/BalancingConfig.h b/src/balancing/BalancingConfig.h new file mode 100644 index 0000000..3d4d0d8 --- /dev/null +++ b/src/balancing/BalancingConfig.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include + +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 ships; + std::vector stations; +}; + +struct ArenaConfig +{ + std::string name; + int heightTiles; + int playerBufferWidth; + int contestZoneWidth; + int enemyBufferWidth; + ArenaTeamConfig teams[2]; +}; + +struct BalancingConfig +{ + std::vector arenas; +}; + +BalancingConfig loadBalancingConfig(const std::string& path); diff --git a/src/balancing/BalancingWindow.cpp b/src/balancing/BalancingWindow.cpp new file mode 100644 index 0000000..fe70bc3 --- /dev/null +++ b/src/balancing/BalancingWindow.cpp @@ -0,0 +1,80 @@ +#include "BalancingWindow.h" + +#include +#include + +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( + 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); + } +} diff --git a/src/balancing/BalancingWindow.h b/src/balancing/BalancingWindow.h new file mode 100644 index 0000000..ab5fbbb --- /dev/null +++ b/src/balancing/BalancingWindow.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#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 simulation; + std::thread worker; + ArenaButton* button; + }; + + std::vector m_arenas; + QTimer* m_pollTimer; +}; diff --git a/src/balancing/CMakeLists.txt b/src/balancing/CMakeLists.txt new file mode 100644 index 0000000..62de17e --- /dev/null +++ b/src/balancing/CMakeLists.txt @@ -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 +) diff --git a/src/balancing/main.cpp b/src/balancing/main.cpp new file mode 100644 index 0000000..f21ddc3 --- /dev/null +++ b/src/balancing/main.cpp @@ -0,0 +1,34 @@ +#include + +#include + +#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()); + 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(); +}