diff --git a/Core/GameEngine/Source/Common/ReplaySimulation.cpp b/Core/GameEngine/Source/Common/ReplaySimulation.cpp index 7d18b5cb58f..59d6803976c 100644 --- a/Core/GameEngine/Source/Common/ReplaySimulation.cpp +++ b/Core/GameEngine/Source/Common/ReplaySimulation.cpp @@ -21,8 +21,10 @@ #include "Common/ReplaySimulation.h" #include "Common/GameEngine.h" +#include "Common/GlobalData.h" #include "Common/LocalFileSystem.h" #include "Common/Recorder.h" +#include "Common/StatsExporter.h" #include "Common/WorkerProcess.h" #include "GameLogic/GameLogic.h" #include "GameClient/GameClient.h" @@ -81,6 +83,8 @@ int ReplaySimulation::simulateReplaysInThisProcess(const std::vectorm_exportStats) + StatsExporterBeginRecording(); if (TheRecorder->simulateReplay(filename)) { UnsignedInt totalTimeSec = TheRecorder->getPlaybackFrameCount() / LOGICFRAMES_PER_SECOND; @@ -99,6 +103,8 @@ int ReplaySimulation::simulateReplaysInThisProcess(const std::vectorUPDATE(); + if (TheGlobalData->m_exportStats) + StatsExporterCollectSnapshot(); if (TheRecorder->sawCRCMismatch()) { numErrors++; @@ -110,6 +116,8 @@ int ReplaySimulation::simulateReplaysInThisProcess(const std::vectorm_exportStats) + ExportGameStatsJSON(TheRecorder->getReplayDir(), filename); } else { @@ -171,11 +179,20 @@ int ReplaySimulation::simulateReplaysInWorkerProcesses(const std::vectorm_windowed ? L" -win" : L"", TheGlobalData->m_headless ? L" -headless" : L"", + TheGlobalData->m_exportStats ? L" -exportStats" : L"", filenameWide.str()); + if (!TheGlobalData->m_statsUrl.isEmpty()) + { + UnicodeString statsUrlWide; + statsUrlWide.translate(TheGlobalData->m_statsUrl); + command.concat(L" -statsUrl \""); + command.concat(statsUrlWide); + command.concat(L"\""); + } processes.push_back(WorkerProcess()); processes.back().startProcess(command); diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 01aabee60d5..c418a8ff584 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -111,6 +111,8 @@ set(GAMEENGINE_SRC Include/Common/StackDump.h Include/Common/StateMachine.h Include/Common/StatsCollector.h + Include/Common/StatsExporter.h + Include/Common/StatsUploader.h # Include/Common/STLTypedefs.h # Include/Common/StreamingArchiveFile.h # Include/Common/SubsystemInterface.h @@ -630,6 +632,8 @@ set(GAMEENGINE_SRC Source/Common/SkirmishBattleHonors.cpp Source/Common/StateMachine.cpp Source/Common/StatsCollector.cpp + Source/Common/StatsExporter.cpp + Source/Common/StatsUploader.cpp # Source/Common/System/ArchiveFile.cpp # Source/Common/System/ArchiveFileSystem.cpp # Source/Common/System/AsciiString.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h b/GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h index bf51ee10116..564906e6120 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h @@ -126,6 +126,25 @@ class AcademyStats : public Snapshot Bool calculateAcademyAdvice( AcademyAdviceInfo *info ); + UnsignedInt getSupplyCentersBuilt() const { return m_supplyCentersBuilt; } + UnsignedInt getPeonsBuilt() const { return m_peonsBuilt; } + UnsignedInt getStructuresCaptured() const { return m_structuresCaptured; } + UnsignedInt getGeneralsPointsSpent() const { return m_generalsPointsSpent; } + UnsignedInt getSpecialPowersUsed() const { return m_specialPowersUsed; } + UnsignedInt getStructuresGarrisoned() const { return m_structuresGarrisoned; } + UnsignedInt getUpgradesPurchased() const { return m_upgradesPurchased; } + UnsignedInt getGatherersBuilt() const { return m_gatherersBuilt; } + UnsignedInt getHeroesBuilt() const { return m_heroesBuilt; } + UnsignedInt getControlGroupsUsed() const { return m_controlGroupsUsed; } + UnsignedInt getSecondaryIncomeUnitsBuilt() const { return m_secondaryIncomeUnitsBuilt; } + UnsignedInt getClearedGarrisonedBuildings() const { return m_clearedGarrisonedBuildings; } + UnsignedInt getSalvageCollected() const { return m_salvageCollected; } + UnsignedInt getGuardAbilityUsedCount() const { return m_guardAbilityUsedCount; } + UnsignedInt getDoubleClickAttackMoveOrdersGiven() const { return m_doubleClickAttackMoveOrdersGiven; } + UnsignedInt getMinesCleared() const { return m_minesCleared; } + UnsignedInt getVehiclesDisguised() const { return m_vehiclesDisguised; } + UnsignedInt getFirestormsCreated() const { return m_firestormsCreated; } + protected: // snapshot methods virtual void crc( Xfer *xfer ); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 48947fdd0d7..a488f7793b3 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -121,6 +121,12 @@ class GlobalData : public SubsystemInterface // Run game without graphics, input or audio. Bool m_headless; + // Export game stats as JSON alongside replay file. + Bool m_exportStats; + + // URL to POST compressed stats JSON after export. + AsciiString m_statsUrl; + Bool m_windowed; Int m_xResolution; Int m_yResolution; diff --git a/GeneralsMD/Code/GameEngine/Include/Common/StatsExporter.h b/GeneralsMD/Code/GameEngine/Include/Common/StatsExporter.h new file mode 100644 index 00000000000..da73b09388f --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/Common/StatsExporter.h @@ -0,0 +1,44 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#pragma once + +class AsciiString; +class Object; +class Player; +class DamageInfo; + +/// Export game statistics as a JSON file alongside the replay file. +/// @param replayDir Directory containing replays (e.g. "[UserDataPath]/Replays/") +/// @param replayFileName Replay filename with extension (e.g. "LastReplay.rep") +void ExportGameStatsJSON(const AsciiString& replayDir, const AsciiString& replayFileName); + +/// Collect a time-series snapshot of all players' stats (called every game logic frame). +/// Snapshots are taken every 30 frames (~1 second) and stored in memory. +void StatsExporterCollectSnapshot(); + +/// Begin recording stats for a new replay. Activates recording and resets all stored data. +void StatsExporterBeginRecording(); + +/// Record a kill event with full context (called from Object::scoreTheKill). +void StatsExporterRecordKill(const Object *killer, const Object *victim, const DamageInfo *damageInfo); + +/// Record a build event (called from Player::onUnitCreated / onStructureConstructionComplete). +void StatsExporterRecordBuild(const Object *producer, const Object *built); + +/// Record a capture event (called from Object::onCapture). +void StatsExporterRecordCapture(const Object *captured, const Player *oldOwner, const Player *newOwner); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/StatsUploader.h b/GeneralsMD/Code/GameEngine/Include/Common/StatsUploader.h new file mode 100644 index 00000000000..19b1b51caf3 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/Common/StatsUploader.h @@ -0,0 +1,27 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#pragma once + +class AsciiString; + +/// Upload gzip-compressed stats data to a REST endpoint via HTTP POST. +/// @param url Full URL including path (e.g. "http://server:8080/stats") +/// @param data Pointer to gzip-compressed data +/// @param dataLen Length of compressed data in bytes +/// @param seed Game seed for the X-Game-Seed header +void UploadStatsToServer(const AsciiString& url, const void *data, unsigned int dataLen, unsigned int seed); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp index 1974175d4c9..7205f59c60a 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp @@ -417,6 +417,22 @@ Int parseHeadless(char *args[], int num) return 1; } +Int parseExportStats(char *args[], int num) +{ + TheWritableGlobalData->m_exportStats = TRUE; + return 1; +} + +Int parseStatsUrl(char *args[], int num) +{ + if (num > 1) + { + TheWritableGlobalData->m_statsUrl = args[1]; + return 2; + } + return 1; +} + Int parseReplay(char *args[], int num) { if (num > 1) @@ -1175,6 +1191,12 @@ static CommandLineParam paramsForStartup[] = // (If you have 4 cores, call it with -jobs 4) // If you do not call this, all replays will be simulated in sequence in the same process. { "-jobs", parseJobs }, + + // Export game stats as JSON alongside replay file. + { "-exportStats", parseExportStats }, + + // URL to POST compressed stats JSON after export. + { "-statsUrl", parseStatsUrl }, }; // These Params are parsed during Engine Init before INI data is loaded diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp index ed94ec7bf54..b9a92962e05 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp @@ -45,7 +45,13 @@ Int GameMain() TheGameEngine = CreateGameEngine(); TheGameEngine->init(); - if (!TheGlobalData->m_simulateReplays.empty()) + if (TheGlobalData->m_exportStats && (!TheGlobalData->m_headless || TheGlobalData->m_simulateReplays.empty())) + { + printf("ERROR: -exportStats requires headless replay mode (-headless -replay ).\n"); + fflush(stdout); + exitcode = 1; + } + else if (!TheGlobalData->m_simulateReplays.empty()) { exitcode = ReplaySimulation::simulateReplays(TheGlobalData->m_simulateReplays, TheGlobalData->m_simulateReplayJobs); } diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index 46d1e2f34cb..7883cc65df8 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -631,6 +631,7 @@ GlobalData::GlobalData() m_framesPerSecondLimit = 0; m_chipSetType = 0; m_headless = FALSE; + m_exportStats = FALSE; m_windowed = 0; m_xResolution = 800; m_yResolution = 600; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp index 9a1e33e3cd4..958f9870ac2 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp @@ -48,6 +48,7 @@ #include "Common/ActionManager.h" #include "Common/BuildAssistant.h" +#include "Common/StatsExporter.h" #include "Common/CRCDebug.h" #include "Common/DisabledTypes.h" #include "Common/GameState.h" @@ -1556,6 +1557,10 @@ void Player::onUnitCreated( Object *factory, Object *unit ) // increment our scorekeeper m_scoreKeeper.addObjectBuilt(unit); + m_scoreKeeper.addMoneySpent(unit->getTemplate()->calcCostToBuild(this)); + + if (TheGlobalData->m_exportStats) + StatsExporterRecordBuild(factory, unit); // ai notification callback if( m_ai ) @@ -1645,6 +1650,8 @@ void Player::onStructureConstructionComplete( Object *builder, Object *structure if (isRebuild == FALSE) { m_scoreKeeper.addObjectBuilt(structure); m_scoreKeeper.addMoneySpent(structure->getTemplate()->calcCostToBuild(this)); + if (TheGlobalData->m_exportStats) + StatsExporterRecordBuild(builder, structure); } structure->friend_adjustPowerForPlayer(TRUE); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp b/GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp new file mode 100644 index 00000000000..48631a04660 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp @@ -0,0 +1,864 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#include "Common/StatsExporter.h" +#include "Common/StatsUploader.h" +#include "Common/Player.h" +#include "Common/PlayerList.h" +#include "Common/PlayerTemplate.h" +#include "Common/GlobalData.h" +#include "Common/Energy.h" +#include "Common/ThingTemplate.h" +#include "Common/RandomValue.h" +#include "GameLogic/Damage.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Object.h" +#include "GameLogic/Module/BodyModule.h" +#include "GameLogic/Module/BattlePlanUpdate.h" + +#include +#include + +#include "GameNetwork/GeneralsOnline/json.hpp" + +using ordered_json = nlohmann::ordered_json; + +//----------------------------------------------------------------------------- + +static std::string wideToString(const WideChar *s) +{ + std::string result; + if (s == nullptr) return result; + for (; *s != L'\0'; ++s) + { + unsigned int c = static_cast(*s); + if (c < 0x80) + { + result += static_cast(c); + } + else if (c < 0x800) + { + result += static_cast(0xC0 | (c >> 6)); + result += static_cast(0x80 | (c & 0x3F)); + } + else + { + result += static_cast(0xE0 | (c >> 12)); + result += static_cast(0x80 | ((c >> 6) & 0x3F)); + result += static_cast(0x80 | (c & 0x3F)); + } + } + return result; +} + +//----------------------------------------------------------------------------- + +static const char* gameModeToString(GameMode mode) +{ + switch (mode) + { + case GAME_SINGLE_PLAYER: return "SinglePlayer"; + case GAME_LAN: return "LAN"; + case GAME_SKIRMISH: return "Skirmish"; + case GAME_REPLAY: return "Replay"; + case GAME_SHELL: return "Shell"; + case GAME_INTERNET: return "Internet"; + case GAME_NONE: return "None"; + default: return "Unknown"; + } +} + +//----------------------------------------------------------------------------- + +static Bool isGamePlayer(Player *player) +{ + if (player == nullptr) return FALSE; + const PlayerTemplate *pt = player->getPlayerTemplate(); + if (pt == nullptr) return FALSE; + const char *name = pt->getName().str(); + if (name == nullptr || name[0] == '\0') return FALSE; + if (strcmp(name, "FactionObserver") == 0) return FALSE; + if (strcmp(name, "FactionCivilian") == 0) return FALSE; + return TRUE; +} + +//----------------------------------------------------------------------------- + +struct PlayerSnapshotData +{ + Int playerIndex; + UnsignedInt money; + Int moneyEarned; + Int moneySpent; +}; + +struct PlayerStateData +{ + Int energyProduction; + Int energyConsumption; + Int rankLevel; + Int skillPoints; + Int sciencePurchasePoints; + Bool hasRadar; + Bool isDead; + Int bombardment; + Int holdTheLine; + Int searchAndDestroy; +}; + +struct StateChangeEvent +{ + UnsignedInt frame; + Int playerIndex; +}; + +struct EnergyEvent : StateChangeEvent { Int production; Int consumption; }; +struct RankEvent : StateChangeEvent { Int rankLevel; }; +struct SkillPointsEvent : StateChangeEvent { Int skillPoints; }; +struct SciencePointsEvent : StateChangeEvent { Int sciencePurchasePoints; }; +struct RadarEvent : StateChangeEvent { Bool hasRadar; }; +struct DeathEvent : StateChangeEvent {}; +struct BattlePlanEvent : StateChangeEvent { Int bombardment; Int holdTheLine; Int searchAndDestroy; }; + +struct FrameSnapshotData +{ + UnsignedInt frame; + Int playerCount; + PlayerSnapshotData players[MAX_PLAYER_COUNT]; +}; + +struct KillEventData +{ + UnsignedInt frame; + Int killerPlayerIndex; + Int victimPlayerIndex; + Real x; + Real y; + char killerTemplateName[64]; + char victimTemplateName[64]; + char damageType[32]; +}; + +struct BuildEventData +{ + UnsignedInt frame; + Int playerIndex; + Real x; + Real y; + Int cost; + Int buildTime; + char templateName[64]; + char producerTemplateName[64]; +}; + +struct CaptureEventData +{ + UnsignedInt frame; + Int newOwnerPlayerIndex; + Int oldOwnerPlayerIndex; + Real x; + Real y; + char templateName[64]; +}; + +struct StatsExporterState +{ + Bool exportingActive; + Bool mappingInitialized; + Int gamePlayerCount; + Int originalToNewIndex[MAX_PLAYER_COUNT]; + UnsignedInt lastSnapshotFrame; + PlayerStateData lastPlayerState[MAX_PLAYER_COUNT]; + + std::vector snapshots; + std::vector killEvents; + std::vector buildEvents; + std::vector captureEvents; + std::vector energyEvents; + std::vector rankEvents; + std::vector skillPointsEvents; + std::vector sciencePointsEvents; + std::vector radarEvents; + std::vector deathEvents; + std::vector battlePlanEvents; + + void resetData() + { + mappingInitialized = FALSE; + gamePlayerCount = 0; + lastSnapshotFrame = 0; + memset(originalToNewIndex, 0, sizeof(originalToNewIndex)); + memset(lastPlayerState, 0, sizeof(lastPlayerState)); + snapshots.clear(); + killEvents.clear(); + buildEvents.clear(); + captureEvents.clear(); + energyEvents.clear(); + rankEvents.clear(); + skillPointsEvents.clear(); + sciencePointsEvents.clear(); + radarEvents.clear(); + deathEvents.clear(); + battlePlanEvents.clear(); + } +}; + +static StatsExporterState s_state; + +//----------------------------------------------------------------------------- + +static Int remapPlayerIndex(Int rawIndex) +{ + if (rawIndex >= 0 && rawIndex < MAX_PLAYER_COUNT) + return s_state.originalToNewIndex[rawIndex]; + return 0; +} + +//----------------------------------------------------------------------------- + +static void initPlayerMapping() +{ + if (s_state.mappingInitialized) + return; + + s_state.gamePlayerCount = 0; + memset(s_state.originalToNewIndex, 0, sizeof(s_state.originalToNewIndex)); + + const Int totalPlayers = ThePlayerList->getPlayerCount(); + Int i; + for (i = 0; i < totalPlayers && i < MAX_PLAYER_COUNT; ++i) + { + Player *player = ThePlayerList->getNthPlayer(i); + if (isGamePlayer(player)) + { + ++s_state.gamePlayerCount; + s_state.originalToNewIndex[i] = s_state.gamePlayerCount; + } + } + + // Only lock in the mapping once we find actual game players. + // Early calls (before players are fully initialized) will retry. + if (s_state.gamePlayerCount > 0) + s_state.mappingInitialized = TRUE; +} + +//----------------------------------------------------------------------------- + +void StatsExporterCollectSnapshot() +{ + if (ThePlayerList == nullptr || TheGameLogic == nullptr) + return; + + UnsignedInt currentFrame = TheGameLogic->getFrame(); + if (!s_state.snapshots.empty() && (currentFrame - s_state.lastSnapshotFrame) < 30) + return; + + s_state.lastSnapshotFrame = currentFrame; + + initPlayerMapping(); + + const Int totalPlayers = ThePlayerList->getPlayerCount(); + + FrameSnapshotData snap; + memset(&snap, 0, sizeof(snap)); + snap.frame = currentFrame; + snap.playerCount = s_state.gamePlayerCount; + + Int gameIdx = 0; + Int i; + for (i = 0; i < totalPlayers && i < MAX_PLAYER_COUNT; ++i) + { + if (s_state.originalToNewIndex[i] == 0) + continue; + + Player *player = ThePlayerList->getNthPlayer(i); + if (player == nullptr) + continue; + + PlayerSnapshotData &pd = snap.players[gameIdx]; + ScoreKeeper *sk = player->getScoreKeeper(); + const Energy *energy = player->getEnergy(); + + pd.playerIndex = s_state.originalToNewIndex[i]; + pd.money = player->getMoney()->countMoney(); + pd.moneyEarned = sk->getTotalMoneyEarned(); + pd.moneySpent = sk->getTotalMoneySpent(); + + // Detect state changes and emit events + { + PlayerStateData &last = s_state.lastPlayerState[i]; + Int curVal, curVal2, curVal3; + Bool curBool; + + curVal = energy->getProduction(); + curVal2 = energy->getConsumption(); + if (curVal != last.energyProduction || curVal2 != last.energyConsumption) + { + EnergyEvent eev; + memset(&eev, 0, sizeof(eev)); + eev.frame = currentFrame; + eev.playerIndex = i; + eev.production = curVal; + eev.consumption = curVal2; + s_state.energyEvents.push_back(eev); + last.energyProduction = curVal; + last.energyConsumption = curVal2; + } + + curVal = player->getRankLevel(); + if (curVal != last.rankLevel) + { + RankEvent rev; + memset(&rev, 0, sizeof(rev)); + rev.frame = currentFrame; + rev.playerIndex = i; + rev.rankLevel = curVal; + s_state.rankEvents.push_back(rev); + last.rankLevel = curVal; + } + + curVal = player->getSkillPoints(); + if (curVal != last.skillPoints) + { + SkillPointsEvent sev; + memset(&sev, 0, sizeof(sev)); + sev.frame = currentFrame; + sev.playerIndex = i; + sev.skillPoints = curVal; + s_state.skillPointsEvents.push_back(sev); + last.skillPoints = curVal; + } + + curVal = player->getSciencePurchasePoints(); + if (curVal != last.sciencePurchasePoints) + { + SciencePointsEvent spev; + memset(&spev, 0, sizeof(spev)); + spev.frame = currentFrame; + spev.playerIndex = i; + spev.sciencePurchasePoints = curVal; + s_state.sciencePointsEvents.push_back(spev); + last.sciencePurchasePoints = curVal; + } + + curBool = player->hasRadar(); + if (curBool != last.hasRadar) + { + RadarEvent raev; + memset(&raev, 0, sizeof(raev)); + raev.frame = currentFrame; + raev.playerIndex = i; + raev.hasRadar = curBool; + s_state.radarEvents.push_back(raev); + last.hasRadar = curBool; + } + + curBool = player->isPlayerDead(); + if (curBool && !last.isDead) + { + DeathEvent dev; + memset(&dev, 0, sizeof(dev)); + dev.frame = currentFrame; + dev.playerIndex = i; + s_state.deathEvents.push_back(dev); + last.isDead = curBool; + } + + curVal = player->getBattlePlansActiveSpecific(PLANSTATUS_BOMBARDMENT); + curVal2 = player->getBattlePlansActiveSpecific(PLANSTATUS_HOLDTHELINE); + curVal3 = player->getBattlePlansActiveSpecific(PLANSTATUS_SEARCHANDDESTROY); + if (curVal != last.bombardment || curVal2 != last.holdTheLine || curVal3 != last.searchAndDestroy) + { + BattlePlanEvent bev; + memset(&bev, 0, sizeof(bev)); + bev.frame = currentFrame; + bev.playerIndex = i; + bev.bombardment = curVal; + bev.holdTheLine = curVal2; + bev.searchAndDestroy = curVal3; + s_state.battlePlanEvents.push_back(bev); + last.bombardment = curVal; + last.holdTheLine = curVal2; + last.searchAndDestroy = curVal3; + } + } + + ++gameIdx; + } + + s_state.snapshots.push_back(snap); +} + +//----------------------------------------------------------------------------- + +void StatsExporterBeginRecording() +{ + s_state.exportingActive = TRUE; + s_state.resetData(); +} + +//----------------------------------------------------------------------------- + +void StatsExporterRecordKill(const Object *killer, const Object *victim, const DamageInfo *damageInfo) +{ + if (!s_state.exportingActive) + return; + if (killer == nullptr || victim == nullptr || TheGameLogic == nullptr) + return; + + const Player *killerPlayer = killer->getControllingPlayer(); + const Player *victimPlayer = victim->getControllingPlayer(); + if (killerPlayer == nullptr || victimPlayer == nullptr) + return; + + KillEventData ev; + memset(&ev, 0, sizeof(ev)); + ev.frame = TheGameLogic->getFrame(); + + // Store raw player indices; remapped to game-player indices at export time. + ev.killerPlayerIndex = killerPlayer->getPlayerIndex(); + ev.victimPlayerIndex = victimPlayer->getPlayerIndex(); + + const Coord3D *pos = victim->getPosition(); + if (pos != nullptr) + { + ev.x = pos->x; + ev.y = pos->y; + } + + strlcpy(ev.killerTemplateName, killer->getTemplate()->getName().str(), ARRAY_SIZE(ev.killerTemplateName)); + strlcpy(ev.victimTemplateName, victim->getTemplate()->getName().str(), ARRAY_SIZE(ev.victimTemplateName)); + + if (damageInfo != nullptr && damageInfo->in.m_damageType >= 0 && damageInfo->in.m_damageType < DAMAGE_NUM_TYPES) + { + const char *name = DamageTypeFlags::s_bitNameList[damageInfo->in.m_damageType]; + if (name != nullptr) + strlcpy(ev.damageType, name, ARRAY_SIZE(ev.damageType)); + } + + s_state.killEvents.push_back(ev); +} + +//----------------------------------------------------------------------------- + +void StatsExporterRecordBuild(const Object *producer, const Object *built) +{ + if (!s_state.exportingActive) + return; + if (built == nullptr || TheGameLogic == nullptr) + return; + + const Player *player = built->getControllingPlayer(); + if (player == nullptr) + return; + + BuildEventData ev; + memset(&ev, 0, sizeof(ev)); + ev.frame = TheGameLogic->getFrame(); + + // Store raw player index; remapped at export time. + ev.playerIndex = player->getPlayerIndex(); + + const Coord3D *pos = built->getPosition(); + if (pos != nullptr) + { + ev.x = pos->x; + ev.y = pos->y; + } + ev.cost = built->getTemplate()->calcCostToBuild(player); + ev.buildTime = built->getTemplate()->calcTimeToBuild(player); + + strlcpy(ev.templateName, built->getTemplate()->getName().str(), ARRAY_SIZE(ev.templateName)); + + if (producer != nullptr) + strlcpy(ev.producerTemplateName, producer->getTemplate()->getName().str(), ARRAY_SIZE(ev.producerTemplateName)); + + s_state.buildEvents.push_back(ev); +} + +//----------------------------------------------------------------------------- + +void StatsExporterRecordCapture(const Object *captured, const Player *oldOwner, const Player *newOwner) +{ + if (!s_state.exportingActive) + return; + if (captured == nullptr || oldOwner == nullptr || newOwner == nullptr || TheGameLogic == nullptr) + return; + if (oldOwner == newOwner) + return; + + CaptureEventData ev; + memset(&ev, 0, sizeof(ev)); + ev.frame = TheGameLogic->getFrame(); + + // Store raw player indices; remapped at export time. + ev.newOwnerPlayerIndex = newOwner->getPlayerIndex(); + ev.oldOwnerPlayerIndex = oldOwner->getPlayerIndex(); + + const Coord3D *pos = captured->getPosition(); + if (pos != nullptr) + { + ev.x = pos->x; + ev.y = pos->y; + } + + strlcpy(ev.templateName, captured->getTemplate()->getName().str(), ARRAY_SIZE(ev.templateName)); + + s_state.captureEvents.push_back(ev); +} + +//----------------------------------------------------------------------------- + +static ordered_json buildCaptureEventsJson() +{ + ordered_json arr = ordered_json::array(); + for (size_t i = 0; i < s_state.captureEvents.size(); ++i) + { + const CaptureEventData &ev = s_state.captureEvents[i]; + arr.push_back(ordered_json{ + {"frame", ev.frame}, + {"newOwner", remapPlayerIndex(ev.newOwnerPlayerIndex)}, + {"oldOwner", remapPlayerIndex(ev.oldOwnerPlayerIndex)}, + {"x", ev.x}, + {"y", ev.y}, + {"object", ev.templateName} + }); + } + return arr; +} + +//----------------------------------------------------------------------------- + +static ordered_json buildBuildEventsJson() +{ + ordered_json arr = ordered_json::array(); + for (size_t i = 0; i < s_state.buildEvents.size(); ++i) + { + const BuildEventData &ev = s_state.buildEvents[i]; + arr.push_back(ordered_json{ + {"frame", ev.frame}, + {"player", remapPlayerIndex(ev.playerIndex)}, + {"x", ev.x}, + {"y", ev.y}, + {"cost", ev.cost}, + {"buildTime", ev.buildTime}, + {"object", ev.templateName}, + {"producer", ev.producerTemplateName} + }); + } + return arr; +} + +//----------------------------------------------------------------------------- + +static ordered_json buildKillEventsJson() +{ + ordered_json arr = ordered_json::array(); + for (size_t i = 0; i < s_state.killEvents.size(); ++i) + { + const KillEventData &ev = s_state.killEvents[i]; + arr.push_back(ordered_json{ + {"frame", ev.frame}, + {"killerPlayer", remapPlayerIndex(ev.killerPlayerIndex)}, + {"victimPlayer", remapPlayerIndex(ev.victimPlayerIndex)}, + {"x", ev.x}, + {"y", ev.y}, + {"killer", ev.killerTemplateName}, + {"victim", ev.victimTemplateName}, + {"damageType", ev.damageType} + }); + } + return arr; +} + +//----------------------------------------------------------------------------- + +static void buildStateChangeEventsJson(ordered_json &root) +{ + size_t i; + + ordered_json energyArr = ordered_json::array(); + for (i = 0; i < s_state.energyEvents.size(); ++i) + { + const EnergyEvent &ev = s_state.energyEvents[i]; + energyArr.push_back(ordered_json{ + {"frame", ev.frame}, {"player", remapPlayerIndex(ev.playerIndex)}, + {"production", ev.production}, {"consumption", ev.consumption} + }); + } + root["energyEvents"] = energyArr; + + ordered_json rankArr = ordered_json::array(); + for (i = 0; i < s_state.rankEvents.size(); ++i) + { + const RankEvent &ev = s_state.rankEvents[i]; + rankArr.push_back(ordered_json{ + {"frame", ev.frame}, {"player", remapPlayerIndex(ev.playerIndex)}, + {"rankLevel", ev.rankLevel} + }); + } + root["rankEvents"] = rankArr; + + ordered_json skillArr = ordered_json::array(); + for (i = 0; i < s_state.skillPointsEvents.size(); ++i) + { + const SkillPointsEvent &ev = s_state.skillPointsEvents[i]; + skillArr.push_back(ordered_json{ + {"frame", ev.frame}, {"player", remapPlayerIndex(ev.playerIndex)}, + {"skillPoints", ev.skillPoints} + }); + } + root["skillPointsEvents"] = skillArr; + + ordered_json scienceArr = ordered_json::array(); + for (i = 0; i < s_state.sciencePointsEvents.size(); ++i) + { + const SciencePointsEvent &ev = s_state.sciencePointsEvents[i]; + scienceArr.push_back(ordered_json{ + {"frame", ev.frame}, {"player", remapPlayerIndex(ev.playerIndex)}, + {"sciencePurchasePoints", ev.sciencePurchasePoints} + }); + } + root["sciencePointsEvents"] = scienceArr; + + ordered_json radarArr = ordered_json::array(); + for (i = 0; i < s_state.radarEvents.size(); ++i) + { + const RadarEvent &ev = s_state.radarEvents[i]; + radarArr.push_back(ordered_json{ + {"frame", ev.frame}, {"player", remapPlayerIndex(ev.playerIndex)}, + {"hasRadar", static_cast(ev.hasRadar)} + }); + } + root["radarEvents"] = radarArr; + + ordered_json deathArr = ordered_json::array(); + for (i = 0; i < s_state.deathEvents.size(); ++i) + { + const DeathEvent &ev = s_state.deathEvents[i]; + deathArr.push_back(ordered_json{ + {"frame", ev.frame}, {"player", remapPlayerIndex(ev.playerIndex)} + }); + } + root["deathEvents"] = deathArr; + + ordered_json bpArr = ordered_json::array(); + for (i = 0; i < s_state.battlePlanEvents.size(); ++i) + { + const BattlePlanEvent &ev = s_state.battlePlanEvents[i]; + bpArr.push_back(ordered_json{ + {"frame", ev.frame}, {"player", remapPlayerIndex(ev.playerIndex)}, + {"bombardment", ev.bombardment}, {"holdTheLine", ev.holdTheLine}, + {"searchAndDestroy", ev.searchAndDestroy} + }); + } + root["battlePlanEvents"] = bpArr; +} + +//----------------------------------------------------------------------------- + +static ordered_json buildTimeSeriesJson() +{ + ordered_json ts; + ordered_json playersArr = ordered_json::array(); + + for (Int pi = 0; pi < s_state.gamePlayerCount; ++pi) + { + ordered_json p; + p["index"] = pi + 1; + + ordered_json money = ordered_json::array(); + ordered_json moneyEarned = ordered_json::array(); + ordered_json moneySpent = ordered_json::array(); + + for (size_t s = 0; s < s_state.snapshots.size(); ++s) + { + money.push_back(s_state.snapshots[s].players[pi].money); + moneyEarned.push_back(s_state.snapshots[s].players[pi].moneyEarned); + moneySpent.push_back(s_state.snapshots[s].players[pi].moneySpent); + } + + p["money"] = money; + p["moneyEarned"] = moneyEarned; + p["moneySpent"] = moneySpent; + playersArr.push_back(p); + } + + ts["players"] = playersArr; + return ts; +} + +//----------------------------------------------------------------------------- + +void ExportGameStatsJSON(const AsciiString& replayDir, const AsciiString& replayFileName) +{ + if (ThePlayerList == nullptr || TheGameLogic == nullptr || TheGlobalData == nullptr) + return; + + // Strip any directory components from the replay filename + const char *replayBase = replayFileName.str(); + const char *lastSlash = strrchr(replayBase, '/'); + const char *lastBackslash = strrchr(replayBase, '\\'); + if (lastBackslash != nullptr && (lastSlash == nullptr || lastBackslash > lastSlash)) + lastSlash = lastBackslash; + if (lastSlash != nullptr) + replayBase = lastSlash + 1; + + // Build stats file path: replace .rep extension with .gamestats.json.gz + char baseName[_MAX_PATH + 1]; + strlcpy(baseName, replayBase, ARRAY_SIZE(baseName)); + char *dot = strrchr(baseName, '.'); + if (dot != nullptr) *dot = '\0'; + + AsciiString statsPath; + statsPath.format("%s%s.gamestats.json.gz", replayDir.str(), baseName); + + initPlayerMapping(); + + const Int playerCount = ThePlayerList->getPlayerCount(); + + // Build JSON document + ordered_json root; + root["version"] = 1; + + // Game info + root["game"] = ordered_json{ + {"map", TheGlobalData->m_mapName.str()}, + {"mode", gameModeToString(TheGameLogic->getGameMode())}, + {"frameCount", TheGameLogic->getFrame()}, + {"seed", GetGameLogicRandomSeed()}, + {"replayFile", replayFileName.str()}, + {"playerCount", s_state.gamePlayerCount}, + {"snapshotInterval", 30} + }; + + // Players array + ordered_json playersArr = ordered_json::array(); + Int i; + for (i = 0; i < playerCount; ++i) + { + Player *player = ThePlayerList->getNthPlayer(i); + if (player == nullptr || !isGamePlayer(player)) + continue; + + ScoreKeeper *sk = player->getScoreKeeper(); + const PlayerTemplate *pt = player->getPlayerTemplate(); + const AcademyStats *academy = player->getAcademyStats(); + + ordered_json p; + p["index"] = s_state.originalToNewIndex[i]; + p["displayName"] = wideToString(player->getPlayerDisplayName().str()); + if (pt != nullptr) + p["faction"] = pt->getName().str(); + p["side"] = player->getSide().str(); + p["baseSide"] = player->getBaseSide().str(); + p["type"] = player->getPlayerType() == PLAYER_HUMAN ? "Human" : "Computer"; + + char colorBuf[8]; + snprintf(colorBuf, sizeof(colorBuf), "#%06X", static_cast(player->getPlayerColor()) & 0x00FFFFFFu); + p["color"] = colorBuf; + + p["money"] = player->getMoney()->countMoney(); + p["moneyEarned"] = sk->getTotalMoneyEarned(); + p["moneySpent"] = sk->getTotalMoneySpent(); + p["score"] = sk->calculateScore(); + + p["academy"] = ordered_json{ + {"supplyCentersBuilt", academy->getSupplyCentersBuilt()}, + {"peonsBuilt", academy->getPeonsBuilt()}, + {"structuresCaptured", academy->getStructuresCaptured()}, + {"generalsPointsSpent", academy->getGeneralsPointsSpent()}, + {"specialPowersUsed", academy->getSpecialPowersUsed()}, + {"structuresGarrisoned", academy->getStructuresGarrisoned()}, + {"upgradesPurchased", academy->getUpgradesPurchased()}, + {"gatherersBuilt", academy->getGatherersBuilt()}, + {"heroesBuilt", academy->getHeroesBuilt()}, + {"controlGroupsUsed", academy->getControlGroupsUsed()}, + {"secondaryIncomeUnitsBuilt", academy->getSecondaryIncomeUnitsBuilt()}, + {"clearedGarrisonedBuildings", academy->getClearedGarrisonedBuildings()}, + {"salvageCollected", academy->getSalvageCollected()}, + {"guardAbilityUsedCount", academy->getGuardAbilityUsedCount()}, + {"doubleClickAttackMoveOrdersGiven", academy->getDoubleClickAttackMoveOrdersGiven()}, + {"minesCleared", academy->getMinesCleared()}, + {"vehiclesDisguised", academy->getVehiclesDisguised()}, + {"firestormsCreated", academy->getFirestormsCreated()} + }; + + playersArr.push_back(p); + } + root["players"] = playersArr; + + root["buildEvents"] = buildBuildEventsJson(); + root["killEvents"] = buildKillEventsJson(); + root["captureEvents"] = buildCaptureEventsJson(); + buildStateChangeEventsJson(root); + root["timeSeries"] = buildTimeSeriesJson(); + + std::string jsonStr = root.dump(2); + + // Write gzip-compressed output to file + printf("[stats] Writing %u bytes JSON to %s\n", static_cast(jsonStr.size()), statsPath.str()); + fflush(stdout); + gzFile gz = gzopen(statsPath.str(), "wb9"); + if (gz != nullptr) + { + gzwrite(gz, jsonStr.data(), static_cast(jsonStr.size())); + gzclose(gz); + } + else + { + printf("[stats] ERROR: Failed to open %s for writing\n", statsPath.str()); + fflush(stdout); + } + + // Upload gzip file to server if URL configured + if (!TheGlobalData->m_statsUrl.isEmpty()) + { + FILE *f = fopen(statsPath.str(), "rb"); + if (f != nullptr) + { + fseek(f, 0, SEEK_END); + long fileSize = ftell(f); + fseek(f, 0, SEEK_SET); + if (fileSize > 0) + { + void *fileData = malloc(static_cast(fileSize)); + if (fileData != nullptr) + { + if (fread(fileData, 1, static_cast(fileSize), f) == static_cast(fileSize)) + { + printf("[stats] Uploading %ld bytes to %s\n", fileSize, TheGlobalData->m_statsUrl.str()); + fflush(stdout); + UploadStatsToServer(TheGlobalData->m_statsUrl, fileData, static_cast(fileSize), GetGameLogicRandomSeed()); + } + free(fileData); + } + } + fclose(f); + } + else + { + printf("[stats] ERROR: Failed to read %s for upload\n", statsPath.str()); + fflush(stdout); + } + } + + s_state.resetData(); + s_state.exportingActive = FALSE; +} diff --git a/GeneralsMD/Code/GameEngine/Source/Common/StatsUploader.cpp b/GeneralsMD/Code/GameEngine/Source/Common/StatsUploader.cpp new file mode 100644 index 00000000000..a609e62dad9 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/StatsUploader.cpp @@ -0,0 +1,104 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#include "PreRTS.h" + +#include "Common/StatsUploader.h" +#include "Common/AsciiString.h" + +#include +#include +#include + +#pragma comment(lib, "wininet.lib") + +void UploadStatsToServer(const AsciiString& url, const void *data, unsigned int dataLen, unsigned int seed) +{ + if (url.isEmpty() || data == nullptr || dataLen == 0) + return; + + // Parse URL components + char hostBuf[256]; + char pathBuf[1024]; + URL_COMPONENTSA uc; + memset(&uc, 0, sizeof(uc)); + uc.dwStructSize = sizeof(uc); + uc.lpszHostName = hostBuf; + uc.dwHostNameLength = sizeof(hostBuf); + uc.lpszUrlPath = pathBuf; + uc.dwUrlPathLength = sizeof(pathBuf); + + if (!InternetCrackUrlA(url.str(), 0, 0, &uc)) + { + printf("Stats upload: failed to parse URL \"%s\"\n", url.str()); + return; + } + + INTERNET_PORT port = uc.nPort; + if (port == 0) + port = (uc.nScheme == INTERNET_SCHEME_HTTPS) ? INTERNET_DEFAULT_HTTPS_PORT : INTERNET_DEFAULT_HTTP_PORT; + + DWORD flags = INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE; + if (uc.nScheme == INTERNET_SCHEME_HTTPS) + flags |= INTERNET_FLAG_SECURE; + + HINTERNET hInternet = InternetOpenA("GeneralsStatsExporter/1.0", INTERNET_OPEN_TYPE_PRECONFIG, nullptr, nullptr, 0); + if (hInternet == nullptr) + { + printf("Stats upload: InternetOpen failed (%lu)\n", GetLastError()); + return; + } + + HINTERNET hConnect = InternetConnectA(hInternet, hostBuf, port, nullptr, nullptr, INTERNET_SERVICE_HTTP, 0, 0); + if (hConnect == nullptr) + { + printf("Stats upload: InternetConnect failed (%lu)\n", GetLastError()); + InternetCloseHandle(hInternet); + return; + } + + HINTERNET hRequest = HttpOpenRequestA(hConnect, "POST", pathBuf, nullptr, nullptr, nullptr, flags, 0); + if (hRequest == nullptr) + { + printf("Stats upload: HttpOpenRequest failed (%lu)\n", GetLastError()); + InternetCloseHandle(hConnect); + InternetCloseHandle(hInternet); + return; + } + + // Build headers + char headers[512]; + sprintf(headers, "Content-Type: application/gzip\r\nX-Game-Seed: %u\r\n", seed); + + BOOL result = HttpSendRequestA(hRequest, headers, (DWORD)strlen(headers), const_cast(data), dataLen); + + if (result) + { + DWORD statusCode = 0; + DWORD statusSize = sizeof(statusCode); + HttpQueryInfoA(hRequest, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, &statusCode, &statusSize, nullptr); + printf("Stats upload: %s -> %lu\n", url.str(), statusCode); + } + else + { + printf("Stats upload: HttpSendRequest failed (%lu)\n", GetLastError()); + } + + InternetCloseHandle(hRequest); + InternetCloseHandle(hConnect); + InternetCloseHandle(hInternet); +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp index ab659354996..d1bffaeaffe 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp @@ -49,6 +49,7 @@ #include "Common/Xfer.h" #include "Common/XferCRC.h" #include "Common/PerfTimer.h" +#include "Common/StatsExporter.h" #include "GameClient/Anim2D.h" #include "GameClient/ControlBar.h" @@ -2989,6 +2990,12 @@ void Object::scoreTheKill( const Object *victim ) controller->getScoreKeeper()->addObjectDestroyed(victim); controller->addSkillPointsForKill(this, victim); controller->doBountyForKill(this, victim); + + if (TheGlobalData->m_exportStats) + { + const DamageInfo *damageInfo = victim->getBodyModule() ? victim->getBodyModule()->getLastDamageInfo() : nullptr; + StatsExporterRecordKill(this, victim, damageInfo); + } } // Now handle experience, if we can gain any @@ -4603,6 +4610,9 @@ void Object::onCapture( Player *oldOwner, Player *newOwner ) // this gets the new owner some points newOwner->getScoreKeeper()->addObjectCaptured(this); + if (TheGlobalData->m_exportStats) + StatsExporterRecordCapture(this, oldOwner, newOwner); + // rip through the behavior modules and call the onCapture for any modules that care for( BehaviorModule **module = m_behaviors; *module; ++module ) (*module)->onCapture( oldOwner, newOwner );