From 5d66ea2d3a26e8bfca1b744d587353dd2d32373b Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 14:28:21 +0200 Subject: [PATCH 01/13] bugfix(battleplan): Fixes battle plan bonuses clearing or double-applying when plans overlap - localApplyBattlePlanBonusesToObject treated a plan value of 0 as "remove" and cleared that plan's weapon bonus flag. With the retail-compatible delta path, activating one plan cleared the weapon bonuses of every other active plan (e.g. two Strategy Centers on different plans). Only a negative delta clears now. - BattlePlanBonusBehavior added effects that were already applied and removed effects that were never applied (Player::removeBattlePlanBonusesForObject uses -ALL_PLANS when an upgrade such as a science activates the module). This stacked or inverted armor, sight and speed scalars. Add/remove now check the applied state instead of asserting. - Adds debug log lines for battle plan count changes and per-object add/remove. Co-Authored-By: Claude Opus 5.5 --- .../GameEngine/Source/Common/RTS/Player.cpp | 12 ++++++-- .../Behavior/BattlePlanBonusBehavior.cpp | 29 +++++++++++++------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp index 7a07b8f0a59..3173255fd5c 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp @@ -3883,6 +3883,11 @@ void Player::changeBattlePlan( BattlePlanStatus plan, Int delta, const BattlePla break; } } + DEBUG_LOG(( "BattlePlan frame %d: player %d plan %s %+d -> count %d (%s)", + TheGameLogic->getFrame(), getPlayerIndex(), + plan == PLANSTATUS_BOMBARDMENT ? "BOMBARDMENT" : plan == PLANSTATUS_HOLDTHELINE ? "HOLDTHELINE" : plan == PLANSTATUS_SEARCHANDDESTROY ? "SEARCHANDDESTROY" : "NONE", + delta, getBattlePlansActiveSpecific( plan ), + addBonus ? "bonus APPLIED" : removeBonus ? "bonus REMOVED" : "stacked, no change" )); if( addBonus ) { applyBattlePlanBonusesForPlayerObjects( bonus ); @@ -4006,7 +4011,8 @@ static void localApplyBattlePlanBonusesToObject( Object *obj, void *userData ) { objectToModify->setWeaponBonusCondition( WEAPONBONUSCONDITION_BATTLEPLAN_BOMBARDMENT ); } - else + // Bonuses are deltas: a plan at 0 is unchanged and must not clear a flag another active plan source set. + else if( bonus->m_bombardment < 0 ) { objectToModify->clearWeaponBonusCondition( WEAPONBONUSCONDITION_BATTLEPLAN_BOMBARDMENT ); } @@ -4014,7 +4020,7 @@ static void localApplyBattlePlanBonusesToObject( Object *obj, void *userData ) { objectToModify->setWeaponBonusCondition( WEAPONBONUSCONDITION_BATTLEPLAN_HOLDTHELINE ); } - else + else if( bonus->m_holdTheLine < 0 ) { objectToModify->clearWeaponBonusCondition( WEAPONBONUSCONDITION_BATTLEPLAN_HOLDTHELINE ); } @@ -4022,7 +4028,7 @@ static void localApplyBattlePlanBonusesToObject( Object *obj, void *userData ) { objectToModify->setWeaponBonusCondition( WEAPONBONUSCONDITION_BATTLEPLAN_SEARCHANDDESTROY ); } - else + else if( bonus->m_searchAndDestroy < 0 ) { objectToModify->clearWeaponBonusCondition( WEAPONBONUSCONDITION_BATTLEPLAN_SEARCHANDDESTROY ); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp index ef18151a195..6b5b28bbaff 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp @@ -35,6 +35,7 @@ #include "Common/INI.h" #include "Common/Xfer.h" #include "Common/Player.h" +#include "Common/ThingTemplate.h" #include "GameLogic/GameLogic.h" #include "GameLogic/Module/BattlePlanBonusBehavior.h" #include "GameLogic/Module/BattlePlanUpdate.h" @@ -194,6 +195,8 @@ void BattlePlanBonusBehavior::upgradeImplementation(void) // Get other BattlePlanBonusBehavior and remove their active effects if they are conflicting Object* obj = getObject(); + DEBUG_LOG(("BattlePlanBonusBehavior(%s) object %d (%s): upgrade activated, reapplying battle plan bonuses", + KEYNAME(getModuleTagNameKey()).str(), obj->getID(), obj->getTemplate()->getName().str())); for (BehaviorModule** b = obj->getBehaviorModules(); *b; ++b) { @@ -266,7 +269,6 @@ void BattlePlanBonusBehavior::applyBonus(const BattlePlanBonusesData* bonus, boo /*else { DEBUG_LOG(("### BattlePlanBonusBehavior(%s)::applyBonus - Not Valid.", KEYNAME(getModuleTagNameKey()).str())); }*/ - DEBUG_LOG(("---\n")); } @@ -274,16 +276,20 @@ void BattlePlanBonusBehavior::applyBonus(const BattlePlanBonusesData* bonus, boo //------------------------------------------------------------------------------------------------- void BattlePlanBonusBehavior::addBonusForType(BattlePlanStatus plan) { - // DEBUG_LOG(("### BattlePlanBonusBehavior(%s)::addBonusForType %d", KEYNAME(getModuleTagNameKey()).str(), plan)); const BattlePlanBonusBehaviorModuleData* d = getBattlePlanBonusBehaviorModuleData(); Object* obj = getObject(); int idx = plan - 1; - //Sanity check: - DEBUG_ASSERTCRASH(!m_effectApplied[idx], ("BattlePlanBonusBehavior::addBonusForType -- Bonus is currently active!")); - if (idx > BATTLE_PLAN_COUNT) return; + // Applying twice would stack the scalars, so only add a plan that is not applied yet. + if (m_effectApplied[idx]) { + DEBUG_LOG(("BattlePlanBonusBehavior(%s) object %d (%s): add %s skipped, already applied", + KEYNAME(getModuleTagNameKey()).str(), obj->getID(), obj->getTemplate()->getName().str(), TheBattlePlanStatusNames[plan])); + return; + } + DEBUG_LOG(("BattlePlanBonusBehavior(%s) object %d (%s): add %s", + KEYNAME(getModuleTagNameKey()).str(), obj->getID(), obj->getTemplate()->getName().str(), TheBattlePlanStatusNames[plan])); if (d->m_weaponBonusEntries[idx] != -1) { obj->setWeaponBonusCondition(d->m_weaponBonusEntries[idx]); } @@ -327,16 +333,21 @@ void BattlePlanBonusBehavior::addBonusForType(BattlePlanStatus plan) //------------------------------------------------------------------------------------------------- void BattlePlanBonusBehavior::removeBonusForType(BattlePlanStatus plan) { - // DEBUG_LOG(("### BattlePlanBonusBehavior(%s)::removeBonusForType %d", KEYNAME(getModuleTagNameKey()).str(), plan)); - const BattlePlanBonusBehaviorModuleData* d = getBattlePlanBonusBehaviorModuleData(); Object* obj = getObject(); int idx = plan - 1; - DEBUG_ASSERTCRASH(m_effectApplied[idx], ("BattlePlanBonusBehavior::removeBonusForType -- Bonus is currently NOT active!")); - if (idx > BATTLE_PLAN_COUNT) return; + // Removal is also requested for plans that were never applied (e.g. -ALL_PLANS in + // Player::removeBattlePlanBonusesForObject); undoing those would invert scalars that were never set. + if (!m_effectApplied[idx]) { + DEBUG_LOG(("BattlePlanBonusBehavior(%s) object %d (%s): remove %s skipped, not applied", + KEYNAME(getModuleTagNameKey()).str(), obj->getID(), obj->getTemplate()->getName().str(), TheBattlePlanStatusNames[plan])); + return; + } + DEBUG_LOG(("BattlePlanBonusBehavior(%s) object %d (%s): remove %s", + KEYNAME(getModuleTagNameKey()).str(), obj->getID(), obj->getTemplate()->getName().str(), TheBattlePlanStatusNames[plan])); if (d->m_weaponBonusEntries[idx] != -1) { obj->clearWeaponBonusCondition(d->m_weaponBonusEntries[idx]); } From 6c73885bea2deba08b49fd0c8afae1bf0cca6794 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 14:28:21 +0200 Subject: [PATCH 02/13] feat(battleplan): Adds AllBattlePlansUpdate for a temporary all-battle-plans power AllBattlePlansUpdate grants its owner the army bonuses of Bombardment, Hold the Line and Search & Destroy while its object exists. It is an additional battle plan source next to the Strategy Centers (reference counted through Player::changeBattlePlan), so the plan a Strategy Center runs, plan switches and the building-only plan effects are untouched. Bonus values and member KindOfs are read from the BattlePlanUpdate of StrategyCenterTemplate so they match the permanent plans exactly. Intended use: an OCLSpecialPower spawns an invisible marker with this module and a DeletionUpdate for the duration. Adds the SPECIAL_ALL_BATTLE_PLANS special power enum (falls back to the no-target CIA Intelligence behavior). Co-Authored-By: Claude Opus 5.5 --- .../System/GameMemoryInitPools_GeneralsMD.inl | 1 + GeneralsMD/Code/GameEngine/CMakeLists.txt | 2 + .../Include/Common/SpecialPowerType.h | 2 + .../GameLogic/Module/AllBattlePlansUpdate.h | 78 ++++++++ .../Source/Common/RTS/ActionManager.cpp | 1 + .../Source/Common/RTS/SpecialPower.cpp | 2 + .../Source/Common/Thing/ModuleFactory.cpp | 2 + .../Object/Update/AllBattlePlansUpdate.cpp | 185 ++++++++++++++++++ 8 files changed, 273 insertions(+) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameLogic/Module/AllBattlePlansUpdate.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/AllBattlePlansUpdate.cpp diff --git a/Core/GameEngine/Source/Common/System/GameMemoryInitPools_GeneralsMD.inl b/Core/GameEngine/Source/Common/System/GameMemoryInitPools_GeneralsMD.inl index 683be7eb63b..79a39d82a9f 100644 --- a/Core/GameEngine/Source/Common/System/GameMemoryInitPools_GeneralsMD.inl +++ b/Core/GameEngine/Source/Common/System/GameMemoryInitPools_GeneralsMD.inl @@ -207,6 +207,7 @@ static PoolSizeRec PoolSizes[] = { "RadiusDecalBehavior", 32, 32 }, { "SpecialPowerDesignatorUpdate", 32, 32 }, { "BattlePlanUpdate", 32, 32 }, + { "AllBattlePlansUpdate", 32, 32 }, { "LifetimeUpdate", 32, 32 }, { "LocomotorSetUpgrade", 512, 128 }, { "LockWeaponCreate", 64, 128 }, diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 76b320ebcc6..d09b7a69853 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -263,6 +263,7 @@ set(GAMEENGINE_SRC Include/GameLogic/Module/BaikonurLaunchPower.h Include/GameLogic/Module/BaseRegenerateUpdate.h Include/GameLogic/Module/BattleBusSlowDeathBehavior.h + Include/GameLogic/Module/AllBattlePlansUpdate.h Include/GameLogic/Module/BattlePlanUpdate.h Include/GameLogic/Module/BehaviorModule.h Include/GameLogic/Module/BodyModule.h @@ -985,6 +986,7 @@ set(GAMEENGINE_SRC Source/GameLogic/Object/Update/AutoDepositUpdate.cpp Source/GameLogic/Object/Update/AutoFindHealingUpdate.cpp Source/GameLogic/Object/Update/BaseRenerateUpdate.cpp + Source/GameLogic/Object/Update/AllBattlePlansUpdate.cpp Source/GameLogic/Object/Update/BattlePlanUpdate.cpp Source/GameLogic/Object/Update/BoneFXUpdate.cpp Source/GameLogic/Object/Update/CheckpointUpdate.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h b/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h index 7eb503a7303..d92973fba11 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h @@ -225,6 +225,8 @@ enum SpecialPowerType CPP_11(: Int) SPECIAL_JUMPJET, + SPECIAL_ALL_BATTLE_PLANS, + SPECIALPOWER_COUNT, // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // NEW CONSTANTS NEED A BEHAVIORTYPE DEFINED IN THE SPECIALPOWER OR return one in getFallbackBehaviorType in ActionManager.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/Module/AllBattlePlansUpdate.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/Module/AllBattlePlansUpdate.h new file mode 100644 index 00000000000..13253b254ee --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/Module/AllBattlePlansUpdate.h @@ -0,0 +1,78 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// FILE: AllBattlePlansUpdate.h /////////////////////////////////////////////////////////////////// +// Desc: Grants the controlling player the army bonuses of all battle plans while this object +// exists. Acts as an additional battle plan source next to the strategy centers, so the +// plan a strategy center runs stays untouched. Bonus values are read from the +// BattlePlanUpdate of the given strategy center template. +/////////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +// INCLUDES /////////////////////////////////////////////////////////////////////////////////////// +#include "GameLogic/Module/UpdateModule.h" +#include "GameLogic/Module/BattlePlanUpdate.h" + +//------------------------------------------------------------------------------------------------- +class AllBattlePlansUpdateModuleData : public UpdateModuleData +{ +public: + AsciiString m_strategyCenterTemplateName; + + AllBattlePlansUpdateModuleData() {} + + static void buildFieldParse(MultiIniFieldParse& p) + { + UpdateModuleData::buildFieldParse(p); + static const FieldParse dataFieldParse[] = + { + { "StrategyCenterTemplate", INI::parseAsciiString, nullptr, offsetof( AllBattlePlansUpdateModuleData, m_strategyCenterTemplateName ) }, + { nullptr, nullptr, nullptr, 0 } + }; + p.add(dataFieldParse); + } +}; + +//------------------------------------------------------------------------------------------------- +//------------------------------------------------------------------------------------------------- +class AllBattlePlansUpdate : public UpdateModule +{ + + MEMORY_POOL_GLUE_WITH_USERLOOKUP_CREATE( AllBattlePlansUpdate, "AllBattlePlansUpdate" ) + MAKE_STANDARD_MODULE_MACRO_WITH_MODULE_DATA( AllBattlePlansUpdate, AllBattlePlansUpdateModuleData ) + +public: + + AllBattlePlansUpdate( Thing *thing, const ModuleData* moduleData ); + // virtual destructor prototype provided by memory pool declaration + + virtual UpdateSleepTime update() override; + virtual void onDelete() override; + virtual void onCapture( Player *oldOwner, Player *newOwner ) override; + +private: + + void changeAllPlans( Player *player, Int delta ); + + enum { PLAN_SOURCE_COUNT = PLANSTATUS_COUNT - 1 }; + + BattlePlanBonusesData m_bonuses[PLAN_SOURCE_COUNT]; ///< indexed by plan - 1 + Bool m_valid; ///< bonus values were found on the strategy center template + Bool m_applied; ///< plans are currently counted at the controlling player +}; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/ActionManager.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/ActionManager.cpp index 2ce7920a130..757a443650b 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/ActionManager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/ActionManager.cpp @@ -2059,6 +2059,7 @@ SpecialPowerType ActionManager::getFallbackBehaviorType(SpecialPowerType type) { return SPECIAL_PARADROP_AMERICA; case SECW_SPECIAL_HUNTER_SEEKER: + case SPECIAL_ALL_BATTLE_PLANS: return SPECIAL_CIA_INTELLIGENCE; case CHINA_SPECIAL_SPY_SATELLITE: diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp index ccfb0d12e97..0087b166e7d 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp @@ -224,6 +224,8 @@ const char* const SpecialPowerMaskType::s_bitNameList[] = "SPECIAL_JUMPJET", + "SPECIAL_ALL_BATTLE_PLANS", + nullptr }; static_assert(ARRAY_SIZE(SpecialPowerMaskType::s_bitNameList) == SpecialPowerMaskType::NumBits + 1, "Incorrect array size"); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Thing/ModuleFactory.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Thing/ModuleFactory.cpp index aa18c63d5fd..748827d53ed 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Thing/ModuleFactory.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Thing/ModuleFactory.cpp @@ -159,6 +159,7 @@ #include "GameLogic/Module/KodiakDeploymentUpdate.h" #include "GameLogic/Module/BaikonurLaunchPower.h" #include "GameLogic/Module/BattlePlanUpdate.h" +#include "GameLogic/Module/AllBattlePlansUpdate.h" #include "GameLogic/Module/LifetimeUpdate.h" #include "GameLogic/Module/RadiusDecalUpdate.h" #include "GameLogic/Module/RadiusDecalBehavior.h" @@ -497,6 +498,7 @@ void ModuleFactory::init() addModule( KodiakDeploymentUpdate ); addModule( BaikonurLaunchPower ); addModule( BattlePlanUpdate ); + addModule( AllBattlePlansUpdate ); addModule( ProjectileStreamUpdate ); addModule( QueueProductionExitUpdate ); addModule( RepairDockUpdate ); diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/AllBattlePlansUpdate.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/AllBattlePlansUpdate.cpp new file mode 100644 index 00000000000..793c588985c --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/AllBattlePlansUpdate.cpp @@ -0,0 +1,185 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// FILE: AllBattlePlansUpdate.cpp ///////////////////////////////////////////////////////////////// +// Desc: Grants the controlling player the army bonuses of all battle plans while this object +// exists. +/////////////////////////////////////////////////////////////////////////////////////////////////// + +// INCLUDES /////////////////////////////////////////////////////////////////////////////////////// +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#include "Common/Player.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "Common/Xfer.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Module/AllBattlePlansUpdate.h" +#include "GameLogic/Object.h" + + +//------------------------------------------------------------------------------------------------- +//------------------------------------------------------------------------------------------------- +AllBattlePlansUpdate::AllBattlePlansUpdate( Thing *thing, const ModuleData* moduleData ) : UpdateModule( thing, moduleData ) +{ + m_valid = FALSE; + m_applied = FALSE; + + // Take the values from the strategy center so the temporary plans match the permanent ones exactly. + // Removing a plan inverts the scalars of whichever source drops the count to 0, so they must be equal. + const AllBattlePlansUpdateModuleData *data = getAllBattlePlansUpdateModuleData(); + const ThingTemplate *centerTemplate = TheThingFactory->findTemplate( data->m_strategyCenterTemplateName ); + const BattlePlanUpdateModuleData *planData = nullptr; + if( centerTemplate ) + { + const ModuleInfo& mi = centerTemplate->getBehaviorModuleInfo(); + for( Int i = 0; i < mi.getCount(); ++i ) + { + if( mi.getNthName( i ).compareNoCase( "BattlePlanUpdate" ) == 0 ) + { + planData = (const BattlePlanUpdateModuleData *)mi.getNthData( i ); + break; + } + } + } + + if( !planData ) + { + DEBUG_CRASH( ("%s object's AllBattlePlansUpdate needs a StrategyCenterTemplate with a BattlePlanUpdate (got '%s').", + getObject()->getTemplate()->getName().str(), data->m_strategyCenterTemplateName.str()) ); + return; + } + + for( Int i = 0; i < PLAN_SOURCE_COUNT; ++i ) + { + m_bonuses[i].m_validKindOf = planData->m_validMemberKindOf; + m_bonuses[i].m_invalidKindOf = planData->m_invalidMemberKindOf; + } + m_bonuses[PLANSTATUS_BOMBARDMENT - 1].m_bombardment = 1; + m_bonuses[PLANSTATUS_HOLDTHELINE - 1].m_holdTheLine = 1; + m_bonuses[PLANSTATUS_HOLDTHELINE - 1].m_armorScalar = planData->m_holdTheLineArmorDamageScalar; + m_bonuses[PLANSTATUS_SEARCHANDDESTROY - 1].m_searchAndDestroy = 1; + m_bonuses[PLANSTATUS_SEARCHANDDESTROY - 1].m_sightRangeScalar = planData->m_searchAndDestroySightRangeScalar; + m_valid = TRUE; +} + +//------------------------------------------------------------------------------------------------- +//------------------------------------------------------------------------------------------------- +AllBattlePlansUpdate::~AllBattlePlansUpdate() +{ +} + +//------------------------------------------------------------------------------------------------- +void AllBattlePlansUpdate::changeAllPlans( Player *player, Int delta ) +{ + DEBUG_LOG(( "AllBattlePlansUpdate frame %d: %s all battle plans for player %d (object %d)", + TheGameLogic->getFrame(), delta > 0 ? "granting" : "removing", player->getPlayerIndex(), getObject()->getID() )); + + player->changeBattlePlan( PLANSTATUS_BOMBARDMENT, delta, &m_bonuses[PLANSTATUS_BOMBARDMENT - 1] ); + player->changeBattlePlan( PLANSTATUS_HOLDTHELINE, delta, &m_bonuses[PLANSTATUS_HOLDTHELINE - 1] ); + player->changeBattlePlan( PLANSTATUS_SEARCHANDDESTROY, delta, &m_bonuses[PLANSTATUS_SEARCHANDDESTROY - 1] ); +} + +//------------------------------------------------------------------------------------------------- +// Applied on the first update rather than on creation: a loaded save constructs the object again, +// but restores m_applied before any update runs, so the plans are not counted twice. +//------------------------------------------------------------------------------------------------- +UpdateSleepTime AllBattlePlansUpdate::update() +{ + if( m_valid && !m_applied ) + { + Player *player = getObject()->getControllingPlayer(); + if( player ) + { + changeAllPlans( player, 1 ); + m_applied = TRUE; + } + } + return UPDATE_SLEEP_FOREVER; +} + +//------------------------------------------------------------------------------------------------- +void AllBattlePlansUpdate::onDelete() +{ + UpdateModule::onDelete(); + + // Player can be null during game reset cycles, where removing the bonuses no longer matters. + Player *player = getObject()->getControllingPlayer(); + if( player && m_applied ) + { + changeAllPlans( player, -1 ); + } + m_applied = FALSE; +} + +//------------------------------------------------------------------------------------------------- +void AllBattlePlansUpdate::onCapture( Player *oldOwner, Player *newOwner ) +{ + if( m_applied ) + { + if( oldOwner ) + changeAllPlans( oldOwner, -1 ); + if( newOwner ) + changeAllPlans( newOwner, 1 ); + else + m_applied = FALSE; + } +} + +// ------------------------------------------------------------------------------------------------ +/** CRC */ +// ------------------------------------------------------------------------------------------------ +void AllBattlePlansUpdate::crc( Xfer *xfer ) +{ + + // extend base class + UpdateModule::crc( xfer ); + +} + +// ------------------------------------------------------------------------------------------------ +/** Xfer method + * Version Info: + * 1: Initial version */ +// ------------------------------------------------------------------------------------------------ +void AllBattlePlansUpdate::xfer( Xfer *xfer ) +{ + + // version + XferVersion currentVersion = 1; + XferVersion version = currentVersion; + xfer->xferVersion( &version, currentVersion ); + + // extend base class + UpdateModule::xfer( xfer ); + + // applied + xfer->xferBool( &m_applied ); + +} + +// ------------------------------------------------------------------------------------------------ +/** Load post process */ +// ------------------------------------------------------------------------------------------------ +void AllBattlePlansUpdate::loadPostProcess() +{ + + // extend base class + UpdateModule::loadPostProcess(); + +} From 22eb5730e52ffd1a963354a3cf0e4c1ec0e960f9 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 15:01:17 +0200 Subject: [PATCH 03/13] refactor(mcp): Moves McpJson into GameEngine for reuse by the game The game gets its own MCP bridge next; both bridges share the JSON value. Co-Authored-By: Claude Opus 5.5 --- GeneralsMD/Code/GameEngine/CMakeLists.txt | 2 ++ .../include/mcp => GameEngine/Include/Common}/McpJson.h | 2 +- .../src/mcp => GameEngine/Source/Common}/McpJson.cpp | 6 +++--- GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt | 2 -- .../Code/Tools/WorldBuilder/include/mcp/McpCommands.h | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) rename GeneralsMD/Code/{Tools/WorldBuilder/include/mcp => GameEngine/Include/Common}/McpJson.h (97%) rename GeneralsMD/Code/{Tools/WorldBuilder/src/mcp => GameEngine/Source/Common}/McpJson.cpp (98%) diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index d09b7a69853..51a4f421c78 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -66,6 +66,7 @@ set(GAMEENGINE_SRC # Include/Common/LocalFileSystem.h # Include/Common/MapObject.h Include/Common/MapReaderWriterInfo.h + Include/Common/McpJson.h # Include/Common/MessageStream.h # Include/Common/MiniLog.h # Include/Common/MiscAudio.h @@ -572,6 +573,7 @@ set(GAMEENGINE_SRC Source/Common/GameLOD.cpp Source/Common/GameMain.cpp Source/Common/GlobalData.cpp + Source/Common/McpJson.cpp # Source/Common/INI/INI.cpp # Source/Common/INI/INIAiData.cpp # Source/Common/INI/INIAnimation.cpp diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpJson.h b/GeneralsMD/Code/GameEngine/Include/Common/McpJson.h similarity index 97% rename from GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpJson.h rename to GeneralsMD/Code/GameEngine/Include/Common/McpJson.h index f6cebd46daf..a3e4ea7d2f8 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpJson.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/McpJson.h @@ -17,7 +17,7 @@ */ // McpJson.h -// Minimal JSON value used by the WorldBuilder MCP command bridge. +// Minimal JSON value used by the MCP command bridges of the game and WorldBuilder. #pragma once diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpJson.cpp b/GeneralsMD/Code/GameEngine/Source/Common/McpJson.cpp similarity index 98% rename from GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpJson.cpp rename to GeneralsMD/Code/GameEngine/Source/Common/McpJson.cpp index 3a623fe47b1..f6a0f99677d 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpJson.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/McpJson.cpp @@ -17,10 +17,10 @@ */ // McpJson.cpp -// Minimal JSON value used by the WorldBuilder MCP command bridge. +// Minimal JSON value used by the MCP command bridges of the game and WorldBuilder. -#include "StdAfx.h" -#include "mcp/McpJson.h" +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine +#include "Common/McpJson.h" #include #include diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index d50a52c9a9c..43d5ebcc4ed 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -196,7 +196,6 @@ set(WORLDBUILDER_SRC "include/WorldBuilderDoc.h" "include/WorldBuilderView.h" "include/mcp/McpCommands.h" - "include/mcp/McpJson.h" "include/mcp/McpNoise.h" "include/mcp/McpScriptsShared.h" "include/mcp/McpShape.h" @@ -221,7 +220,6 @@ set(WORLDBUILDER_SRC "src/mcp/McpCmdTerrain.cpp" "src/mcp/McpCmdWaypoints.cpp" "src/mcp/McpCommands.cpp" - "src/mcp/McpJson.cpp" "src/mcp/McpNoise.cpp" "src/mcp/McpScreenshot.cpp" "src/mcp/McpServer.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index d8e8870b3e6..47ecb8974c1 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -22,7 +22,7 @@ #pragma once -#include "mcp/McpJson.h" +#include "Common/McpJson.h" #include "Common/AsciiString.h" #include From 06ebd2c36fa00857144d144cd2a529c509c9f1c8 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 15:01:18 +0200 Subject: [PATCH 04/13] refactor(aimatch): Extracts skirmish setup into SkirmishLaunch Lets other command line launchers start a skirmish with a human local player. Co-Authored-By: Claude Opus 5.5 --- GeneralsMD/Code/GameEngine/CMakeLists.txt | 2 + .../Include/GameLogic/SkirmishLaunch.h | 53 ++++++ .../Source/GameLogic/AI/AIMatch.cpp | 126 +++---------- .../GameLogic/System/SkirmishLaunch.cpp | 168 ++++++++++++++++++ 4 files changed, 252 insertions(+), 97 deletions(-) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameLogic/SkirmishLaunch.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameLogic/System/SkirmishLaunch.cpp diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 51a4f421c78..1f31fb6fbdf 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -489,6 +489,7 @@ set(GAMEENGINE_SRC Include/GameLogic/ScriptEngine.h Include/GameLogic/Scripts.h Include/GameLogic/SidesList.h + Include/GameLogic/SkirmishLaunch.h Include/GameLogic/Squad.h Include/GameLogic/TerrainLogic.h Include/GameLogic/TurretAI.h @@ -1088,6 +1089,7 @@ set(GAMEENGINE_SRC Source/GameLogic/System/GameLogic.cpp # Source/GameLogic/System/GameLogicDispatch.cpp # Source/GameLogic/System/RankInfo.cpp + Source/GameLogic/System/SkirmishLaunch.cpp # Source/GameNetwork/Connection.cpp # Source/GameNetwork/ConnectionManager.cpp # Source/GameNetwork/DisconnectManager.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/SkirmishLaunch.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/SkirmishLaunch.h new file mode 100644 index 00000000000..8b04857c722 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/SkirmishLaunch.h @@ -0,0 +1,53 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// SkirmishLaunch.h +// Starts a skirmish without the skirmish menu, for games that are set up on the command line +// (AI matches, the MCP bridge). + +#pragma once + +#include "Common/AsciiString.h" + +#include + +struct SkirmishSlot +{ + AsciiString faction; ///< player template name or side; empty or "Observer" for an observer + Int slotState; ///< SLOT_PLAYER for the local player, SLOT_EASY_AI .. SLOT_BRUTAL_AI for AI players + Int team; ///< -1 for none +}; + +namespace SkirmishLaunch +{ + /// The map cache key for a map key or part of a multiplayer map's path; empty if none matches. + AsciiString findMap(const AsciiString &name); + + /// Number of start positions of a map from the cache, or -1 when unknown. + Int countStartPositions(const AsciiString &mapKey); + + /// SlotState of an AI difficulty name (easy, normal, hard), or -1. + Int slotStateForDifficulty(const AsciiString &difficulty); + + /// Fills the skirmish game info: slot 0 is the local player, the others are AI players. + /// Returns false with a reason when a faction is unknown. + Bool prepare(const AsciiString &mapKey, const std::vector &slots, Int seed, Int startingCash, AsciiString &error); + + /// Starts the prepared game. maxFps is the frame rate limit of the game (1000 for none). + void launch(const AsciiString &mapKey, Int seed, Int maxFps); +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIMatch.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIMatch.cpp index a842ba66530..bc2991122d7 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIMatch.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIMatch.cpp @@ -45,6 +45,7 @@ #include "GameLogic/GameLogic.h" #include "GameLogic/Object.h" #include "GameLogic/ScriptEngine.h" +#include "GameLogic/SkirmishLaunch.h" #include "GameNetwork/GameInfo.h" #include @@ -94,14 +95,6 @@ void writeCounts(FILE *file, const std::map &counts) fputc('}', file); } -Int slotStateFor(const AsciiString &difficulty) -{ - if (difficulty.compareNoCase("easy") == 0) return SLOT_EASY_AI; - if (difficulty.compareNoCase("normal") == 0 || difficulty.compareNoCase("medium") == 0) return SLOT_MED_AI; - if (difficulty.compareNoCase("hard") == 0 || difficulty.compareNoCase("brutal") == 0) return SLOT_BRUTAL_AI; - return -1; -} - const char *difficultyName(GameDifficulty difficulty) { switch (difficulty) { @@ -198,7 +191,7 @@ AIMatch *AIMatch::createFromCommandLine() entrant.faction = part; } if (entry.nextToken(&part, ":")) { - entrant.difficulty = slotStateFor(part); + entrant.difficulty = SkirmishLaunch::slotStateForDifficulty(part); } if (entry.nextToken(&part, ":")) { entrant.team = atoi(part.str()); @@ -234,26 +227,13 @@ void AIMatch::start() return; } - // The map: its key in the map cache, or the first multiplayer map whose path holds the name. - AsciiString mapKey = m_map; - mapKey.toLower(); - if (TheMapCache->find(mapKey) == TheMapCache->end()) { - AsciiString found; - for (MapCache::const_iterator it = TheMapCache->begin(); it != TheMapCache->end(); ++it) { - if (it->second.m_isMultiplayer && strstr(it->first.str(), mapKey.str()) != nullptr) { - found = it->first; - break; - } - } - if (found.isEmpty()) { - AsciiString reason; - reason.format("no multiplayer map matches '%s'", m_map.str()); - fail(reason.str()); - return; - } - mapKey = found; + const AsciiString mapKey = SkirmishLaunch::findMap(m_map); + if (mapKey.isEmpty()) { + AsciiString reason; + reason.format("no multiplayer map matches '%s'", m_map.str()); + fail(reason.str()); + return; } - const MapMetaData *mapData = TheMapCache->findMap(mapKey); // Files that stand in for the game's own, to try changes without installing them. if (m_scriptFile.isNotEmpty() && !TheFileSystem->doesFileExist(m_scriptFile.str())) { @@ -278,66 +258,35 @@ void AIMatch::start() fail("-aiPlayers needs at least two players (Faction:difficulty,Faction:difficulty)"); return; } - if (mapData && (Int)m_entrants.size() > mapData->m_numPlayers) { + const Int startPositions = SkirmishLaunch::countStartPositions(mapKey); + if (startPositions >= 0 && (Int)m_entrants.size() > startPositions) { AsciiString reason; - reason.format("the map has %d start positions for %d players", mapData->m_numPlayers, (Int)m_entrants.size()); + reason.format("the map has %d start positions for %d players", startPositions, (Int)m_entrants.size()); fail(reason.str()); return; } - if (TheSkirmishGameInfo == nullptr) { - TheSkirmishGameInfo = NEW SkirmishGameInfo; - } - TheSkirmishGameInfo->init(); - TheSkirmishGameInfo->clearSlotList(); - TheSkirmishGameInfo->reset(); - TheSkirmishGameInfo->setLocalIP(TheSkirmishGameInfo->getSlot(0)->getIP()); - TheSkirmishGameInfo->enterGame(); - // The local player only watches. - UnicodeString observerName; - observerName.translate("Observer"); - GameSlot observer; - observer.setState(SLOT_PLAYER, observerName); - observer.setPlayerTemplate(PLAYERTEMPLATE_OBSERVER); - TheSkirmishGameInfo->setSlot(0, observer); - + std::vector slots; + SkirmishSlot observer; + observer.slotState = SLOT_PLAYER; + observer.team = -1; + slots.push_back(observer); for (size_t i = 0; i < m_entrants.size(); i++) { - const Entrant &entrant = m_entrants[i]; - Int templateIndex = -1; - for (Int t = 0; t < ThePlayerTemplateStore->getPlayerTemplateCount(); t++) { - const PlayerTemplate *playerTemplate = ThePlayerTemplateStore->getNthPlayerTemplate(t); - if (playerTemplate && (playerTemplate->getName().compareNoCase(entrant.faction.str()) == 0 - || playerTemplate->getSide().compareNoCase(entrant.faction.str()) == 0)) { - templateIndex = t; - break; - } - } - if (templateIndex < 0 || entrant.difficulty < 0) { - AsciiString reason; - reason.format("-aiPlayers entry %d: unknown faction '%s' or difficulty (easy, normal, hard)", (Int)i + 1, entrant.faction.str()); - fail(reason.str()); - return; - } - GameSlot slot; - slot.setState((SlotState)entrant.difficulty); - slot.setPlayerTemplate(templateIndex); - slot.setTeamNumber(entrant.team); - TheSkirmishGameInfo->setSlot((Int)i + 1, slot); + SkirmishSlot slot; + slot.faction = m_entrants[i].faction; + slot.slotState = m_entrants[i].difficulty; + slot.team = m_entrants[i].team; + slots.push_back(slot); } const Int seed = TheGlobalData->m_fixedSeed >= 0 ? TheGlobalData->m_fixedSeed : (Int)GetTickCount(); - TheSkirmishGameInfo->setSeed(seed); - TheSkirmishGameInfo->setMap(mapKey); - TheSkirmishGameInfo->setMapCRC(mapData ? mapData->m_CRC : 0); - TheSkirmishGameInfo->setMapSize(mapData ? mapData->m_filesize : 0); - if (m_startingCash > 0) { - Money cash; - cash.init(); - cash.deposit(m_startingCash, FALSE); - TheSkirmishGameInfo->setStartingCash(cash); - } else { - TheSkirmishGameInfo->setStartingCash(TheGlobalData->m_defaultStartingCash); + AsciiString error; + if (!SkirmishLaunch::prepare(mapKey, slots, seed, m_startingCash, error)) { + AsciiString reason; + reason.format("-aiPlayers %s", error.str()); + fail(reason.str()); + return; } fprintf(m_log, "{\"t\":\"setup\",\"map\":%s,\"seed\":%d,\"max_frames\":%u,\"snapshot_frames\":%u,\"frames_per_second\":%d," @@ -346,25 +295,8 @@ void AIMatch::start() quoted(m_scriptFile).c_str(), quoted(m_aiDataFile).c_str()); fflush(m_log); - // From here on as the skirmish menu starts a game. - TheWritableGlobalData->m_mapName = mapKey; - TheWritableGlobalData->m_shellMapOn = FALSE; - TheSkirmishGameInfo->startGame(0); - InitRandom(seed); - - // Without the client nobody carries messages from the stream to the logic, so the message - // goes to the logic's command list directly, as it does when a replay is simulated. - GameMessage *msg; - if (TheGlobalData->m_headless) { - msg = newInstance(GameMessage)(GameMessage::MSG_NEW_GAME); - TheCommandList->appendMessage(msg); - } else { - msg = TheMessageStream->appendMessage(GameMessage::MSG_NEW_GAME); - } - msg->appendIntegerArgument(GAME_SKIRMISH); - msg->appendIntegerArgument(DIFFICULTY_NORMAL); - msg->appendIntegerArgument(0); - msg->appendIntegerArgument(1000); // no frame rate limit: the match runs as fast as the machine allows + // No frame rate limit: the match runs as fast as the machine allows. + SkirmishLaunch::launch(mapKey, seed, 1000); } //------------------------------------------------------------------------------------------------- diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/SkirmishLaunch.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/SkirmishLaunch.cpp new file mode 100644 index 00000000000..3ed07087c51 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/SkirmishLaunch.cpp @@ -0,0 +1,168 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// SkirmishLaunch.cpp +// Starts a skirmish without the skirmish menu. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#include "GameLogic/SkirmishLaunch.h" + +#include "Common/GlobalData.h" +#include "Common/MessageStream.h" +#include "Common/Money.h" +#include "Common/PlayerTemplate.h" +#include "Common/RandomValue.h" +#include "GameClient/MapUtil.h" +#include "GameLogic/GameLogic.h" +#include "GameNetwork/GameInfo.h" + +//------------------------------------------------------------------------------------------------- +AsciiString SkirmishLaunch::findMap(const AsciiString &name) +{ + // The map: its key in the map cache, or the first multiplayer map whose path holds the name. + AsciiString mapKey = name; + mapKey.toLower(); + if (TheMapCache->find(mapKey) != TheMapCache->end()) { + return mapKey; + } + for (MapCache::const_iterator it = TheMapCache->begin(); it != TheMapCache->end(); ++it) { + if (it->second.m_isMultiplayer && strstr(it->first.str(), mapKey.str()) != nullptr) { + return it->first; + } + } + return AsciiString::TheEmptyString; +} + +//------------------------------------------------------------------------------------------------- +Int SkirmishLaunch::countStartPositions(const AsciiString &mapKey) +{ + const MapMetaData *mapData = TheMapCache->findMap(mapKey); + return mapData ? mapData->m_numPlayers : -1; +} + +//------------------------------------------------------------------------------------------------- +Int SkirmishLaunch::slotStateForDifficulty(const AsciiString &difficulty) +{ + if (difficulty.compareNoCase("easy") == 0) return SLOT_EASY_AI; + if (difficulty.compareNoCase("normal") == 0 || difficulty.compareNoCase("medium") == 0) return SLOT_MED_AI; + if (difficulty.compareNoCase("hard") == 0 || difficulty.compareNoCase("brutal") == 0) return SLOT_BRUTAL_AI; + return -1; +} + +//------------------------------------------------------------------------------------------------- +static Int findPlayerTemplate(const AsciiString &faction) +{ + for (Int t = 0; t < ThePlayerTemplateStore->getPlayerTemplateCount(); t++) { + const PlayerTemplate *playerTemplate = ThePlayerTemplateStore->getNthPlayerTemplate(t); + if (playerTemplate && (playerTemplate->getName().compareNoCase(faction.str()) == 0 + || playerTemplate->getSide().compareNoCase(faction.str()) == 0)) { + return t; + } + } + return -1; +} + +//------------------------------------------------------------------------------------------------- +static Bool isObserverFaction(const AsciiString &faction) +{ + return faction.isEmpty() || faction.compareNoCase("Observer") == 0; +} + +//------------------------------------------------------------------------------------------------- +Bool SkirmishLaunch::prepare(const AsciiString &mapKey, const std::vector &slots, Int seed, Int startingCash, AsciiString &error) +{ + const MapMetaData *mapData = TheMapCache->findMap(mapKey); + + if (TheSkirmishGameInfo == nullptr) { + TheSkirmishGameInfo = NEW SkirmishGameInfo; + } + TheSkirmishGameInfo->init(); + TheSkirmishGameInfo->clearSlotList(); + TheSkirmishGameInfo->reset(); + TheSkirmishGameInfo->setLocalIP(TheSkirmishGameInfo->getSlot(0)->getIP()); + TheSkirmishGameInfo->enterGame(); + + Int entry = 0; + for (size_t i = 0; i < slots.size(); i++) { + const SkirmishSlot &wanted = slots[i]; + GameSlot slot; + if (i == 0) { + // The local player plays a faction or only watches. + UnicodeString name; + if (isObserverFaction(wanted.faction)) { + name.translate("Observer"); + slot.setState(SLOT_PLAYER, name); + slot.setPlayerTemplate(PLAYERTEMPLATE_OBSERVER); + TheSkirmishGameInfo->setSlot(0, slot); + continue; + } + name.translate("Player"); + slot.setState(SLOT_PLAYER, name); + } else { + slot.setState((SlotState)wanted.slotState); + } + entry++; + const Int templateIndex = findPlayerTemplate(wanted.faction); + if (templateIndex < 0 || wanted.slotState < 0) { + error.format("entry %d: unknown faction '%s' or difficulty (easy, normal, hard)", entry, wanted.faction.str()); + return FALSE; + } + slot.setPlayerTemplate(templateIndex); + slot.setTeamNumber(wanted.team); + TheSkirmishGameInfo->setSlot((Int)i, slot); + } + + TheSkirmishGameInfo->setSeed(seed); + TheSkirmishGameInfo->setMap(mapKey); + TheSkirmishGameInfo->setMapCRC(mapData ? mapData->m_CRC : 0); + TheSkirmishGameInfo->setMapSize(mapData ? mapData->m_filesize : 0); + if (startingCash > 0) { + Money cash; + cash.init(); + cash.deposit(startingCash, FALSE); + TheSkirmishGameInfo->setStartingCash(cash); + } else { + TheSkirmishGameInfo->setStartingCash(TheGlobalData->m_defaultStartingCash); + } + return TRUE; +} + +//------------------------------------------------------------------------------------------------- +void SkirmishLaunch::launch(const AsciiString &mapKey, Int seed, Int maxFps) +{ + // From here on as the skirmish menu starts a game. + TheWritableGlobalData->m_mapName = mapKey; + TheWritableGlobalData->m_shellMapOn = FALSE; + TheSkirmishGameInfo->startGame(0); + InitRandom(seed); + + // Without the client nobody carries messages from the stream to the logic, so the message + // goes to the logic's command list directly, as it does when a replay is simulated. + GameMessage *msg; + if (TheGlobalData->m_headless) { + msg = newInstance(GameMessage)(GameMessage::MSG_NEW_GAME); + TheCommandList->appendMessage(msg); + } else { + msg = TheMessageStream->appendMessage(GameMessage::MSG_NEW_GAME); + } + msg->appendIntegerArgument(GAME_SKIRMISH); + msg->appendIntegerArgument(DIFFICULTY_NORMAL); + msg->appendIntegerArgument(0); + msg->appendIntegerArgument(maxFps); +} From 6e8ecc8a3b6c487db9508ba5d90a66f66e268864 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 15:20:35 +0200 Subject: [PATCH 05/13] refactor(screenshot): Splits back buffer capture and image writing into helpers Prepares writing a screenshot to a chosen file without the screenshot thread. Co-Authored-By: Claude Opus 5.5 --- .../W3DDevice/GameClient/W3DScreenshot.cpp | 80 ++++++++++++------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp index 0d0d851eb63..7d6b518d88a 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp @@ -59,18 +59,9 @@ struct ScreenshotWrittenMessage }; static MPSCIntrusiveQueue s_screenshotWrittenQueue; -static DWORD WINAPI screenshotThreadFunc(LPVOID param) +// Converts the captured pixels and writes them to a file. Returns non-zero on success. +static int writeScreenshotImage(const ScreenshotThreadData* data, const char* pathname) { - ScreenshotThreadData* data = static_cast(param); - - // TheSuperHackers @feature bobtista 08/07/2026 Save screenshots into a Screenshots subfolder - // to keep the user data root folder tidy. - char pathname[_MAX_PATH]; - strlcpy(pathname, data->userDataDirectory, ARRAY_SIZE(pathname)); - strlcat(pathname, "Screenshots\\", ARRAY_SIZE(pathname)); - CreateDirectory(pathname, nullptr); - strlcat(pathname, data->leafname, ARRAY_SIZE(pathname)); - const unsigned int width = data->width; const unsigned int height = data->height; @@ -121,6 +112,25 @@ static DWORD WINAPI screenshotThreadFunc(LPVOID param) break; } + delete[] image; + + return success; +} + +static DWORD WINAPI screenshotThreadFunc(LPVOID param) +{ + ScreenshotThreadData* data = static_cast(param); + + // TheSuperHackers @feature bobtista 08/07/2026 Save screenshots into a Screenshots subfolder + // to keep the user data root folder tidy. + char pathname[_MAX_PATH]; + strlcpy(pathname, data->userDataDirectory, ARRAY_SIZE(pathname)); + strlcat(pathname, "Screenshots\\", ARRAY_SIZE(pathname)); + CreateDirectory(pathname, nullptr); + strlcat(pathname, data->leafname, ARRAY_SIZE(pathname)); + + const int success = writeScreenshotImage(data, pathname); + if (success) { ScreenshotWrittenMessage* message = new ScreenshotWrittenMessage; @@ -132,7 +142,6 @@ static DWORD WINAPI screenshotThreadFunc(LPVOID param) DEBUG_LOG(("Failed to write screenshot %s", pathname)); } - delete[] image; delete data; return success; @@ -152,20 +161,9 @@ void W3D_UpdateScreenshotMessages() } } -void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) +// Copies the back buffer. Returns null if its format is not supported. +static ScreenshotThreadData* captureBackBuffer(ScreenshotFormat format, Int jpegQuality) { - static constexpr const char* const ScreenshotFormatExtensions[] = { "jpg", "png" }; - static_assert(ARRAY_SIZE(ScreenshotFormatExtensions) == SCREENSHOT_FORMAT_COUNT, "Incorrect array size"); - - // The filename is created here so the timestamp matches the capture time. - char leafname[_MAX_FNAME]; - const char* extension = ScreenshotFormatExtensions[format]; - - SYSTEMTIME st; - GetLocalTime(&st); - sprintf(leafname, "sshot_%04d%02d%02d_%02d%02d%02d_%03d.%s", - st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, extension); - // TheSuperHackers @bugfix xezon 21/05/2025 Get the back buffer and create a copy of the surface. // Originally this code took the front buffer and tried to lock it. This does not work when the // render view clips outside the desktop boundaries. It crashed the game. @@ -182,7 +180,7 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) { DEBUG_LOG(("Screenshot does not support back buffer format %d", (int)surfaceDesc.Format)); surface->Release_Ref(); - return; + return nullptr; } SurfaceClass* surfaceCopy = NEW_REF(SurfaceClass, (DX8Wrapper::_Create_DX8_Surface(surfaceDesc.Width, surfaceDesc.Height, surfaceDesc.Format))); @@ -201,7 +199,7 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) if (lrect.pBits == nullptr) { surfaceCopy->Release_Ref(); - return; + return nullptr; } ScreenshotThreadData* threadData = new ScreenshotThreadData(); @@ -211,8 +209,6 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) threadData->is16Bit = is16Bit; threadData->quality = jpegQuality; threadData->format = format; - strlcpy(threadData->userDataDirectory, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(threadData->userDataDirectory)); - strlcpy(threadData->leafname, leafname, ARRAY_SIZE(threadData->leafname)); // Copy the locked surface with a single memcpy, including any row padding. The pixel // conversion and all file operations are done on the screenshot thread to keep the @@ -224,6 +220,31 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) surfaceCopy->Release_Ref(); surfaceCopy = nullptr; + return threadData; +} + +void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) +{ + static constexpr const char* const ScreenshotFormatExtensions[] = { "jpg", "png" }; + static_assert(ARRAY_SIZE(ScreenshotFormatExtensions) == SCREENSHOT_FORMAT_COUNT, "Incorrect array size"); + + // The filename is created here so the timestamp matches the capture time. + char leafname[_MAX_FNAME]; + const char* extension = ScreenshotFormatExtensions[format]; + + SYSTEMTIME st; + GetLocalTime(&st); + sprintf(leafname, "sshot_%04d%02d%02d_%02d%02d%02d_%03d.%s", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, extension); + + ScreenshotThreadData* threadData = captureBackBuffer(format, jpegQuality); + if (threadData == nullptr) + { + return; + } + strlcpy(threadData->userDataDirectory, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(threadData->userDataDirectory)); + strlcpy(threadData->leafname, leafname, ARRAY_SIZE(threadData->leafname)); + const HANDLE hThread = CreateThread(nullptr, 0, screenshotThreadFunc, threadData, 0, nullptr); if (hThread) { @@ -234,3 +255,4 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) delete threadData; } } + From c304a09d2134b4c878684fd9b90602b166615ddd Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 15:21:02 +0200 Subject: [PATCH 06/13] feat(mcp): Adds an in-game MCP bridge enabled with -mcp generalszh.exe started with -mcp listens on 127.0.0.1:47801 for JSON line commands that pause and step the game, inspect objects and players, give orders as any player, spawn and change objects, set player values, run scripts, read flags and counters, move the camera and take screenshots. -mcpGame and -mcpPlayers start a skirmish with a human local player, alone in a sandbox or against AI players; -headless runs the logic only. The bridge is compiled only with RTS_BUILD_OPTION_GAME_MCP (default ON). Without -mcp nothing is created and every hook is a null check. Co-Authored-By: Claude Opus 5.5 --- .../W3DDevice/GameClient/W3DScreenshot.h | 5 + .../W3DDevice/GameClient/W3DScreenshot.cpp | 13 + GeneralsMD/Code/GameEngine/CMakeLists.txt | 21 + .../Code/GameEngine/Include/Common/GameMcp.h | 102 +++++ .../Include/GameLogic/ScriptEngine.h | 11 + .../GameEngine/Source/Common/GameEngine.cpp | 24 ++ .../GameEngine/Source/Common/GameMain.cpp | 8 + .../GameEngine/Source/Common/Mcp/GameMcp.cpp | 249 ++++++++++++ .../Source/Common/Mcp/GameMcpCmdCheats.cpp | 272 +++++++++++++ .../Source/Common/Mcp/GameMcpCmdObjects.cpp | 378 ++++++++++++++++++ .../Source/Common/Mcp/GameMcpCmdOrders.cpp | 281 +++++++++++++ .../Source/Common/Mcp/GameMcpCmdPlayers.cpp | 165 ++++++++ .../Source/Common/Mcp/GameMcpCmdScripts.cpp | 111 +++++ .../Source/Common/Mcp/GameMcpCmdState.cpp | 182 +++++++++ .../Source/Common/Mcp/GameMcpCmdView.cpp | 105 +++++ .../Source/Common/Mcp/GameMcpCommands.cpp | 368 +++++++++++++++++ .../Source/Common/Mcp/GameMcpCommands.h | 137 +++++++ .../Source/Common/Mcp/GameMcpServer.cpp | 279 +++++++++++++ .../GameLogic/ScriptEngine/ScriptEngine.cpp | 59 +++ .../ScriptEngine/VictoryConditions.cpp | 7 + .../W3DDevice/GameClient/W3DDisplay.cpp | 5 + cmake/config-build.cmake | 6 + 22 files changed, 2788 insertions(+) create mode 100644 GeneralsMD/Code/GameEngine/Include/Common/GameMcp.h create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcp.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdCheats.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdOrders.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdScripts.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdView.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCommands.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCommands.h create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpServer.cpp diff --git a/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DScreenshot.h b/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DScreenshot.h index e6edec10cd8..0a123c54552 100644 --- a/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DScreenshot.h +++ b/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DScreenshot.h @@ -22,6 +22,11 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality); +#ifdef RTS_GAME_MCP +// Writes the current back buffer to a file right away, for the game's MCP bridge. +bool W3D_WriteScreenshotFile(const char* pathname, ScreenshotFormat format, Int jpegQuality); +#endif + // Called once per frame on the main thread to show messages for screenshots // that the screenshot thread has finished writing. void W3D_UpdateScreenshotMessages(); diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp index 7d6b518d88a..d7f90d95e90 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp @@ -256,3 +256,16 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) } } +#ifdef RTS_GAME_MCP +bool W3D_WriteScreenshotFile(const char* pathname, ScreenshotFormat format, Int jpegQuality) +{ + ScreenshotThreadData* data = captureBackBuffer(format, jpegQuality); + if (data == nullptr) + { + return false; + } + const int success = writeScreenshotImage(data, pathname); + delete data; + return success != 0; +} +#endif diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 1f31fb6fbdf..59e11ab9e20 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1256,6 +1256,23 @@ else() endif() +if(RTS_BUILD_OPTION_GAME_MCP) + list(APPEND GAMEENGINE_SRC + Include/Common/GameMcp.h + Source/Common/Mcp/GameMcp.cpp + Source/Common/Mcp/GameMcpCmdCheats.cpp + Source/Common/Mcp/GameMcpCmdObjects.cpp + Source/Common/Mcp/GameMcpCmdOrders.cpp + Source/Common/Mcp/GameMcpCmdPlayers.cpp + Source/Common/Mcp/GameMcpCmdScripts.cpp + Source/Common/Mcp/GameMcpCmdState.cpp + Source/Common/Mcp/GameMcpCmdView.cpp + Source/Common/Mcp/GameMcpCommands.cpp + Source/Common/Mcp/GameMcpCommands.h + Source/Common/Mcp/GameMcpServer.cpp + ) +endif() + add_library(z_gameengine STATIC) target_sources(z_gameengine PRIVATE ${GAMEENGINE_SRC}) @@ -1278,6 +1295,10 @@ target_link_libraries(z_gameengine PUBLIC z_wwvegas ) +if(RTS_BUILD_OPTION_GAME_MCP) + target_link_libraries(z_gameengine PUBLIC ws2_32) +endif() + target_precompile_headers(z_gameengine PRIVATE [["Utility/CppMacros.h"]] # Must be first, to be removed when abandoning VC6 # Include/Precompiled/PreRTS.h diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GameMcp.h b/GeneralsMD/Code/GameEngine/Include/Common/GameMcp.h new file mode 100644 index 00000000000..3ad12d42d13 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/Common/GameMcp.h @@ -0,0 +1,102 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcp.h +// A localhost command bridge that lets an external MCP server drive the running game: pause and +// step it, inspect objects and players, give orders, change values and run scripts. It exists only +// when the game is built with RTS_GAME_MCP and started with -mcp. +// +// -mcp listen on 127.0.0.1:47801 +// -mcpport: listen on another port +// -mcpGame start a skirmish on that map (map name or part of its path) +// -mcpPlayers Faction[:team] of the local player (or Observer), then the AI players as +// Faction:difficulty[:team], comma separated. Only the local player: a +// sandbox without opponents in which nobody wins or loses. +// -mcpCash starting cash (default: the game's) +// -mcpStartPaused pause as soon as the game has started +// -seed random seed + +#pragma once + +#ifdef RTS_GAME_MCP + +#include "Common/AsciiString.h" +#include "GameClient/Display.h" + +#include +#include + +class GameMcp +{ +public: + enum { DEFAULT_PORT = 47801 }; + + struct Message + { + UnsignedInt serial; + UnsignedInt frame; + std::string text; + }; + + /// Reads the command line; returns the bridge if -mcp asked for one and it could listen. + static GameMcp *createFromCommandLine(); + + ~GameMcp(); + + /// Starts the skirmish asked for with -mcpGame, if any. + void startGameFromCommandLine(); + + /// Runs the commands that have arrived. Main thread only, between logic frames. + void poll(); + + /// Runs the game logic without the client, honoring pause and frame steps. Returns the process exit code. + Int runHeadless(); + + /// The script engine reported something. + void scriptMessage(const AsciiString &text); + + Bool isSandbox() const { return m_sandbox; } + Int getPort() const { return m_port; } + const AsciiString &getStartedMap() const { return m_map; } + + /// Frames per second the headless loop is held to; 0 for as fast as possible. + void setHeadlessFps(Int fps) { m_headlessFps = fps; } + Int getHeadlessFps() const { return m_headlessFps; } + + const std::deque &getMessages() const { return m_messages; } + UnsignedInt getNextMessageSerial() const { return m_nextMessageSerial; } + +private: + GameMcp(); + + Int m_port; + AsciiString m_map; + Bool m_sandbox; + Bool m_pauseAtStart; + Int m_headlessFps; + std::deque m_messages; + UnsignedInt m_nextMessageSerial; +}; + +extern GameMcp *TheGameMcp; ///< null unless the game was started with -mcp + +/// Writes what the display shows to a file; set by the display device, null without one (headless). +typedef bool (*GameMcpScreenshotFunc)(const char *pathname, ScreenshotFormat format, Int jpegQuality); +extern GameMcpScreenshotFunc TheGameMcpScreenshot; + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/ScriptEngine.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/ScriptEngine.h index 3ed3d9fc00c..e361738dfd4 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/ScriptEngine.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/ScriptEngine.h @@ -335,6 +335,17 @@ class ScriptEngine : public SubsystemInterface, const TCounter *getCounter(const AsciiString& counterName); + /// Named flags and counters for tools that test scripts. Setting one creates it if needed. + const TFlag *getFlag(const AsciiString& flagName); + void setFlagValue(const AsciiString& flagName, Bool value); + void setCounterValue(const AsciiString& counterName, Int value); + Int friend_getNumFlags() const { return m_numFlags; } ///< flags start at 1 + const TFlag *friend_getNthFlag(Int ndx) const { return &m_flags[ndx]; } + Int friend_getNumCounters() const { return m_numCounters; } ///< counters start at 1 + const TCounter *friend_getNthCounter(Int ndx) const { return &m_counters[ndx]; } + /// Enables or disables a script or script group by name; false if there is none. + Bool setScriptActive(const AsciiString& name, Bool active); + void createNamedMapReveal(const AsciiString& revealName, const AsciiString& waypointName, Real radiusToReveal, const AsciiString& playerName); void doNamedMapReveal(const AsciiString& revealName); void undoNamedMapReveal(const AsciiString& revealName); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index ee6eedba717..43a8cbe7302 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp @@ -105,6 +105,7 @@ #include "GameClient/GUICallbacks.h" #include "GameLogic/AIMatch.h" +#include "Common/GameMcp.h" #include "GameNetwork/NetworkInterface.h" #include "GameNetwork/WOLBrowser/WebBrowser.h" #include "GameNetwork/LANAPI.h" @@ -287,6 +288,11 @@ GameEngine::~GameEngine() delete TheAIMatch; TheAIMatch = nullptr; +#ifdef RTS_GAME_MCP + delete TheGameMcp; + TheGameMcp = nullptr; +#endif + delete TheChallengeGameInfo; TheChallengeGameInfo = nullptr; @@ -750,6 +756,15 @@ void GameEngine::init() TheAIMatch->start(); } +#ifdef RTS_GAME_MCP + // The bridge for tools that drive the game, asked for on the command line. + TheGameMcp = GameMcp::createFromCommandLine(); + if (TheGameMcp) + { + TheGameMcp->startGameFromCommandLine(); + } +#endif + // if (TheMapCache && TheGlobalData->m_shellMapOn) { @@ -933,6 +948,15 @@ void GameEngine::update() } } +#ifdef RTS_GAME_MCP + // Between the client and the logic, outside of the CRC check above because commands may change + // the game; orders from the bridge go out with this logic frame. + if (TheGameMcp) + { + TheGameMcp->poll(); + } +#endif + // TheSuperHackers @info Ignores frozen time because the script engine needs updating in the logic update regardless. if (canUpdateGameLogic(FramePacer::IgnoreFrozenTime)) { diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp index 02a15506176..c2e56ba7952 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp @@ -31,6 +31,7 @@ #include "Common/FramePacer.h" #include "Common/GameEngine.h" #include "Common/ReplaySimulation.h" +#include "Common/GameMcp.h" #include "GameLogic/AIMatch.h" @@ -50,6 +51,13 @@ Int GameMain() { exitcode = ReplaySimulation::simulateReplays(TheGlobalData->m_simulateReplays, TheGlobalData->m_simulateReplayJobs); } +#ifdef RTS_GAME_MCP + else if (TheGameMcp && TheGlobalData->m_headless) + { + // Also runs an AI match started with -aiMatch, which ends itself. + exitcode = TheGameMcp->runHeadless(); + } +#endif else if (TheAIMatch && TheGlobalData->m_headless) { exitcode = TheAIMatch->runHeadless(); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcp.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcp.cpp new file mode 100644 index 00000000000..84bd5eef737 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcp.cpp @@ -0,0 +1,249 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcp.cpp +// The game's MCP bridge: command line, game start and the main thread side. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include "Common/GameMcp.h" + +#include "Common/GameEngine.h" +#include "Common/GlobalData.h" +#include "GameLogic/AIMatch.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/SkirmishLaunch.h" +#include "GameNetwork/GameInfo.h" + +#include "GameMcpCommands.h" + +#include + +GameMcp *TheGameMcp = nullptr; +GameMcpScreenshotFunc TheGameMcpScreenshot = nullptr; + +namespace +{ + +const size_t MAX_KEPT_MESSAGES = 1000; + +/// Faction[:team] of the local player. +SkirmishSlot localSlot(const AsciiString &entry) +{ + SkirmishSlot slot; + slot.slotState = SLOT_PLAYER; + slot.team = -1; + AsciiString rest = entry; + AsciiString part; + if (rest.nextToken(&part, ":")) { + slot.faction = part; + } + if (rest.nextToken(&part, ":")) { + slot.team = atoi(part.str()); + } + return slot; +} + +/// Faction:difficulty[:team] of an AI player. +SkirmishSlot aiSlot(const AsciiString &entry) +{ + SkirmishSlot slot; + slot.slotState = SLOT_BRUTAL_AI; + slot.team = -1; + AsciiString rest = entry; + AsciiString part; + if (rest.nextToken(&part, ":")) { + slot.faction = part; + } + if (rest.nextToken(&part, ":")) { + slot.slotState = SkirmishLaunch::slotStateForDifficulty(part); + } + if (rest.nextToken(&part, ":")) { + slot.team = atoi(part.str()); + } + return slot; +} + +AsciiString s_players; +Int s_startingCash = 0; +AsciiString s_startError; + +} // namespace + +//------------------------------------------------------------------------------------------------- +GameMcp::GameMcp() : + m_port(0), + m_sandbox(FALSE), + m_pauseAtStart(FALSE), + m_headlessFps(0), + m_nextMessageSerial(1) +{ +} + +//------------------------------------------------------------------------------------------------- +GameMcp::~GameMcp() +{ + gameMcpServerStop(); +} + +//------------------------------------------------------------------------------------------------- +GameMcp *GameMcp::createFromCommandLine() +{ + // The options are read here and not with the other command line options, which both games share. + Int port = 0; + AsciiString map; + Bool pauseAtStart = FALSE; + for (Int i = 1; i < __argc; i++) { + const char *arg = __argv[i]; + const char *value = (i + 1 < __argc) ? __argv[i + 1] : nullptr; + if (stricmp(arg, "-mcp") == 0) { + if (port == 0) { + port = DEFAULT_PORT; + } + } else if (strnicmp(arg, "-mcpport:", 9) == 0 || strnicmp(arg, "-mcpport=", 9) == 0) { + port = atoi(arg + 9); + } else if (stricmp(arg, "-mcpStartPaused") == 0) { + pauseAtStart = TRUE; + } else if (value && stricmp(arg, "-mcpGame") == 0) { + map = value; + } else if (value && stricmp(arg, "-mcpPlayers") == 0) { + s_players = value; + } else if (value && stricmp(arg, "-mcpCash") == 0) { + s_startingCash = atoi(value); + } + } + if (port <= 0 || port > 65535) { + return nullptr; + } + if (!gameMcpServerStart(port)) { + return nullptr; + } + GameMcp *mcp = new GameMcp; + mcp->m_port = port; + mcp->m_map = map; + mcp->m_pauseAtStart = pauseAtStart; + return mcp; +} + +//------------------------------------------------------------------------------------------------- +void GameMcp::startGameFromCommandLine() +{ + // An AI match starts its own game. + if (m_map.isEmpty() || TheAIMatch != nullptr) { + return; + } + + const AsciiString mapKey = SkirmishLaunch::findMap(m_map); + if (mapKey.isEmpty()) { + s_startError.format("-mcpGame: no multiplayer map matches '%s'", m_map.str()); + DEBUG_LOG(("Game MCP bridge: %s", s_startError.str())); + return; + } + + // Faction of the local player first, then the AI players. + std::vector slots; + AsciiString players = s_players; + AsciiString entry; + while (players.nextToken(&entry, ",")) { + slots.push_back(slots.empty() ? localSlot(entry) : aiSlot(entry)); + } + if (slots.empty()) { + s_startError = "-mcpPlayers needs the faction of the local player, e.g. FactionAmerica or FactionAmerica,FactionChina:hard"; + DEBUG_LOG(("Game MCP bridge: %s", s_startError.str())); + return; + } + const Int startPositions = SkirmishLaunch::countStartPositions(mapKey); + if (startPositions >= 0 && (Int)slots.size() > startPositions) { + s_startError.format("-mcpGame: the map has %d start positions for %d players", startPositions, (Int)slots.size()); + DEBUG_LOG(("Game MCP bridge: %s", s_startError.str())); + return; + } + + const Int seed = TheGlobalData->m_fixedSeed >= 0 ? TheGlobalData->m_fixedSeed : (Int)GetTickCount(); + AsciiString error; + if (!SkirmishLaunch::prepare(mapKey, slots, seed, s_startingCash, error)) { + s_startError.format("-mcpPlayers %s", error.str()); + DEBUG_LOG(("Game MCP bridge: %s", s_startError.str())); + return; + } + m_map = mapKey; + m_sandbox = slots.size() == 1; + SkirmishLaunch::launch(mapKey, seed, TheGlobalData->m_framesPerSecondLimit); +} + +//------------------------------------------------------------------------------------------------- +const AsciiString &gameMcpStartError() +{ + return s_startError; +} + +//------------------------------------------------------------------------------------------------- +void GameMcp::poll() +{ + gameMcpServerDrain(); + + if (m_pauseAtStart && TheGameLogic->isInGame() && !TheGameLogic->isInShellGame() && TheGameLogic->getFrame() >= 1) { + m_pauseAtStart = FALSE; + TheGameLogic->setGamePaused(TRUE, FALSE, FALSE); + } +} + +//------------------------------------------------------------------------------------------------- +Int GameMcp::runHeadless() +{ + // As AIMatch::runHeadless, but the logic waits while the game is paused and the commands are run in between. + DWORD nextFrameTime = timeGetTime(); + while (!TheGameEngine->getQuitting()) { + poll(); + TheGameLogic->preUpdate(); + if (TheGameLogic->isGamePaused()) { + ::Sleep(1); + continue; + } + if (m_headlessFps > 0) { + const DWORD now = timeGetTime(); + if ((Int)(nextFrameTime - now) > 0) { + ::Sleep(1); + continue; + } + nextFrameTime = max(nextFrameTime + 1000 / m_headlessFps, now); + } + TheGameLogic->UPDATE(); + if (!TheGameLogic->isInGame()) { + ::Sleep(1); + } + } + return 0; +} + +//------------------------------------------------------------------------------------------------- +void GameMcp::scriptMessage(const AsciiString &text) +{ + Message message; + message.serial = m_nextMessageSerial++; + message.frame = TheGameLogic ? TheGameLogic->getFrame() : 0; + message.text = text.str(); + m_messages.push_back(message); + while (m_messages.size() > MAX_KEPT_MESSAGES) { + m_messages.pop_front(); + } +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdCheats.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdCheats.cpp new file mode 100644 index 00000000000..d34c76b9c56 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdCheats.cpp @@ -0,0 +1,272 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCmdCheats.cpp +// Test commands of the MCP bridge that change the game directly, between two logic frames: +// spawning and removing objects, changing objects and players. Unlike orders they are not +// recorded, so a replay of a game changed this way does not play back the same. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include "GameMcpCommands.h" + +#include "Common/GlobalData.h" +#include "Common/KindOf.h" +#include "Common/Money.h" +#include "Common/Player.h" +#include "Common/Science.h" +#include "Common/Team.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "Common/Upgrade.h" +#include "GameLogic/AI.h" +#include "GameLogic/AIPathfind.h" +#include "GameLogic/ExperienceTracker.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Module/BodyModule.h" +#include "GameLogic/Object.h" +#include "GameLogic/ScriptEngine.h" +#include "GameLogic/TerrainLogic.h" + +namespace +{ + +VeterancyLevel requireVeterancy(const McpJson &value) +{ + if (value.isNumber()) { + const Int level = (Int)value.asNumber(); + if (level >= LEVEL_FIRST && level <= LEVEL_LAST) { + return (VeterancyLevel)level; + } + } else if (value.isString()) { + for (Int level = LEVEL_FIRST; level <= LEVEL_LAST; level++) { + if (_stricmp(TheVeterancyNames[level], value.asString().c_str()) == 0) { + return (VeterancyLevel)level; + } + } + } + gameMcpFail("veterancy must be 0..%d or one of REGULAR, VETERAN, ELITE, HEROIC", (int)LEVEL_LAST); +} + +const UpgradeTemplate *requireUpgrade(const std::string &name) +{ + const UpgradeTemplate *upgrade = TheUpgradeCenter->findUpgrade(name.c_str()); + if (upgrade == nullptr) { + gameMcpFail("unknown upgrade '%s'", name.c_str()); + } + return upgrade; +} + +ScienceType requireScience(const std::string &name) +{ + const ScienceType science = TheScienceStore->getScienceFromInternalName(name.c_str()); + if (science == SCIENCE_INVALID) { + gameMcpFail("unknown science '%s'", name.c_str()); + } + return science; +} + +McpJson cmdSpawn(const McpJson &args) +{ + gameMcpRequireGame(); + const ThingTemplate *tmpl = gameMcpRequireTemplate(gameMcpArgString(args, "template")); + Player *player = gameMcpPlayerArg(args, "player"); + Team *team = player->getDefaultTeam(); + if (args.has("team")) { + const std::string teamName = gameMcpArgString(args, "team"); + team = TheScriptEngine->getTeamNamed(teamName.c_str()); + if (team == nullptr) { + gameMcpFail("unknown team '%s'", teamName.c_str()); + } + } + if (team == nullptr) { + gameMcpFail("player %s has no team to put the object in", gameMcpPlayerName(player).c_str()); + } + const Coord3D center = gameMcpArgPosition(args); + const Real angle = (Real)(gameMcpArgNumber(args, "angle", 0) * PI / 180.0); + const Int count = gameMcpArgInt(args, "count", 1); + const Real spacing = (Real)gameMcpArgNumber(args, "spacing", 20); + if (count < 1 || count > 500) { + gameMcpFail("count must be 1..500"); + } + + // Several objects stand in a square grid around the position. + Int columns = 1; + while (columns * columns < count) { + columns++; + } + McpJson ids = McpJson::makeArray(); + for (Int i = 0; i < count; i++) { + Coord3D pos = center; + if (count > 1) { + pos.x += ((i % columns) - (columns - 1) * 0.5f) * spacing; + pos.y += ((i / columns) - (columns - 1) * 0.5f) * spacing; + pos.z = args.has("z") ? center.z : TheTerrainLogic->getGroundHeight(pos.x, pos.y); + } + Object *object = TheThingFactory->newObject(tmpl, team); + if (object == nullptr) { + gameMcpFail("could not create '%s'", tmpl->getName().str()); + } + object->setOrientation(angle); + object->setPosition(&pos); + if (object->isKindOf(KINDOF_BLAST_CRATER)) { + TheTerrainLogic->createCraterInTerrain(object); + TheAI->pathfinder()->addObjectToPathfindMap(object); + } + if (args.has("name") && count == 1) { + object->setName(gameMcpArgString(args, "name").c_str()); + TheScriptEngine->addObjectToCache(object); + } + ids.push((unsigned int)object->getID()); + } + return McpJson::makeObject().set("ids", ids).set("frame", TheGameLogic->getFrame()); +} + +McpJson cmdDestroy(const McpJson &args) +{ + std::vector objects; + gameMcpRequireObjects(args, objects); + // kill lets the object die as if it were destroyed (death effects, rubble); remove takes it away silently. + const bool remove = gameMcpArgString(args, "how", "kill") == "remove"; + for (size_t i = 0; i < objects.size(); i++) { + if (remove) { + TheGameLogic->destroyObject(objects[i]); + } else { + objects[i]->kill(); + } + } + return McpJson::makeObject().set("count", (int)objects.size()).set("how", remove ? "remove" : "kill"); +} + +McpJson cmdSet(const McpJson &args) +{ + std::vector objects; + gameMcpRequireObjects(args, objects); + for (size_t i = 0; i < objects.size(); i++) { + Object *object = objects[i]; + BodyModuleInterface *body = object->getBodyModule(); + if (args.has("max_health")) { + if (body == nullptr) gameMcpFail("object %u has no body", (unsigned int)object->getID()); + body->setMaxHealth((Real)gameMcpArgNumber(args, "max_health"), SAME_CURRENTHEALTH); + } + if (args.has("health") || args.has("health_percent")) { + if (body == nullptr) gameMcpFail("object %u has no body", (unsigned int)object->getID()); + const Real health = args.has("health") ? (Real)gameMcpArgNumber(args, "health") + : body->getMaxHealth() * (Real)gameMcpArgNumber(args, "health_percent") / 100.0f; + if (health <= 0) { + object->kill(); + continue; + } + body->internalChangeHealth(min(health, body->getMaxHealth()) - body->getHealth()); + } + if (args.has("x") || args.has("y")) { + Coord3D pos = *object->getPosition(); + pos.x = (Real)gameMcpArgNumber(args, "x", pos.x); + pos.y = (Real)gameMcpArgNumber(args, "y", pos.y); + pos.z = args.has("z") ? (Real)gameMcpArgNumber(args, "z") : TheTerrainLogic->getGroundHeight(pos.x, pos.y); + object->setPosition(&pos); + } + if (args.has("angle")) { + object->setOrientation((Real)(gameMcpArgNumber(args, "angle") * PI / 180.0)); + } + if (args.has("veterancy") && object->getExperienceTracker()) { + object->getExperienceTracker()->setVeterancyLevel(requireVeterancy(args.get("veterancy"))); + } + if (args.has("upgrade")) { + object->giveUpgrade(requireUpgrade(gameMcpArgString(args, "upgrade"))); + } + if (args.has("set_status") || args.has("clear_status")) { + const bool set = args.has("set_status"); + const Int bit = gameMcpRequireFlag(gameMcpArgString(args, set ? "set_status" : "clear_status"), "status"); + ObjectStatusMaskType mask; + mask.set(bit); + object->setStatus(mask, set); + } + } + return McpJson::makeObject().set("count", (int)objects.size()); +} + +McpJson cmdPlayerSet(const McpJson &args) +{ + Player *player = gameMcpPlayerArg(args, "player"); + Money *money = player->getMoney(); + if (args.has("money")) { + money->withdraw(money->countMoney(), FALSE); + money->deposit((UnsignedInt)max(0, gameMcpArgInt(args, "money")), FALSE, FALSE); + } + if (args.has("add_money")) { + money->deposit((UnsignedInt)max(0, gameMcpArgInt(args, "add_money")), FALSE, FALSE); + } + if (args.has("rank")) { + player->setRankLevel(gameMcpArgInt(args, "rank")); + } + if (args.has("add_skill_points")) { + player->addSkillPoints(gameMcpArgInt(args, "add_skill_points")); + } + if (args.has("add_science_points")) { + player->addSciencePurchasePoints(gameMcpArgInt(args, "add_science_points")); + } + const McpJson &sciences = args.get("sciences"); + for (size_t i = 0; i < sciences.size(); i++) { + player->grantScience(requireScience(sciences.at(i).asString())); + } + if (gameMcpArgBool(args, "all_sciences", false)) { + const std::vector names = TheScienceStore->friend_getScienceNames(); + for (size_t i = 0; i < names.size(); i++) { + const ScienceType science = TheScienceStore->getScienceFromInternalName(names[i]); + if (science != SCIENCE_INVALID && TheScienceStore->isScienceGrantable(science)) { + player->grantScience(science); + } + } + } + const McpJson &upgrades = args.get("upgrades"); + for (size_t i = 0; i < upgrades.size(); i++) { + const UpgradeTemplate *upgrade = requireUpgrade(upgrades.at(i).asString()); + if (upgrade->getUpgradeType() != UPGRADE_TYPE_PLAYER) { + gameMcpFail("'%s' is an object upgrade; give it to an object with objects.set upgrade", upgrade->getUpgradeName().str()); + } + player->addUpgrade(upgrade, UPGRADE_STATUS_COMPLETE); + } + if (args.has("instant_build") && gameMcpArgBool(args, "instant_build", false) != (player->buildsInstantly() != FALSE)) { + player->toggleInstantBuild(); + } + if (args.has("special_power_delays")) { + // Applies to every player, as the cheat does. + TheWritableGlobalData->m_specialPowerUsesDelay = gameMcpArgBool(args, "special_power_delays", true); + } + return McpJson::makeObject().set("player", gameMcpPlayerName(player)).set("money", money->countMoney()) + .set("instant_build", player->buildsInstantly() != FALSE).set("special_power_delays", TheGlobalData->m_specialPowerUsesDelay != FALSE); +} + +} // namespace + +void gameMcpRegisterCheatCommands() +{ + gameMcpRegisterCommand("objects.spawn", cmdSpawn, + "{template, player?, team?, x, y, z?, angle=0, count=1, spacing=20, name?} Creates objects for a player (default: the local one)."); + gameMcpRegisterCommand("objects.destroy", cmdDestroy, "{ids, how=kill|remove} Kills objects (they die normally) or removes them silently."); + gameMcpRegisterCommand("objects.set", cmdSet, + "{ids, health? | health_percent?, max_health?, x?, y?, z?, angle?, veterancy?, upgrade?, set_status? | clear_status?} Changes objects."); + gameMcpRegisterCommand("players.set", cmdPlayerSet, + "{player?, money?, add_money?, rank?, add_skill_points?, add_science_points?, sciences?, all_sciences?, upgrades?, " + "instant_build?, special_power_delays?} Changes a player."); +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp new file mode 100644 index 00000000000..c03e678fbfa --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp @@ -0,0 +1,378 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCmdObjects.cpp +// Object inspection commands of the MCP bridge. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#define DEFINE_WEAPONSLOTTYPE_NAMES + +#include "GameMcpCommands.h" + +#include "Common/KindOf.h" +#include "Common/NameKeyGenerator.h" +#include "Common/Player.h" +#include "Common/Team.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "Common/Upgrade.h" +#include "GameClient/Drawable.h" +#include "GameLogic/ExperienceTracker.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Module/AIUpdate.h" +#include "GameLogic/Module/BehaviorModule.h" +#include "GameLogic/Module/BodyModule.h" +#include "GameLogic/Module/ContainModule.h" +#include "GameLogic/Module/PhysicsUpdate.h" +#include "GameLogic/Module/ProductionUpdate.h" +#include "GameLogic/Module/SpecialPowerModule.h" +#include "GameLogic/Object.h" +#include "GameLogic/Weapon.h" +#include "GameLogic/WeaponSet.h" +#include "GameLogic/WeaponStatus.h" + +#include + +namespace +{ + +const char *const WEAPON_STATUS_NAMES[] = { "READY_TO_FIRE", "OUT_OF_AMMO", "BETWEEN_FIRING_SHOTS", "RELOADING_CLIP", "PRE_ATTACK" }; +static_assert(ARRAY_SIZE(WEAPON_STATUS_NAMES) == WEAPON_STATUS_COUNT, "Incorrect array size"); + +const char *const DAMAGE_STATE_NAMES[] = { "PRISTINE", "DAMAGED", "REALLYDAMAGED", "RUBBLE" }; + +double degrees(Real radians) +{ + return radians * 180.0 / PI; +} + +std::string keyName(NameKeyType key) +{ + return TheNameKeyGenerator->keyToName(key).str(); +} + +/// One line per object, for lists. +McpJson describeBrief(const Object *object) +{ + McpJson row = McpJson::makeObject(); + row.set("id", (unsigned int)object->getID()); + row.set("template", object->getTemplate()->getName().str()); + if (object->getName().isNotEmpty()) { + row.set("name", object->getName().str()); + } + row.set("owner", gameMcpPlayerName(object->getControllingPlayer())); + row.set("position", gameMcpCoord(*object->getPosition())); + row.set("angle", degrees(object->getOrientation())); + const BodyModuleInterface *body = object->getBodyModule(); + if (body) { + row.set("health", body->getHealth()).set("max_health", body->getMaxHealth()); + } + if (object->isEffectivelyDead()) { + row.set("dead", true); + } + if (object->getContainedBy()) { + row.set("contained_by", (unsigned int)object->getContainedBy()->getID()); + } + return row; +} + +McpJson describeWeapons(const Object *object) +{ + McpJson weapons = McpJson::makeArray(); + WeaponSlotType currentSlot = PRIMARY_WEAPON; + const Weapon *current = object->getCurrentWeapon(¤tSlot); + for (Int slot = 0; slot < WEAPONSLOT_COUNT; slot++) { + const Weapon *weapon = object->getWeaponInWeaponSlot((WeaponSlotType)slot); + if (weapon == nullptr) { + continue; + } + const WeaponStatus status = weapon->getStatus(); + McpJson entry = McpJson::makeObject(); + entry.set("slot", TheWeaponSlotTypeNames[slot]); + entry.set("name", weapon->getName().str()); + entry.set("current", current != nullptr && slot == (Int)currentSlot); + entry.set("status", status >= 0 && status < WEAPON_STATUS_COUNT ? WEAPON_STATUS_NAMES[status] : "?"); + entry.set("ammo", weapon->getRemainingAmmo()).set("clip_size", weapon->getClipSize()); + entry.set("range", weapon->getAttackRange(object)); + entry.set("next_shot_frame", weapon->getPossibleNextShotFrame()).set("last_shot_frame", weapon->getLastShotFrame()); + entry.set("clip_reload_frames", weapon->getClipReloadTime(object)); + weapons.push(entry); + } + return weapons; +} + +McpJson describeAI(const Object *object) +{ + const AIUpdateInterface *ai = object->getAIUpdateInterface(); + if (ai == nullptr) { + return McpJson(); + } + McpJson result = McpJson::makeObject(); + result.set("state", ai->getCurrentStateName().str()); + result.set("state_type", (int)ai->getAIStateType()); + result.set("idle", ai->isIdle() != FALSE).set("moving", ai->isMoving() != FALSE).set("attacking", ai->isAttacking() != FALSE); + result.set("last_command_source", (int)ai->getLastCommandSource()); + result.set("speed", ai->getCurLocomotorSpeed()); + AIUpdateInterface *mutableAI = const_cast(ai); + const Object *goal = mutableAI->getGoalObject(); + if (goal) { + result.set("goal_object", (unsigned int)goal->getID()); + } + const Coord3D *goalPos = ai->getGoalPosition(); + if (goalPos && (goalPos->x != 0 || goalPos->y != 0)) { + result.set("goal_position", gameMcpCoord(*goalPos)); + } + return result; +} + +McpJson describeModules(const Object *object) +{ + McpJson modules = McpJson::makeArray(); + for (BehaviorModule **m = object->getBehaviorModules(); m && *m; ++m) { + modules.push(McpJson::makeObject().set("module", keyName((*m)->getModuleNameKey())).set("tag", keyName((*m)->getModuleTagNameKey()))); + } + return modules; +} + +McpJson describeSpecialPowers(const Object *object) +{ + McpJson powers = McpJson::makeArray(); + for (BehaviorModule **m = object->getBehaviorModules(); m && *m; ++m) { + const SpecialPowerModuleInterface *power = (*m)->getSpecialPower(); + if (power == nullptr) { + continue; + } + powers.push(McpJson::makeObject().set("power", power->getPowerName().str()).set("ready", power->isReady() != FALSE) + .set("percent_ready", power->getPercentReady()).set("ready_frame", power->getReadyFrame())); + } + return powers; +} + +McpJson describeProduction(Object *object) +{ + ProductionUpdateInterface *production = object->getProductionUpdateInterface(); + if (production == nullptr) { + return McpJson(); + } + McpJson queue = McpJson::makeArray(); + for (const ProductionEntry *entry = production->firstProduction(); entry; entry = production->nextProduction(entry)) { + McpJson row = McpJson::makeObject(); + if (entry->getProductionObject()) { + row.set("unit", entry->getProductionObject()->getName().str()); + } else if (entry->getProductionUpgrade()) { + row.set("upgrade", entry->getProductionUpgrade()->getUpgradeName().str()); + } + row.set("percent", entry->getPercentComplete()).set("quantity", entry->getProductionQuantity()) + .set("remaining", entry->getProductionQuantityRemaining()); + queue.push(row); + } + return queue; +} + +McpJson describeContain(const Object *object) +{ + const ContainModuleInterface *contain = object->getContain(); + if (contain == nullptr) { + return McpJson(); + } + McpJson ids = McpJson::makeArray(); + const ContainedItemsList *items = contain->getContainedItemsList(); + if (items) { + for (ContainedItemsList::const_iterator it = items->begin(); it != items->end(); ++it) { + ids.push((unsigned int)(*it)->getID()); + } + } + return McpJson::makeObject().set("count", contain->getContainCount()).set("max", contain->getContainMax()).set("ids", ids); +} + +McpJson describeUpgrades(const Object *object) +{ + McpJson names = McpJson::makeArray(); + for (const UpgradeTemplate *upgrade = TheUpgradeCenter->firstUpgradeTemplate(); upgrade; upgrade = upgrade->friend_getNext()) { + if (upgrade->getUpgradeType() == UPGRADE_TYPE_OBJECT && object->hasUpgrade(upgrade)) { + names.push(upgrade->getUpgradeName().str()); + } + } + return names; +} + +bool wants(const McpJson §ions, const char *name) +{ + if (!sections.isArray()) { + return true; + } + for (size_t i = 0; i < sections.size(); i++) { + if (_stricmp(sections.at(i).asString().c_str(), name) == 0) { + return true; + } + } + return false; +} + +/// Everything about one object. +McpJson describeFull(Object *object, const McpJson §ions) +{ + McpJson result = describeBrief(object); + result.set("team", object->getTeam() ? object->getTeam()->getName().str() : ""); + result.set("display_name", gameMcpUtf8(object->getTemplate()->getDisplayName())); + result.set("status", gameMcpFlagNames(object->getStatusBits())); + result.set("disabled", gameMcpFlagNames(object->getDisabledFlags())); + if (wants(sections, "kindof")) { + KindOfMaskType kindOf; + for (Int i = 0; i < KINDOF_COUNT; i++) { + if (object->isKindOf((KindOfType)i)) { + kindOf.set(i); + } + } + result.set("kindof", gameMcpFlagNames(kindOf)); + } + const BodyModuleInterface *body = object->getBodyModule(); + if (body) { + const Int state = (Int)body->getDamageState(); + result.set("damage_state", state >= 0 && state < (Int)ARRAY_SIZE(DAMAGE_STATE_NAMES) ? DAMAGE_STATE_NAMES[state] : "?"); + } + result.set("veterancy", TheVeterancyNames[object->getVeterancyLevel()]); + if (object->getExperienceTracker()) { + result.set("experience", object->getExperienceTracker()->getCurrentExperience()); + } + if (object->testStatus(OBJECT_STATUS_UNDER_CONSTRUCTION)) { + result.set("construction_percent", object->getConstructionPercent()); + } + if (object->getPhysics()) { + result.set("velocity", gameMcpCoord(*object->getPhysics()->getVelocity())); + } + if (object->getDrawable() && wants(sections, "model")) { + result.set("model_conditions", gameMcpFlagNames(object->getDrawable()->getModelConditionFlags())); + } + if (wants(sections, "ai")) { + result.set("ai", describeAI(object)); + } + if (wants(sections, "weapons")) { + result.set("weapons", describeWeapons(object)); + } + if (wants(sections, "upgrades")) { + result.set("upgrades", describeUpgrades(object)); + } + if (wants(sections, "special_powers")) { + result.set("special_powers", describeSpecialPowers(object)); + } + if (wants(sections, "production")) { + result.set("production", describeProduction(object)); + } + if (wants(sections, "contain")) { + result.set("contain", describeContain(object)); + } + if (wants(sections, "modules")) { + result.set("modules", describeModules(object)); + } + return result; +} + +McpJson cmdList(const McpJson &args) +{ + gameMcpRequireGame(); + const Player *owner = args.has("player") ? gameMcpPlayerArg(args, "player") : nullptr; + const std::string templateFilter = gameMcpArgString(args, "template", ""); + const std::string nameFilter = gameMcpArgString(args, "name", ""); + const Int kindOf = args.has("kindof") ? gameMcpRequireFlag(gameMcpArgString(args, "kindof"), "kindof") : -1; + const Int status = args.has("status") ? gameMcpRequireFlag(gameMcpArgString(args, "status"), "status") : -1; + const bool includeDead = gameMcpArgBool(args, "include_dead", false); + const Int limit = gameMcpArgInt(args, "limit", 200); + const bool circle = args.has("radius"); + const bool rect = args.has("x0"); + Real cx = 0, cy = 0, radius = 0, x0 = 0, y0 = 0, x1 = 0, y1 = 0; + if (circle) { + cx = (Real)gameMcpArgNumber(args, "x"); + cy = (Real)gameMcpArgNumber(args, "y"); + radius = (Real)gameMcpArgNumber(args, "radius"); + } else if (rect) { + x0 = (Real)gameMcpArgNumber(args, "x0"); + y0 = (Real)gameMcpArgNumber(args, "y0"); + x1 = (Real)gameMcpArgNumber(args, "x1"); + y1 = (Real)gameMcpArgNumber(args, "y1"); + } + + McpJson list = McpJson::makeArray(); + Int matched = 0; + for (Object *object = TheGameLogic->getFirstObject(); object; object = object->getNextObject()) { + if (!includeDead && object->isEffectivelyDead()) continue; + if (owner && object->getControllingPlayer() != owner) continue; + if (!gameMcpContainsNoCase(object->getTemplate()->getName().str(), templateFilter.c_str())) continue; + if (!nameFilter.empty() && !gameMcpContainsNoCase(object->getName().str(), nameFilter.c_str())) continue; + if (kindOf >= 0 && !object->isKindOf((KindOfType)kindOf)) continue; + if (status >= 0 && !object->testStatus((ObjectStatusTypes)status)) continue; + const Coord3D *pos = object->getPosition(); + if (circle && (pos->x - cx) * (pos->x - cx) + (pos->y - cy) * (pos->y - cy) > radius * radius) continue; + if (rect && (pos->x < min(x0, x1) || pos->x > max(x0, x1) || pos->y < min(y0, y1) || pos->y > max(y0, y1))) continue; + matched++; + if ((Int)list.size() < limit) { + list.push(describeBrief(object)); + } + } + return McpJson::makeObject().set("objects", list).set("matched", matched).set("frame", TheGameLogic->getFrame()); +} + +McpJson cmdGet(const McpJson &args) +{ + std::vector objects; + gameMcpRequireObjects(args, objects); + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < objects.size(); i++) { + list.push(describeFull(objects[i], args.get("sections"))); + } + return McpJson::makeObject().set("objects", list).set("frame", TheGameLogic->getFrame()); +} + +McpJson cmdTemplates(const McpJson &args) +{ + const std::string contains = gameMcpArgString(args, "contains", ""); + const std::string side = gameMcpArgString(args, "side", ""); + const Int kindOf = args.has("kindof") ? gameMcpRequireFlag(gameMcpArgString(args, "kindof"), "kindof") : -1; + const Int limit = gameMcpArgInt(args, "limit", 300); + McpJson list = McpJson::makeArray(); + Int matched = 0; + for (const ThingTemplate *tmpl = TheThingFactory->firstTemplate(); tmpl; tmpl = tmpl->friend_getNextTemplate()) { + if (!gameMcpContainsNoCase(tmpl->getName().str(), contains.c_str())) continue; + if (!side.empty() && _stricmp(tmpl->getDefaultOwningSide().str(), side.c_str()) != 0) continue; + if (kindOf >= 0 && !tmpl->isKindOf((KindOfType)kindOf)) continue; + matched++; + if ((Int)list.size() < limit) { + list.push(McpJson::makeObject().set("name", tmpl->getName().str()).set("side", tmpl->getDefaultOwningSide().str()) + .set("cost", tmpl->friend_getBuildCost()).set("build_seconds", tmpl->friend_getBuildTime())); + } + } + return McpJson::makeObject().set("templates", list).set("matched", matched); +} + +} // namespace + +void gameMcpRegisterObjectCommands() +{ + gameMcpRegisterCommand("objects.list", cmdList, + "{player?, template?, name?, kindof?, status?, x,y,radius | x0,y0,x1,y1, include_dead?, limit=200} " + "Objects in the game: id, template, owner, position, angle (degrees), health."); + gameMcpRegisterCommand("objects.get", cmdGet, + "{ids | id, sections?} Everything about objects: status, disabled, kindof, veterancy, ai, weapons, upgrades, " + "special_powers, production, contain, modules, model (sections limits the optional parts)."); + gameMcpRegisterCommand("objects.templates", cmdTemplates, "{contains?, side?, kindof?, limit=300} Object templates that can be spawned."); +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdOrders.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdOrders.cpp new file mode 100644 index 00000000000..002377879ff --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdOrders.cpp @@ -0,0 +1,281 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCmdOrders.cpp +// Order commands of the MCP bridge. Orders go to the logic as the player's own command messages +// would, so they are carried out on the next logic frame, recorded in the replay and checked like +// the player's clicks. They work for the units of any player. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include "GameMcpCommands.h" + +#include "Common/MessageStream.h" +#include "Common/Player.h" +#include "Common/Science.h" +#include "Common/SpecialPower.h" +#include "Common/ThingTemplate.h" +#include "Common/Upgrade.h" +#include "GameClient/ControlBar.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Module/ProductionUpdate.h" +#include "GameLogic/Object.h" + +namespace +{ + +GameMessage *appendOrder(GameMessage::Type type, const Player *player) +{ + GameMessage *msg = newInstance(GameMessage)(type); + msg->friend_setPlayerIndex(player->getPlayerIndex()); + TheCommandList->appendMessage(msg); + return msg; +} + +/// Makes the objects the selection of their owner, which the order that follows applies to. +Player *selectObjects(const std::vector &objects) +{ + Player *owner = objects[0]->getControllingPlayer(); + if (owner == nullptr) { + gameMcpFail("object %u has no owner", (unsigned int)objects[0]->getID()); + } + for (size_t i = 1; i < objects.size(); i++) { + if (objects[i]->getControllingPlayer() != owner) { + gameMcpFail("all objects of one order must belong to one player (%u belongs to %s, %u to %s)", + (unsigned int)objects[0]->getID(), gameMcpPlayerName(owner).c_str(), + (unsigned int)objects[i]->getID(), gameMcpPlayerName(objects[i]->getControllingPlayer()).c_str()); + } + } + GameMessage *msg = appendOrder(GameMessage::MSG_CREATE_SELECTED_GROUP_NO_SOUND, owner); + msg->appendBooleanArgument(TRUE); + for (size_t i = 0; i < objects.size(); i++) { + msg->appendObjectIDArgument(objects[i]->getID()); + } + return owner; +} + +Player *selectOne(Object *object) +{ + std::vector objects(1, object); + return selectObjects(objects); +} + +McpJson queued(const std::vector &objects) +{ + McpJson ids = McpJson::makeArray(); + for (size_t i = 0; i < objects.size(); i++) { + ids.push((unsigned int)objects[i]->getID()); + } + return McpJson::makeObject().set("queued", true).set("ids", ids).set("frame", TheGameLogic->getFrame()) + .set("note", "carried out on the next logic frame (step or resume a paused game)"); +} + +McpJson cmdMove(const McpJson &args) +{ + std::vector objects; + gameMcpRequireObjects(args, objects); + const Coord3D pos = gameMcpArgPosition(args); + Player *owner = selectObjects(objects); + const bool attackMove = gameMcpArgBool(args, "attack_move", false); + GameMessage *msg = appendOrder(attackMove ? GameMessage::MSG_DO_ATTACKMOVETO : GameMessage::MSG_DO_MOVETO, owner); + msg->appendLocationArgument(pos); + return queued(objects); +} + +McpJson cmdAttack(const McpJson &args) +{ + std::vector objects; + gameMcpRequireObjects(args, objects); + if (args.has("target")) { + Object *target = gameMcpRequireObject(gameMcpArgInt(args, "target")); + Player *owner = selectObjects(objects); + const bool force = gameMcpArgBool(args, "force", false); + GameMessage *msg = appendOrder(force ? GameMessage::MSG_DO_FORCE_ATTACK_OBJECT : GameMessage::MSG_DO_ATTACK_OBJECT, owner); + msg->appendObjectIDArgument(target->getID()); + } else { + const Coord3D pos = gameMcpArgPosition(args); + Player *owner = selectObjects(objects); + GameMessage *msg = appendOrder(GameMessage::MSG_DO_FORCE_ATTACK_GROUND, owner); + msg->appendLocationArgument(pos); + } + return queued(objects); +} + +McpJson cmdStop(const McpJson &args) +{ + std::vector objects; + gameMcpRequireObjects(args, objects); + appendOrder(GameMessage::MSG_DO_STOP, selectObjects(objects)); + return queued(objects); +} + +McpJson cmdGuard(const McpJson &args) +{ + std::vector objects; + gameMcpRequireObjects(args, objects); + const Int mode = gameMcpArgInt(args, "mode", GUARDMODE_NORMAL); + if (args.has("target")) { + Object *target = gameMcpRequireObject(gameMcpArgInt(args, "target")); + GameMessage *msg = appendOrder(GameMessage::MSG_DO_GUARD_OBJECT, selectObjects(objects)); + msg->appendObjectIDArgument(target->getID()); + msg->appendIntegerArgument(mode); + } else { + const Coord3D pos = gameMcpArgPosition(args); + GameMessage *msg = appendOrder(GameMessage::MSG_DO_GUARD_POSITION, selectObjects(objects)); + msg->appendLocationArgument(pos); + msg->appendIntegerArgument(mode); + } + return queued(objects); +} + +McpJson cmdSell(const McpJson &args) +{ + std::vector objects; + gameMcpRequireObjects(args, objects); + appendOrder(GameMessage::MSG_SELL, selectObjects(objects)); + return queued(objects); +} + +McpJson cmdBuild(const McpJson &args) +{ + Object *dozer = gameMcpRequireObject(gameMcpArgInt(args, "dozer")); + const ThingTemplate *tmpl = gameMcpRequireTemplate(gameMcpArgString(args, "template")); + const Coord3D pos = gameMcpArgPosition(args); + const Real angle = (Real)(gameMcpArgNumber(args, "angle", 0) * PI / 180.0); + GameMessage *msg = appendOrder(GameMessage::MSG_DOZER_CONSTRUCT, selectOne(dozer)); + msg->appendIntegerArgument(tmpl->getTemplateID()); + msg->appendLocationArgument(pos); + msg->appendRealArgument(angle); + return queued(std::vector(1, dozer)); +} + +McpJson cmdProduce(const McpJson &args) +{ + Object *factory = gameMcpRequireObject(gameMcpArgInt(args, "factory")); + const ThingTemplate *tmpl = gameMcpRequireTemplate(gameMcpArgString(args, "template")); + ProductionUpdateInterface *production = factory->getProductionUpdateInterface(); + if (production == nullptr) { + gameMcpFail("object %u (%s) cannot produce anything", (unsigned int)factory->getID(), factory->getTemplate()->getName().str()); + } + const Int count = gameMcpArgInt(args, "count", 1); + Player *owner = selectOne(factory); + for (Int i = 0; i < count; i++) { + GameMessage *msg = appendOrder(GameMessage::MSG_QUEUE_UNIT_CREATE, owner); + msg->appendIntegerArgument(tmpl->getTemplateID()); + msg->appendIntegerArgument(production->requestUniqueUnitID()); + } + return queued(std::vector(1, factory)); +} + +McpJson cmdUpgrade(const McpJson &args) +{ + Object *object = gameMcpRequireObject(gameMcpArgInt(args, "id")); + const std::string name = gameMcpArgString(args, "upgrade"); + const UpgradeTemplate *upgrade = TheUpgradeCenter->findUpgrade(name.c_str()); + if (upgrade == nullptr) { + gameMcpFail("unknown upgrade '%s'", name.c_str()); + } + GameMessage *msg = appendOrder(GameMessage::MSG_QUEUE_UPGRADE, selectOne(object)); + msg->appendObjectIDArgument(object->getID()); + msg->appendIntegerArgument(upgrade->getUpgradeNameKey()); + return queued(std::vector(1, object)); +} + +McpJson cmdSpecialPower(const McpJson &args) +{ + Object *source = gameMcpRequireObject(gameMcpArgInt(args, "id")); + const SpecialPowerTemplate *power = nullptr; + UnsignedInt options = (UnsignedInt)gameMcpArgInt(args, "options", 0); + if (args.has("button")) { + // A command button carries the power and the options the UI would send. + const std::string buttonName = gameMcpArgString(args, "button"); + const CommandButton *button = TheControlBar ? TheControlBar->findCommandButton(buttonName.c_str()) : nullptr; + if (button == nullptr || button->getSpecialPowerTemplate() == nullptr) { + gameMcpFail("no command button '%s' with a special power", buttonName.c_str()); + } + power = button->getSpecialPowerTemplate(); + if (!args.has("options")) { + options = button->getOptions(); + } + } else { + const std::string name = gameMcpArgString(args, "power"); + power = TheSpecialPowerStore->findSpecialPowerTemplate(name.c_str()); + if (power == nullptr) { + gameMcpFail("unknown special power '%s' (see objects.get section special_powers)", name.c_str()); + } + } + + Player *owner = selectOne(source); + if (args.has("target")) { + Object *target = gameMcpRequireObject(gameMcpArgInt(args, "target")); + GameMessage *msg = appendOrder(GameMessage::MSG_DO_SPECIAL_POWER_AT_OBJECT, owner); + msg->appendIntegerArgument(power->getID()); + msg->appendObjectIDArgument(target->getID()); + msg->appendIntegerArgument(options); + msg->appendObjectIDArgument(source->getID()); + } else if (args.has("x")) { + const Coord3D pos = gameMcpArgPosition(args); + GameMessage *msg = appendOrder(GameMessage::MSG_DO_SPECIAL_POWER_AT_LOCATION, owner); + msg->appendIntegerArgument(power->getID()); + msg->appendLocationArgument(pos); + msg->appendRealArgument((Real)(gameMcpArgNumber(args, "angle", 0) * PI / 180.0)); + msg->appendObjectIDArgument(INVALID_ID); + msg->appendIntegerArgument(options); + msg->appendObjectIDArgument(source->getID()); + } else { + GameMessage *msg = appendOrder(GameMessage::MSG_DO_SPECIAL_POWER, owner); + msg->appendIntegerArgument(power->getID()); + msg->appendIntegerArgument(options); + msg->appendObjectIDArgument(source->getID()); + } + return queued(std::vector(1, source)); +} + +McpJson cmdPurchaseScience(const McpJson &args) +{ + Player *player = gameMcpPlayerArg(args, "player"); + const std::string name = gameMcpArgString(args, "science"); + const ScienceType science = TheScienceStore->getScienceFromInternalName(name.c_str()); + if (science == SCIENCE_INVALID) { + gameMcpFail("unknown science '%s'", name.c_str()); + } + GameMessage *msg = appendOrder(GameMessage::MSG_PURCHASE_SCIENCE, player); + msg->appendIntegerArgument(science); + return McpJson::makeObject().set("queued", true).set("player", gameMcpPlayerName(player)).set("frame", TheGameLogic->getFrame()); +} + +} // namespace + +void gameMcpRegisterOrderCommands() +{ + gameMcpRegisterCommand("orders.move", cmdMove, "{ids, x, y, attack_move?} Moves units to a position."); + gameMcpRegisterCommand("orders.attack", cmdAttack, "{ids, target, force?} | {ids, x, y} Attacks an object, or fires at the ground."); + gameMcpRegisterCommand("orders.stop", cmdStop, "{ids} Stops units."); + gameMcpRegisterCommand("orders.guard", cmdGuard, "{ids, target | x, y, mode=0} Guards an object or position (mode 0 normal, 1 without pursuit, 2 flying units only)."); + gameMcpRegisterCommand("orders.sell", cmdSell, "{ids} Sells structures."); + gameMcpRegisterCommand("orders.build", cmdBuild, "{dozer, template, x, y, angle=0} A dozer or worker builds a structure."); + gameMcpRegisterCommand("orders.produce", cmdProduce, "{factory, template, count=1} Queues units in a production building."); + gameMcpRegisterCommand("orders.upgrade", cmdUpgrade, "{id, upgrade} Queues an upgrade in an object."); + gameMcpRegisterCommand("orders.special_power", cmdSpecialPower, + "{id, power | button, target | x, y, angle?, options?} Uses a special power of an object, at an object, a position or without a target."); + gameMcpRegisterCommand("orders.purchase_science", cmdPurchaseScience, "{player?, science} Buys a general's power with science points."); +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp new file mode 100644 index 00000000000..cb49f6e2e7f --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp @@ -0,0 +1,165 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCmdPlayers.cpp +// Player inspection commands of the MCP bridge. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include "GameMcpCommands.h" + +#include "Common/Energy.h" +#include "Common/Money.h" +#include "Common/Player.h" +#include "Common/PlayerList.h" +#include "Common/PlayerTemplate.h" +#include "Common/Science.h" +#include "Common/ScoreKeeper.h" +#include "Common/Team.h" +#include "Common/ThingTemplate.h" +#include "Common/Upgrade.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Object.h" + +#include + +namespace +{ + +const char *relationshipName(Relationship relationship) +{ + switch (relationship) { + case ENEMIES: return "enemies"; + case ALLIES: return "allies"; + default: return "neutral"; + } +} + +const char *difficultyName(GameDifficulty difficulty) +{ + switch (difficulty) { + case DIFFICULTY_EASY: return "easy"; + case DIFFICULTY_NORMAL: return "normal"; + case DIFFICULTY_HARD: return "hard"; + default: return ""; + } +} + +McpJson describeBrief(Player *player) +{ + McpJson row = McpJson::makeObject(); + row.set("name", gameMcpPlayerName(player)); + row.set("display_name", gameMcpUtf8(player->getPlayerDisplayName())); + row.set("index", player->getPlayerIndex()); + row.set("side", player->getSide().str()); + row.set("faction", player->getPlayerTemplate() ? player->getPlayerTemplate()->getName().str() : ""); + row.set("type", player->getPlayerType() == PLAYER_HUMAN ? "human" : "computer"); + if (player->getPlayerType() == PLAYER_COMPUTER) { + row.set("difficulty", difficultyName(player->getPlayerDifficulty())); + } + row.set("local", player->isLocalPlayer() != FALSE); + row.set("active", player->isPlayerActive() != FALSE); + row.set("money", player->getMoney()->countMoney()); + return row; +} + +McpJson describeFull(Player *player) +{ + McpJson result = describeBrief(player); + result.set("power", McpJson::makeObject().set("production", player->getEnergy()->getProduction()) + .set("consumption", player->getEnergy()->getConsumption())); + result.set("rank", player->getRankLevel()).set("skill_points", player->getSkillPoints()) + .set("science_points", player->getSciencePurchasePoints()); + result.set("instant_build", player->buildsInstantly() != FALSE); + + McpJson sciences = McpJson::makeArray(); + const std::vector scienceNames = TheScienceStore->friend_getScienceNames(); + for (size_t s = 0; s < scienceNames.size(); s++) { + if (player->hasScience(TheScienceStore->getScienceFromInternalName(scienceNames[s]))) { + sciences.push(scienceNames[s].str()); + } + } + result.set("sciences", sciences); + + McpJson upgrades = McpJson::makeArray(); + for (const UpgradeTemplate *upgrade = TheUpgradeCenter->firstUpgradeTemplate(); upgrade; upgrade = upgrade->friend_getNext()) { + if (upgrade->getUpgradeType() == UPGRADE_TYPE_PLAYER && player->hasUpgradeComplete(upgrade)) { + upgrades.push(upgrade->getUpgradeName().str()); + } + } + result.set("upgrades", upgrades); + + ScoreKeeper *score = player->getScoreKeeper(); + result.set("score", McpJson::makeObject().set("units_built", score->getTotalUnitsBuilt()).set("units_lost", score->getTotalUnitsLost()) + .set("units_destroyed", score->getTotalUnitsDestroyed()).set("buildings_built", score->getTotalBuildingsBuilt()) + .set("buildings_lost", score->getTotalBuildingsLost()).set("buildings_destroyed", score->getTotalBuildingsDestroyed()) + .set("money_earned", score->getTotalMoneyEarned()).set("money_spent", score->getTotalMoneySpent())); + + McpJson relations = McpJson::makeObject(); + for (Int i = 0; i < ThePlayerList->getPlayerCount(); i++) { + Player *other = ThePlayerList->getNthPlayer(i); + if (other && other != player && other != ThePlayerList->getNeutralPlayer() && other->getDefaultTeam()) { + relations.set(gameMcpPlayerName(other).c_str(), relationshipName(player->getRelationship(other->getDefaultTeam()))); + } + } + result.set("relationships", relations); + + std::map counts; + for (Object *object = TheGameLogic->getFirstObject(); object; object = object->getNextObject()) { + if (object->getControllingPlayer() == player && !object->isEffectivelyDead()) { + counts[object->getTemplate()->getName().str()]++; + } + } + McpJson objects = McpJson::makeObject(); + for (std::map::const_iterator it = counts.begin(); it != counts.end(); ++it) { + objects.set(it->first.c_str(), it->second); + } + result.set("objects", objects); + return result; +} + +McpJson cmdList(const McpJson &) +{ + gameMcpRequireGame(); + McpJson list = McpJson::makeArray(); + for (Int i = 0; i < ThePlayerList->getPlayerCount(); i++) { + Player *player = ThePlayerList->getNthPlayer(i); + if (player) { + list.push(describeBrief(player)); + } + } + return McpJson::makeObject().set("players", list); +} + +McpJson cmdGet(const McpJson &args) +{ + return describeFull(gameMcpPlayerArg(args, "player")); +} + +} // namespace + +void gameMcpRegisterPlayerCommands() +{ + gameMcpRegisterCommand("players.list", cmdList, "All players: name, side, faction, human or computer, local, active, money."); + gameMcpRegisterCommand("players.get", cmdGet, + "{player?} One player (default: the local one): money, power, rank, sciences, upgrades, score, relationships, objects by type."); +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdScripts.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdScripts.cpp new file mode 100644 index 00000000000..e07986ecfb1 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdScripts.cpp @@ -0,0 +1,111 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCmdScripts.cpp +// Script engine commands of the MCP bridge: run scripts, switch them on and off, read and set +// flags and counters. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include "GameMcpCommands.h" + +#include "GameLogic/GameLogic.h" +#include "GameLogic/ScriptEngine.h" + +namespace +{ + +McpJson cmdRun(const McpJson &args) +{ + gameMcpRequireGame(); + const std::string name = gameMcpArgString(args, "name"); + TheScriptEngine->runScript(name.c_str()); + return McpJson::makeObject().set("ran", name).set("frame", TheGameLogic->getFrame()); +} + +McpJson cmdSetActive(const McpJson &args) +{ + gameMcpRequireGame(); + const std::string name = gameMcpArgString(args, "name"); + const bool active = gameMcpArgBool(args, "active", true); + if (!TheScriptEngine->setScriptActive(name.c_str(), active)) { + gameMcpFail("no script or script group named '%s'", name.c_str()); + } + return McpJson::makeObject().set("name", name).set("active", active); +} + +McpJson cmdFlags(const McpJson &args) +{ + gameMcpRequireGame(); + if (args.has("set")) { + const McpJson &values = args.get("set"); + for (size_t i = 0; i < values.numKeys(); i++) { + if (!values.valueAt(i).isBool()) { + gameMcpFail("flag '%s' must be set to true or false", values.keyAt(i).c_str()); + } + TheScriptEngine->setFlagValue(values.keyAt(i).c_str(), values.valueAt(i).asBool()); + } + } + const std::string contains = gameMcpArgString(args, "contains", ""); + McpJson flags = McpJson::makeObject(); + for (Int i = 1; i < TheScriptEngine->friend_getNumFlags(); i++) { + const TFlag *flag = TheScriptEngine->friend_getNthFlag(i); + if (gameMcpContainsNoCase(flag->name.str(), contains.c_str())) { + flags.set(flag->name.str(), flag->value != FALSE); + } + } + return McpJson::makeObject().set("flags", flags); +} + +McpJson cmdCounters(const McpJson &args) +{ + gameMcpRequireGame(); + if (args.has("set")) { + const McpJson &values = args.get("set"); + for (size_t i = 0; i < values.numKeys(); i++) { + if (!values.valueAt(i).isNumber()) { + gameMcpFail("counter '%s' must be set to a number", values.keyAt(i).c_str()); + } + TheScriptEngine->setCounterValue(values.keyAt(i).c_str(), (Int)values.valueAt(i).asNumber()); + } + } + const std::string contains = gameMcpArgString(args, "contains", ""); + McpJson counters = McpJson::makeObject(); + for (Int i = 1; i < TheScriptEngine->friend_getNumCounters(); i++) { + const TCounter *counter = TheScriptEngine->friend_getNthCounter(i); + if (gameMcpContainsNoCase(counter->name.str(), contains.c_str())) { + counters.set(counter->name.str(), McpJson::makeObject().set("value", counter->value) + .set("timer", counter->isCountdownTimer != FALSE)); + } + } + return McpJson::makeObject().set("counters", counters).set("note", "timers count logic frames (30 per second)"); +} + +} // namespace + +void gameMcpRegisterScriptCommands() +{ + gameMcpRegisterCommand("scripts.run", cmdRun, "{name} Runs a script's actions now (like the RUN_SCRIPT action), whatever its conditions."); + gameMcpRegisterCommand("scripts.set_active", cmdSetActive, "{name, active=true} Enables or disables a script or script group."); + gameMcpRegisterCommand("scripts.flags", cmdFlags, "{contains?, set?: {name: bool}} Script flags (set first, then list)."); + gameMcpRegisterCommand("scripts.counters", cmdCounters, "{contains?, set?: {name: number}} Script counters and timers (set first, then list)."); +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp new file mode 100644 index 00000000000..6c11be7d5c3 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp @@ -0,0 +1,182 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCmdState.cpp +// Game state commands of the MCP bridge: status, pause, frame steps, speed, messages, quit. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include "GameMcpCommands.h" + +#include "Common/FramePacer.h" +#include "Common/GameEngine.h" +#include "Common/GameMcp.h" +#include "Common/GlobalData.h" +#include "Common/Player.h" +#include "Common/PlayerList.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/ScriptEngine.h" + +namespace +{ + +const char *gameModeName(Int mode) +{ + switch (mode) { + case GAME_SINGLE_PLAYER: return "single_player"; + 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"; + default: return "none"; + } +} + +void requirePausable() +{ + gameMcpRequireGame(); + if (TheGameLogic->isInMultiplayerGame()) { + gameMcpFail("a multiplayer game cannot be paused"); + } +} + +McpJson cmdStatus(const McpJson &) +{ + McpJson result = McpJson::makeObject(); + const Bool inGame = TheGameLogic->isInGame() && !TheGameLogic->isInShellGame(); + result.set("in_game", inGame != FALSE); + result.set("loading", TheGameLogic->isLoadingMap() != FALSE); + result.set("mode", gameModeName(TheGameLogic->getGameMode())); + result.set("map", TheGlobalData->m_mapName.str()); + result.set("frame", TheGameLogic->getFrame()); + result.set("seconds", (double)TheGameLogic->getFrame() / LOGICFRAMES_PER_SECOND); + result.set("frames_per_second", (int)LOGICFRAMES_PER_SECOND); + result.set("paused", TheGameLogic->isGamePaused() != FALSE); + result.set("time_frozen_by_script", TheScriptEngine && TheScriptEngine->isTimeFrozenScript()); + result.set("headless", TheGlobalData->m_headless != FALSE); + result.set("sandbox", TheGameMcp->isSandbox() != FALSE); + if (TheGlobalData->m_headless) { + result.set("speed_fps", TheGameMcp->getHeadlessFps()); + } else { + result.set("speed_fps", TheFramePacer->isFramesPerSecondLimitEnabled() ? TheFramePacer->getFramesPerSecondLimit() : 0); + } + result.set("object_count", (unsigned int)(inGame ? TheGameLogic->getObjectCount() : 0)); + if (inGame && ThePlayerList->getLocalPlayer()) { + result.set("local_player", gameMcpPlayerName(ThePlayerList->getLocalPlayer())); + } + if (gameMcpStartError().isNotEmpty()) { + result.set("start_error", gameMcpStartError().str()); + } + return result; +} + +McpJson cmdPause(const McpJson &) +{ + requirePausable(); + TheGameLogic->setGamePaused(TRUE, FALSE, FALSE); + return McpJson::makeObject().set("paused", true).set("frame", TheGameLogic->getFrame()); +} + +McpJson cmdResume(const McpJson &) +{ + requirePausable(); + TheGameLogic->setGamePaused(FALSE, FALSE, FALSE); + return McpJson::makeObject().set("paused", false).set("frame", TheGameLogic->getFrame()); +} + +McpJson cmdStep(const McpJson &args) +{ + requirePausable(); + const Int frames = gameMcpArgInt(args, "frames", 1); + if (frames < 1) { + gameMcpFail("frames must be at least 1"); + } + const UnsignedInt target = TheGameLogic->getFrame() + frames; + TheGameLogic->setGamePaused(FALSE, FALSE, FALSE); + TheGameLogic->setGamePausedInFrame(target, FALSE); + return McpJson::makeObject().set("frame", TheGameLogic->getFrame()).set("pause_at_frame", target); +} + +McpJson cmdSetSpeed(const McpJson &args) +{ + // Logic frames run at most once per render frame, so the frame rate limit is the game speed. + const Int fps = gameMcpArgInt(args, "fps"); + if (fps < 0) { + gameMcpFail("fps must be 0 (as fast as possible) or more"); + } + if (TheGlobalData->m_headless) { + TheGameMcp->setHeadlessFps(fps); + } else { + TheFramePacer->enableLogicTimeScale(FALSE); + if (fps == 0) { + TheFramePacer->enableFramesPerSecondLimit(FALSE); + } else { + TheFramePacer->enableFramesPerSecondLimit(TRUE); + TheFramePacer->setFramesPerSecondLimit(fps); + } + } + return McpJson::makeObject().set("speed_fps", fps).set("normal_fps", (int)LOGICFRAMES_PER_SECOND); +} + +McpJson cmdMessages(const McpJson &args) +{ + const UnsignedInt since = (UnsignedInt)gameMcpArgInt(args, "since", 0); + const std::string contains = gameMcpArgString(args, "contains", ""); + const Int limit = gameMcpArgInt(args, "limit", 200); + McpJson list = McpJson::makeArray(); + const std::deque &messages = TheGameMcp->getMessages(); + for (std::deque::const_iterator it = messages.begin(); it != messages.end(); ++it) { + if (it->serial <= since || !gameMcpContainsNoCase(it->text.c_str(), contains.c_str())) { + continue; + } + list.push(McpJson::makeObject().set("serial", it->serial).set("frame", it->frame).set("text", it->text)); + if ((Int)list.size() >= limit) { + break; + } + } + return McpJson::makeObject().set("messages", list).set("next_serial", TheGameMcp->getNextMessageSerial()); +} + +McpJson cmdQuit(const McpJson &) +{ + // Leave as the main menu's quit button does. Without the client there is nothing to tell. + TheGameEngine->setQuitting(TRUE); + if (TheGameLogic->isInGame() && !TheGlobalData->m_headless) { + TheGameLogic->exitGame(); + } + return McpJson::makeObject().set("quitting", true); +} + +} // namespace + +void gameMcpRegisterStateCommands() +{ + gameMcpRegisterCommand("game.status", cmdStatus, + "In game, mode, map, frame (30 per second), paused, speed, headless, sandbox, local player, start_error."); + gameMcpRegisterCommand("game.pause", cmdPause, "Pauses the game logic (the window keeps drawing)."); + gameMcpRegisterCommand("game.resume", cmdResume, "Lets the game logic run again."); + gameMcpRegisterCommand("game.step", cmdStep, "{frames=1} Runs that many logic frames, then pauses."); + gameMcpRegisterCommand("game.set_speed", cmdSetSpeed, "{fps} Logic frames per second: 30 is normal speed, 0 as fast as possible."); + gameMcpRegisterCommand("game.messages", cmdMessages, "{since?, contains?, limit?} What the script engine and the AI reported (serial > since)."); + gameMcpRegisterCommand("game.quit", cmdQuit, "Ends the game and closes it."); +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdView.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdView.cpp new file mode 100644 index 00000000000..9a697ac0cbd --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdView.cpp @@ -0,0 +1,105 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCmdView.cpp +// Camera and screenshot commands of the MCP bridge. They need the window, so they fail headless. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include "GameMcpCommands.h" + +#include "Common/GameMcp.h" +#include "Common/GlobalData.h" +#include "GameClient/Display.h" +#include "GameClient/View.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Object.h" + +namespace +{ + +void requireWindow() +{ + if (TheGlobalData->m_headless || TheTacticalView == nullptr) { + gameMcpFail("the game runs headless: there is no view (start it without -headless)"); + } +} + +McpJson describeCamera() +{ + return McpJson::makeObject().set("position", gameMcpCoord(TheTacticalView->getPosition())) + .set("angle", TheTacticalView->getAngle() * 180.0 / PI).set("pitch", TheTacticalView->getPitch() * 180.0 / PI) + .set("zoom", TheTacticalView->getZoom()); +} + +McpJson cmdCamera(const McpJson &args) +{ + requireWindow(); + if (args.has("id")) { + const Object *object = gameMcpRequireObject(gameMcpArgInt(args, "id")); + TheTacticalView->lookAt(object->getPosition()); + } else if (args.has("x") || args.has("y")) { + Coord3D pos = TheTacticalView->getPosition(); + pos.x = (Real)gameMcpArgNumber(args, "x", pos.x); + pos.y = (Real)gameMcpArgNumber(args, "y", pos.y); + TheTacticalView->lookAt(&pos); + } + if (args.has("angle")) { + TheTacticalView->setAngle((Real)(gameMcpArgNumber(args, "angle") * PI / 180.0)); + } + if (args.has("pitch")) { + TheTacticalView->setPitch((Real)(gameMcpArgNumber(args, "pitch") * PI / 180.0)); + } + if (args.has("zoom")) { + TheTacticalView->setZoom((Real)gameMcpArgNumber(args, "zoom")); + } + if (gameMcpArgBool(args, "reset", false)) { + TheTacticalView->setAngleToDefault(); + TheTacticalView->setPitchToDefault(); + TheTacticalView->setZoomToDefault(); + } + return describeCamera(); +} + +McpJson cmdScreenshot(const McpJson &args) +{ + requireWindow(); + if (TheGameMcpScreenshot == nullptr) { + gameMcpFail("the display cannot take screenshots"); + } + const std::string path = gameMcpArgString(args, "path"); + const bool png = path.size() > 4 && _stricmp(path.c_str() + path.size() - 4, ".png") == 0; + // What is on the back buffer is the last frame drawn. + if (!TheGameMcpScreenshot(path.c_str(), png ? SCREENSHOT_PNG : SCREENSHOT_JPEG, gameMcpArgInt(args, "quality", 85))) { + gameMcpFail("could not write the screenshot to '%s'", path.c_str()); + } + return McpJson::makeObject().set("path", path).set("frame", TheGameLogic->getFrame()).set("camera", describeCamera()); +} + +} // namespace + +void gameMcpRegisterViewCommands() +{ + gameMcpRegisterCommand("view.camera", cmdCamera, + "{id? | x?, y?, angle?, pitch?, zoom?, reset?} Moves the camera (to an object or a position) and returns where it is."); + gameMcpRegisterCommand("view.screenshot", cmdScreenshot, "{path, quality=85} Writes what the window shows to a .jpg or .png file."); +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCommands.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCommands.cpp new file mode 100644 index 00000000000..0e5c72bf9ee --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCommands.cpp @@ -0,0 +1,368 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCommands.cpp +// Command table and shared helpers of the game's MCP bridge. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include "GameMcpCommands.h" + +#include "Common/NameKeyGenerator.h" +#include "Common/Player.h" +#include "Common/PlayerList.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Object.h" +#include "GameLogic/TerrainLogic.h" + +#include +#include + +namespace +{ + +struct CommandEntry +{ + GameMcpHandler handler; + std::string help; +}; + +typedef std::map CommandTable; + +CommandTable &commandTable() +{ + static CommandTable table; + return table; +} + +McpJson cmdListCommands(const McpJson &) +{ + McpJson list = McpJson::makeArray(); + const CommandTable &table = commandTable(); + for (CommandTable::const_iterator it = table.begin(); it != table.end(); ++it) { + list.push(McpJson::makeObject().set("name", it->first).set("help", it->second.help)); + } + return McpJson::makeObject().set("commands", list); +} + +/// Where the game runs, so that clients can find its files. +McpJson cmdAppInfo(const McpJson &) +{ + char exePath[MAX_PATH]; + exePath[0] = 0; + ::GetModuleFileNameA(nullptr, exePath, sizeof(exePath)); + std::string exeDir = exePath; + const size_t slash = exeDir.find_last_of("\\/"); + if (slash != std::string::npos) exeDir.erase(slash); + char workDir[MAX_PATH]; + workDir[0] = 0; + ::GetCurrentDirectoryA(sizeof(workDir), workDir); + return McpJson::makeObject().set("exe_path", exePath).set("exe_dir", exeDir).set("working_dir", workDir) + .set("process_id", (unsigned int)::GetCurrentProcessId()); +} + +void registerAllCommands() +{ + static bool registered = false; + if (registered) { + return; + } + registered = true; + gameMcpRegisterCommand("list_commands", cmdListCommands, "Lists all bridge commands."); + gameMcpRegisterCommand("app.info", cmdAppInfo, "Path and folder of the game executable, its working directory and process id."); + gameMcpRegisterStateCommands(); + gameMcpRegisterObjectCommands(); + gameMcpRegisterPlayerCommands(); + gameMcpRegisterOrderCommands(); + gameMcpRegisterCheatCommands(); + gameMcpRegisterScriptCommands(); + gameMcpRegisterViewCommands(); +} + +} // namespace + +//------------------------------------------------------------------------------------------------- +void gameMcpRegisterCommand(const char *name, GameMcpHandler handler, const char *help) +{ + CommandEntry entry; + entry.handler = handler; + entry.help = help ? help : ""; + commandTable()[name] = entry; +} + +//------------------------------------------------------------------------------------------------- +std::string gameMcpDispatch(const std::string &requestLine) +{ + registerAllCommands(); + + McpJson reply = McpJson::makeObject(); + McpJson request; + std::string parseError; + if (!McpJson::parse(requestLine, request, parseError) || !request.isObject()) { + reply.set("id", McpJson()).set("ok", false).set("error", "invalid request: " + parseError); + return reply.dump(); + } + reply.set("id", request.get("id")); + + const std::string cmd = request.get("cmd").asString(); + const CommandTable &table = commandTable(); + CommandTable::const_iterator it = table.find(cmd); + if (it == table.end()) { + reply.set("ok", false).set("error", "unknown command '" + cmd + "' (see list_commands)"); + return reply.dump(); + } + + McpJson args = request.get("args"); + if (args.isNull()) { + args = McpJson::makeObject(); + } + try { + McpJson result = it->second.handler(args); + reply.set("ok", true).set("result", result); + } catch (const GameMcpError &e) { + reply.set("ok", false).set("error", e.message); + } + return reply.dump(); +} + +//------------------------------------------------------------------------------------------------- +void gameMcpFail(const char *fmt, ...) +{ + char buffer[2048]; + va_list args; + va_start(args, fmt); + vsnprintf(buffer, sizeof(buffer), fmt, args); + va_end(args); + buffer[sizeof(buffer) - 1] = 0; + GameMcpError error; + error.message = buffer; + throw error; +} + +//------------------------------------------------------------------------------------------------- +double gameMcpArgNumber(const McpJson &args, const char *key) +{ + const McpJson &value = args.get(key); + if (!value.isNumber()) { + gameMcpFail("argument '%s' must be a number", key); + } + return value.asNumber(); +} + +double gameMcpArgNumber(const McpJson &args, const char *key, double defaultValue) +{ + return args.has(key) && !args.get(key).isNull() ? gameMcpArgNumber(args, key) : defaultValue; +} + +int gameMcpArgInt(const McpJson &args, const char *key) +{ + return (int)floor(gameMcpArgNumber(args, key) + 0.5); +} + +int gameMcpArgInt(const McpJson &args, const char *key, int defaultValue) +{ + return args.has(key) && !args.get(key).isNull() ? gameMcpArgInt(args, key) : defaultValue; +} + +bool gameMcpArgBool(const McpJson &args, const char *key, bool defaultValue) +{ + const McpJson &value = args.get(key); + if (value.isNull()) { + return defaultValue; + } + if (!value.isBool()) { + gameMcpFail("argument '%s' must be true or false", key); + } + return value.asBool(); +} + +std::string gameMcpArgString(const McpJson &args, const char *key) +{ + const McpJson &value = args.get(key); + if (!value.isString()) { + gameMcpFail("argument '%s' must be a string", key); + } + return value.asString(); +} + +std::string gameMcpArgString(const McpJson &args, const char *key, const char *defaultValue) +{ + return args.has(key) && !args.get(key).isNull() ? gameMcpArgString(args, key) : std::string(defaultValue); +} + +//------------------------------------------------------------------------------------------------- +void gameMcpRequireGame() +{ + if (TheGameLogic == nullptr || !TheGameLogic->isInGame() || TheGameLogic->isInShellGame()) { + gameMcpFail("no game is running (start one with -mcpGame, or wait until it has loaded; see game.status)"); + } +} + +//------------------------------------------------------------------------------------------------- +Object *gameMcpRequireObject(int id) +{ + gameMcpRequireGame(); + Object *object = TheGameLogic->findObjectByID((ObjectID)id); + if (object == nullptr) { + gameMcpFail("no object with id %d (it may have been destroyed; see objects.list)", id); + } + return object; +} + +//------------------------------------------------------------------------------------------------- +void gameMcpRequireObjects(const McpJson &args, std::vector &objects) +{ + objects.clear(); + const McpJson &ids = args.get("ids"); + if (ids.isArray()) { + for (size_t i = 0; i < ids.size(); i++) { + if (!ids.at(i).isNumber()) { + gameMcpFail("'ids' must hold object ids (numbers)"); + } + objects.push_back(gameMcpRequireObject((int)ids.at(i).asNumber())); + } + } else if (ids.isNumber()) { + objects.push_back(gameMcpRequireObject((int)ids.asNumber())); + } else if (args.get("id").isNumber()) { + objects.push_back(gameMcpRequireObject(gameMcpArgInt(args, "id"))); + } + if (objects.empty()) { + gameMcpFail("give the objects as 'ids' (a list of object ids) or 'id'"); + } +} + +//------------------------------------------------------------------------------------------------- +std::string gameMcpPlayerName(const Player *player) +{ + if (player == nullptr) { + return std::string(); + } + return TheNameKeyGenerator->keyToName(player->getPlayerNameKey()).str(); +} + +//------------------------------------------------------------------------------------------------- +Player *gameMcpRequirePlayer(const std::string &name) +{ + gameMcpRequireGame(); + if (name.empty() || _stricmp(name.c_str(), "local") == 0) { + return ThePlayerList->getLocalPlayer(); + } + if (_stricmp(name.c_str(), "neutral") == 0) { + return ThePlayerList->getNeutralPlayer(); + } + std::string names; + for (Int i = 0; i < ThePlayerList->getPlayerCount(); i++) { + Player *player = ThePlayerList->getNthPlayer(i); + if (player == nullptr) { + continue; + } + const std::string internal = gameMcpPlayerName(player); + if (_stricmp(internal.c_str(), name.c_str()) == 0 || _stricmp(gameMcpUtf8(player->getPlayerDisplayName()).c_str(), name.c_str()) == 0) { + return player; + } + names += names.empty() ? "" : ", "; + names += internal; + } + char *end = nullptr; + const long index = strtol(name.c_str(), &end, 10); + if (end != name.c_str() && *end == 0 && index >= 0 && index < ThePlayerList->getPlayerCount()) { + return ThePlayerList->getNthPlayer((Int)index); + } + gameMcpFail("unknown player '%s' (players: %s; or 'local', 'neutral', an index)", name.c_str(), names.c_str()); +} + +//------------------------------------------------------------------------------------------------- +Player *gameMcpPlayerArg(const McpJson &args, const char *key) +{ + return gameMcpRequirePlayer(gameMcpArgString(args, key, "")); +} + +//------------------------------------------------------------------------------------------------- +const ThingTemplate *gameMcpRequireTemplate(const std::string &name) +{ + const ThingTemplate *tmpl = TheThingFactory->findTemplate(name.c_str()); + if (tmpl == nullptr) { + std::string similar; + Int count = 0; + for (const ThingTemplate *t = TheThingFactory->firstTemplate(); t && count < 10; t = t->friend_getNextTemplate()) { + if (gameMcpContainsNoCase(t->getName().str(), name.c_str())) { + similar += similar.empty() ? "" : ", "; + similar += t->getName().str(); + count++; + } + } + gameMcpFail("unknown object template '%s'%s%s", name.c_str(), similar.empty() ? "" : "; similar: ", similar.c_str()); + } + return tmpl; +} + +//------------------------------------------------------------------------------------------------- +bool gameMcpContainsNoCase(const char *haystack, const char *needle) +{ + if (needle == nullptr || *needle == 0) { + return true; + } + if (haystack == nullptr) { + return false; + } + const size_t needleLen = strlen(needle); + for (const char *h = haystack; *h; h++) { + if (_strnicmp(h, needle, needleLen) == 0) { + return true; + } + } + return false; +} + +//------------------------------------------------------------------------------------------------- +std::string gameMcpUtf8(const UnicodeString &text) +{ + const WideChar *wide = text.str(); + if (wide == nullptr || *wide == 0) { + return std::string(); + } + const int bytes = ::WideCharToMultiByte(CP_UTF8, 0, wide, -1, nullptr, 0, nullptr, nullptr); + if (bytes <= 1) { + return std::string(); + } + std::string out(bytes - 1, '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, wide, -1, &out[0], bytes, nullptr, nullptr); + return out; +} + +//------------------------------------------------------------------------------------------------- +McpJson gameMcpCoord(const Coord3D &pos) +{ + return McpJson::makeObject().set("x", pos.x).set("y", pos.y).set("z", pos.z); +} + +//------------------------------------------------------------------------------------------------- +Coord3D gameMcpArgPosition(const McpJson &args) +{ + Coord3D pos; + pos.x = (Real)gameMcpArgNumber(args, "x"); + pos.y = (Real)gameMcpArgNumber(args, "y"); + pos.z = args.has("z") ? (Real)gameMcpArgNumber(args, "z") : TheTerrainLogic->getGroundHeight(pos.x, pos.y); + return pos; +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCommands.h b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCommands.h new file mode 100644 index 00000000000..a1148f01858 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCommands.h @@ -0,0 +1,137 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpCommands.h +// Command table, transport and shared helpers of the game's MCP bridge. Handlers run on the main +// thread between logic frames. + +#pragma once + +#include "Common/McpJson.h" +#include "Common/AsciiString.h" + +#include +#include + +class Object; +class Player; +class ThingTemplate; + +/// Thrown by command handlers to report an error to the client. +struct GameMcpError +{ + std::string message; +}; + +typedef McpJson (*GameMcpHandler)(const McpJson &args); + +void gameMcpRegisterCommand(const char *name, GameMcpHandler handler, const char *help); + +/// Parses one request line, runs the command and returns the reply line (without newline). +std::string gameMcpDispatch(const std::string &requestLine); + +// Per-area registration, called once from gameMcpDispatch. +void gameMcpRegisterStateCommands(); +void gameMcpRegisterObjectCommands(); +void gameMcpRegisterPlayerCommands(); +void gameMcpRegisterOrderCommands(); +void gameMcpRegisterCheatCommands(); +void gameMcpRegisterScriptCommands(); +void gameMcpRegisterViewCommands(); + +/// Why the game asked for with -mcpGame did not start; empty if it did (GameMcp.cpp). +const AsciiString &gameMcpStartError(); + +// Transport (GameMcpServer.cpp). +bool gameMcpServerStart(int port); +void gameMcpServerStop(); +/// Runs every queued request through gameMcpDispatch and hands the replies back. +void gameMcpServerDrain(); + +//------------------------------------------------------------------------------------------------- +// Helpers +//------------------------------------------------------------------------------------------------- + +[[noreturn]] void gameMcpFail(const char *fmt, ...); + +double gameMcpArgNumber(const McpJson &args, const char *key); +double gameMcpArgNumber(const McpJson &args, const char *key, double defaultValue); +int gameMcpArgInt(const McpJson &args, const char *key); +int gameMcpArgInt(const McpJson &args, const char *key, int defaultValue); +bool gameMcpArgBool(const McpJson &args, const char *key, bool defaultValue); +std::string gameMcpArgString(const McpJson &args, const char *key); +std::string gameMcpArgString(const McpJson &args, const char *key, const char *defaultValue); + +/// Fails unless a game is running. +void gameMcpRequireGame(); + +/// Object by id; fails if there is none. +Object *gameMcpRequireObject(int id); +/// Object ids from "ids" (array) or "id" (number); fails on unknown ids or when none are given. +void gameMcpRequireObjects(const McpJson &args, std::vector &objects); + +/// Player by internal name ("player0", "SkirmishChina"), display name or index; fails if unknown. +Player *gameMcpRequirePlayer(const std::string &name); +/// The player named by args[key], or the local player when the key is missing. +Player *gameMcpPlayerArg(const McpJson &args, const char *key); +std::string gameMcpPlayerName(const Player *player); + +/// Thing template by name; fails with a hint if unknown. +const ThingTemplate *gameMcpRequireTemplate(const std::string &name); + +/// Case-insensitive substring test used by list filters. +bool gameMcpContainsNoCase(const char *haystack, const char *needle); + +/// Unicode text as UTF-8. +std::string gameMcpUtf8(const UnicodeString &text); + +McpJson gameMcpCoord(const Coord3D &pos); +/// World position from args x, y (and optional z; default: the terrain height there). +Coord3D gameMcpArgPosition(const McpJson &args); + +/// Names of the bits set in a BitFlags value. +template +McpJson gameMcpFlagNames(const FLAGS &flags) +{ + McpJson names = McpJson::makeArray(); + for (Int i = 0; i < flags.size(); i++) { + if (flags.test(i)) { + const char *name = FLAGS::getNameFromSingleBit(i); + names.push(name ? name : "?"); + } + } + return names; +} + +/// Bit index of a BitFlags bit name (case-insensitive); fails with the valid names if unknown. +template +Int gameMcpRequireFlag(const std::string &name, const char *what) +{ + const char *const *names = FLAGS::getBitNames(); + std::string all; + for (Int i = 0; names[i] != nullptr; i++) { + if (_stricmp(names[i], name.c_str()) == 0) { + return i; + } + if (all.size() < 1500) { + all += all.empty() ? "" : ", "; + all += names[i]; + } + } + gameMcpFail("unknown %s '%s' (known: %s)", what, name.c_str(), all.c_str()); +} diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpServer.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpServer.cpp new file mode 100644 index 00000000000..2ffdd715333 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpServer.cpp @@ -0,0 +1,279 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** 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 . +*/ + +// GameMcpServer.cpp +// Localhost TCP transport of the game's MCP bridge. A socket thread reads request lines and queues +// them; the main thread runs them in gameMcpServerDrain() and the socket thread sends the replies. +// Kept in its own file because must not meet the of the network code. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#ifdef RTS_GAME_MCP + +#include +#include + +#include "GameMcpCommands.h" + +#include +#include + +namespace +{ + +// Long enough for a map to load while a command waits behind it. +const DWORD COMMAND_TIMEOUT_MS = 300000; +const size_t MAX_LINE_LENGTH = 16 * 1024 * 1024; + +struct Request +{ + Request() : done(::CreateEvent(nullptr, TRUE, FALSE, nullptr)) {} + ~Request() { ::CloseHandle(done); } + + std::string line; + std::string reply; + HANDLE done; +}; + +typedef std::shared_ptr RequestPtr; + +CRITICAL_SECTION s_queueLock; +bool s_queueLockInited = false; +std::deque s_queue; + +HANDLE s_thread = nullptr; +HANDLE s_stopEvent = nullptr; +volatile LONG s_stopping = 0; +SOCKET s_listenSocket = INVALID_SOCKET; +bool s_inDrain = false; + +RequestPtr popRequest() +{ + RequestPtr req; + ::EnterCriticalSection(&s_queueLock); + if (!s_queue.empty()) { + req = s_queue.front(); + s_queue.pop_front(); + } + ::LeaveCriticalSection(&s_queueLock); + return req; +} + +bool sendAll(SOCKET sock, const std::string &data) +{ + size_t sent = 0; + while (sent < data.size()) { + int n = ::send(sock, data.c_str() + sent, (int)(data.size() - sent), 0); + if (n <= 0) { + return false; + } + sent += n; + } + return true; +} + +std::string timeoutReply(const std::string &line) +{ + McpJson request; + std::string err; + McpJson id; + if (McpJson::parse(line, request, err)) { + id = request.get("id"); + } + McpJson reply = McpJson::makeObject(); + reply.set("id", id).set("ok", false).set("error", "timed out waiting for the game to run the command"); + return reply.dump(); +} + +/// Queues one request line for the main thread and waits for its reply. +std::string runOnMainThread(const std::string &line) +{ + RequestPtr req(new Request); + req->line = line; + ::EnterCriticalSection(&s_queueLock); + s_queue.push_back(req); + ::LeaveCriticalSection(&s_queueLock); + + HANDLE handles[2] = { req->done, s_stopEvent }; + DWORD result = ::WaitForMultipleObjects(2, handles, FALSE, COMMAND_TIMEOUT_MS); + if (result == WAIT_OBJECT_0) { + return req->reply; + } + return timeoutReply(line); +} + +void serveClient(SOCKET client) +{ + std::string buffer; + char chunk[8192]; + while (!s_stopping) { + fd_set readSet; + FD_ZERO(&readSet); + FD_SET(client, &readSet); + timeval tv = { 0, 250000 }; + int ready = ::select(0, &readSet, nullptr, nullptr, &tv); + if (ready < 0) { + return; + } + if (ready == 0) { + continue; + } + int n = ::recv(client, chunk, sizeof(chunk), 0); + if (n <= 0) { + return; + } + buffer.append(chunk, n); + if (buffer.size() > MAX_LINE_LENGTH) { + return; + } + size_t newline; + while ((newline = buffer.find('\n')) != std::string::npos) { + std::string line = buffer.substr(0, newline); + buffer.erase(0, newline + 1); + if (!line.empty() && line[line.size() - 1] == '\r') { + line.erase(line.size() - 1); + } + if (line.empty()) { + continue; + } + std::string reply = runOnMainThread(line); + reply += '\n'; + if (!sendAll(client, reply)) { + return; + } + } + } +} + +unsigned __stdcall serverThread(void *) +{ + while (!s_stopping) { + fd_set readSet; + FD_ZERO(&readSet); + FD_SET(s_listenSocket, &readSet); + timeval tv = { 0, 250000 }; + int ready = ::select(0, &readSet, nullptr, nullptr, &tv); + if (ready <= 0) { + continue; + } + SOCKET client = ::accept(s_listenSocket, nullptr, nullptr); + if (client == INVALID_SOCKET) { + continue; + } + serveClient(client); + ::closesocket(client); + } + return 0; +} + +} // namespace + +//------------------------------------------------------------------------------------------------- +bool gameMcpServerStart(int port) +{ + if (s_thread != nullptr) { + return true; + } + if (!s_queueLockInited) { + ::InitializeCriticalSection(&s_queueLock); + s_queueLockInited = true; + } + + WSADATA wsaData; + if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { + DEBUG_LOG(("Game MCP bridge: WSAStartup failed")); + return false; + } + + s_listenSocket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons((u_short)port); + // Loopback only: the bridge can change the game at will, so it must never be reachable from the network. + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (s_listenSocket == INVALID_SOCKET + || ::bind(s_listenSocket, (sockaddr *)&addr, sizeof(addr)) != 0 + || ::listen(s_listenSocket, 1) != 0) { + DEBUG_LOG(("Game MCP bridge: could not listen on 127.0.0.1:%d (error %d)", port, ::WSAGetLastError())); + if (s_listenSocket != INVALID_SOCKET) { + ::closesocket(s_listenSocket); + s_listenSocket = INVALID_SOCKET; + } + ::WSACleanup(); + return false; + } + + s_stopping = 0; + s_stopEvent = ::CreateEvent(nullptr, TRUE, FALSE, nullptr); + s_thread = (HANDLE)_beginthreadex(nullptr, 0, serverThread, nullptr, 0, nullptr); + if (s_thread == nullptr) { + gameMcpServerStop(); + return false; + } + DEBUG_LOG(("Game MCP bridge: listening on 127.0.0.1:%d", port)); + return true; +} + +//------------------------------------------------------------------------------------------------- +void gameMcpServerStop() +{ + if (s_listenSocket == INVALID_SOCKET) { + return; + } + ::InterlockedExchange(&s_stopping, 1); + if (s_stopEvent != nullptr) { + ::SetEvent(s_stopEvent); + } + if (s_thread != nullptr) { + ::WaitForSingleObject(s_thread, 5000); + ::CloseHandle(s_thread); + s_thread = nullptr; + } + ::closesocket(s_listenSocket); + s_listenSocket = INVALID_SOCKET; + ::WSACleanup(); + if (s_stopEvent != nullptr) { + ::CloseHandle(s_stopEvent); + s_stopEvent = nullptr; + } + ::EnterCriticalSection(&s_queueLock); + s_queue.clear(); + ::LeaveCriticalSection(&s_queueLock); +} + +//------------------------------------------------------------------------------------------------- +void gameMcpServerDrain() +{ + // A command can run the engine (e.g. while a map loads), which polls again; don't recurse. + if (s_inDrain || !s_queueLockInited) { + return; + } + s_inDrain = true; + for (;;) { + RequestPtr req = popRequest(); + if (!req) { + break; + } + req->reply = gameMcpDispatch(req->line); + ::SetEvent(req->done); + } + s_inDrain = false; +} + +#endif // RTS_GAME_MCP diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptEngine.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptEngine.cpp index cd136a08520..9ecdedb87cc 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptEngine.cpp @@ -54,6 +54,7 @@ #include "GameLogic/PartitionManager.h" #include "GameLogic/ScriptActions.h" #include "GameLogic/ScriptConditions.h" +#include "Common/GameMcp.h" #include "GameLogic/AIMatch.h" #include "GameLogic/ScriptEngine.h" #include "GameLogic/SidesList.h" @@ -6179,6 +6180,59 @@ const TCounter *ScriptEngine::getCounter(const AsciiString& counterName) return nullptr; } +//------------------------------------------------------------------------------------------------- +const TFlag *ScriptEngine::getFlag(const AsciiString& flagName) +{ + // Note - flags start at 1. 0 means not assigned. + for (Int i=1; i 0) + { + m_flags[flagNdx].value = value; + } +} + +//------------------------------------------------------------------------------------------------- +void ScriptEngine::setCounterValue(const AsciiString& counterName, Int value) +{ + const Int counterNdx = allocateCounter(counterName); + if (counterNdx > 0) + { + m_counters[counterNdx].value = value; + } +} + +//------------------------------------------------------------------------------------------------- +Bool ScriptEngine::setScriptActive(const AsciiString& name, Bool active) +{ + Bool found = FALSE; + ScriptGroup *pGroup = findGroup(name); + if (pGroup) + { + pGroup->setActive(active); + found = TRUE; + } + Script *pScript = findScript(name); + if (pScript) + { + pScript->setActive(active); + found = TRUE; + } + return found; +} + //------------------------------------------------------------------------------------------------- void ScriptEngine::createNamedMapReveal(const AsciiString& revealName, const AsciiString& waypointName, Real radiusToReveal, const AsciiString& playerName) { @@ -8519,6 +8573,11 @@ void ScriptEngine::AppendDebugMessage(const AsciiString& strToAdd, Bool forcePau if (TheAIMatch) { TheAIMatch->scriptMessage(strToAdd); } +#ifdef RTS_GAME_MCP + if (TheGameMcp) { + TheGameMcp->scriptMessage(strToAdd); + } +#endif #ifdef INTENSE_DEBUG DEBUG_LOG(("-SCRIPT- %d %s", TheGameLogic->getFrame(), strToAdd.str())); #endif diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/VictoryConditions.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/VictoryConditions.cpp index ab34ddf756a..cd0b7df9bf7 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/VictoryConditions.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/VictoryConditions.cpp @@ -32,6 +32,7 @@ #include "Common/GameAudio.h" #include "Common/GameCommon.h" #include "Common/GameEngine.h" +#include "Common/GameMcp.h" #include "Common/GameUtility.h" #include "Common/KindOf.h" #include "Common/PlayerList.h" @@ -180,6 +181,12 @@ void VictoryConditions::update() if (!TheRecorder->isMultiplayer() || (m_localSlotNum < 0 && !m_isObserver)) return; +#ifdef RTS_GAME_MCP + // A sandbox of the MCP bridge has a single player, who would win at once. + if (TheGameMcp && TheGameMcp->isSandbox()) + return; +#endif + // Check for a single winning alliance if (!m_singleAllianceRemaining) { diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp index 155e8ac43ec..7575ccf15a5 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp @@ -107,6 +107,7 @@ static void drawFramerateBar(); #include "GameLogic/GameLogic.h" #ifdef DUMP_PERF_STATS #include "GameLogic/PartitionManager.h" +#include "Common/GameMcp.h" #endif #include "WinMain.h" @@ -735,6 +736,10 @@ void W3DDisplay::init2DScene() //============================================================================= void W3DDisplay::init() { +#ifdef RTS_GAME_MCP + TheGameMcpScreenshot = W3D_WriteScreenshotFile; +#endif + // // call our base class init, this method should be able to handle re-entry diff --git a/cmake/config-build.cmake b/cmake/config-build.cmake index 7973f5a1f03..9884f1ca545 100644 --- a/cmake/config-build.cmake +++ b/cmake/config-build.cmake @@ -9,6 +9,7 @@ option(RTS_BUILD_OPTION_DEBUG "Build code with the \"Debug\" configuration." OFF option(RTS_BUILD_OPTION_ASAN "Build code with Address Sanitizer." OFF) option(RTS_BUILD_OPTION_VC6_FULL_DEBUG "Build VC6 with full debug info." OFF) option(RTS_BUILD_OPTION_FFMPEG "Enable FFmpeg support" OFF) +option(RTS_BUILD_OPTION_GAME_MCP "Build the game with the MCP bridge that tools can drive it through (-mcp)." ON) if(NOT RTS_BUILD_ZEROHOUR AND NOT RTS_BUILD_GENERALS) set(RTS_BUILD_ZEROHOUR TRUE) @@ -24,6 +25,7 @@ add_feature_info(DebugBuild RTS_BUILD_OPTION_DEBUG "Building as a \"Debug\" buil add_feature_info(AddressSanitizer RTS_BUILD_OPTION_ASAN "Building with address sanitizer") add_feature_info(Vc6FullDebug RTS_BUILD_OPTION_VC6_FULL_DEBUG "Building VC6 with full debug info") add_feature_info(FFmpegSupport RTS_BUILD_OPTION_FFMPEG "Building with FFmpeg support") +add_feature_info(GameMcp RTS_BUILD_OPTION_GAME_MCP "Building the game with the MCP bridge") set(RTS_BUILD_OUTPUT_SUFFIX "" CACHE STRING "Suffix appended to output names of installable targets") @@ -77,6 +79,10 @@ if(RTS_BUILD_OPTION_PROFILE) target_compile_definitions(core_config INTERFACE RTS_PROFILE_LEGACY) endif() +if(RTS_BUILD_OPTION_GAME_MCP) + target_compile_definitions(core_config INTERFACE RTS_GAME_MCP) +endif() + # Define a dummy Tracy target when the build option is disabled. if(RTS_BUILD_OPTION_PROFILE_TRACY) include(cmake/tracy.cmake) From 4adb93a7cf04655e6a839c84a4255fbec06d0471 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 15:21:03 +0200 Subject: [PATCH 07/13] feat(mcp): Adds the game MCP server, smoke test and docs game_server.py exposes the in-game bridge as MCP tools (launch, time, objects, players, orders, test changes, scripts, view) and is registered in .mcp.json as "game". The bridge client is shared with WorldBuilder. Co-Authored-By: Claude Opus 5.5 --- .mcp.json | 4 + CLAUDE.md | 21 +- scripts/worldbuilder-mcp/README.md | 53 +++ scripts/worldbuilder-mcp/game_server.py | 445 ++++++++++++++++++++ scripts/worldbuilder-mcp/game_smoke_test.py | 156 +++++++ scripts/worldbuilder-mcp/wb_bridge.py | 28 +- 6 files changed, 694 insertions(+), 13 deletions(-) create mode 100644 scripts/worldbuilder-mcp/game_server.py create mode 100644 scripts/worldbuilder-mcp/game_smoke_test.py diff --git a/.mcp.json b/.mcp.json index 7a616be850e..2e5c37e1b7f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,6 +3,10 @@ "worldbuilder": { "command": "uv", "args": ["run", "--directory", "scripts/worldbuilder-mcp", "server.py"] + }, + "game": { + "command": "uv", + "args": ["run", "--directory", "scripts/worldbuilder-mcp", "game_server.py"] } } } diff --git a/CLAUDE.md b/CLAUDE.md index 5bfb44be414..5544eb91257 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Zero Hour targets are prefixed `z_` (Generals uses `g_`): ### Key build options (`cmake/config-build.cmake`) -`RTS_BUILD_ZEROHOUR` (default ON), `RTS_BUILD_GENERALS` (turn OFF to skip original Generals), `RTS_BUILD_OPTION_DEBUG`, `RTS_BUILD_OPTION_PROFILE`, `RTS_DEBUG_LOGGING`, `RTS_DEBUG_CRASHING`, `RTS_DEBUG_STACKTRACE`, `RTS_BUILD_OPTION_FFMPEG`. Debug defines: `RTS_DEBUG WWDEBUG DEBUG`; Release: `RTS_RELEASE NDEBUG`. +`RTS_BUILD_ZEROHOUR` (default ON), `RTS_BUILD_GENERALS` (turn OFF to skip original Generals), `RTS_BUILD_OPTION_DEBUG`, `RTS_BUILD_OPTION_PROFILE`, `RTS_DEBUG_LOGGING`, `RTS_DEBUG_CRASHING`, `RTS_DEBUG_STACKTRACE`, `RTS_BUILD_OPTION_FFMPEG`, `RTS_BUILD_OPTION_GAME_MCP` (default ON; the game MCP bridge, see below). Debug defines: `RTS_DEBUG WWDEBUG DEBUG`; Release: `RTS_RELEASE NDEBUG`. ## Architecture @@ -155,3 +155,22 @@ The pieces build on each other; files live in `worldbuilder_factions\` next to t - Server tools: `scripts/worldbuilder-mcp/server.py`; logic that needs no bridge is in `faction.py`, `framework.py`, `scaffold.py`, `aidata.py`, `match.py`. - AI matches are an engine feature (`GameLogic/AIMatch.h`): `generalszh.exe -aiMatch -aiPlayers Faction:difficulty,... -aiMatchLog [-headless]`. - After a change: build `z_worldbuilder`, copy `WorldBuilderZH.exe` to the mod folder, restart it with `-mcp -ignoreAsserts`, run `python scripts/worldbuilder-mcp/smoke_test.py` (it replaces the open map with a test map) and extend that test for new commands. + +## Game MCP + +A second MCP server drives the running game itself, so features can be tested by playing: start a skirmish as the local player (alone in a sandbox or against AI), pause, step frames, inspect every object and player, give orders, spawn and change things, run scripts, take screenshots. Use its tools (`mcp__game__*`) for that. Full reference: the game section of `scripts/worldbuilder-mcp/README.md`. + +``` +Claude <-- stdio --> scripts/worldbuilder-mcp/game_server.py <-- TCP 127.0.0.1:47801 --> generalszh.exe -mcp +``` + +- The bridge exists only in builds with `RTS_BUILD_OPTION_GAME_MCP` and only listens when the game is started with `-mcp`. Without the flag nothing changes for normal play. +- `game_launch` starts `generalszh.exe` from the mod folder (`GAME_DIR`, default `F:\Operation-Firestorm-2.0`; `GAME_EXE` picks another exe name) with `-mcpGame -mcpPlayers [,:...] [-mcpStartPaused] [-headless]`. Only one faction = sandbox (no victory check). +- Logic runs 30 frames per second. Orders (`orders_*`) go through the normal command messages and take effect on the next frame: give them while paused, then `game_step`. `objects_spawn` / `objects_set` / `players_set` change the game directly and are not in the replay. +- Headless runs only the logic (fast, no `view_*`). Windowed must not be minimized (the engine sleeps while iconic). + +### Working on the game MCP + +- C++: `GeneralsMD/Code/GameEngine/Source/Common/Mcp/` (`GameMcp.cpp` command line and main-thread side, `GameMcpServer.cpp` socket thread, `GameMcpCmd*.cpp` commands registered with `gameMcpRegisterCommand`, failing with `gameMcpFail`). Every hook in other files is inside `#ifdef RTS_GAME_MCP`. Handlers run on the main thread between logic frames (polled in `GameEngine::update`, or in `GameMcp::runHeadless`). +- Skirmish setup without the menu is shared with AI matches: `GameLogic/SkirmishLaunch.h`. +- After a change: build `z_generals`, copy `generalszh.exe` to the mod folder under another name and run `python scripts/worldbuilder-mcp/game_smoke_test.py --exe ` (add `--windowed` for the view commands); extend it for new commands. diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index ace22cd118b..aa4651b8d1c 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -142,3 +142,56 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - Heightmaps have one pixel per heightmap vertex, and the pixel value is the raw height. 16-bit images are accepted on import. - Masks have one pixel per cell. - Relative paths are resolved by the Python server, not by WorldBuilder. + +# Game MCP server + +`game_server.py` drives the running game the same way: Claude starts a skirmish as the local +player, alone in a sandbox or against AI players, pauses and steps it, looks at every object and +player, gives orders, changes things for a test and takes screenshots. + +``` +MCP client <-- stdio --> game_server.py <-- TCP 127.0.0.1:47801, JSON lines --> generalszh.exe -mcp +``` + +The C++ side lives in `GeneralsMD/Code/GameEngine/Source/Common/Mcp/`. It is compiled only with the +CMake option `RTS_BUILD_OPTION_GAME_MCP` (default ON) and listens only when the game is started with +`-mcp`. Commands run on the game's main thread between two logic frames. + +## Starting the game + +`game_launch` starts `generalszh.exe` from `GAME_DIR` (default `F:\Operation-Firestorm-2.0`; set +`GAME_EXE` for another executable name) and waits until the map has loaded. By hand: + +``` +generalszh.exe -mcp -mcpGame "armed neighbors" -mcpPlayers FactionAmerica,FactionChina:hard -mcpStartPaused -win -quickstart -ignoreAsserts +``` + +| Option | Meaning | +|---|---| +| `-mcp` / `-mcpport:N` | Enable the bridge on port 47801 / N. | +| `-mcpGame ` | Start a skirmish on that map (map name or part of its path). | +| `-mcpPlayers ` | `Faction[:team]` of the local player (or `Observer`), then AI players as `Faction:difficulty[:team]`. Only the local player: a sandbox in which nobody wins or loses. | +| `-mcpCash ` | Starting money. | +| `-mcpStartPaused` | Pause as soon as the game has started. | +| `-seed ` | Random seed (start positions, everything random). | +| `-headless` | Only the game logic: no window, no screenshots, as fast as allowed by `game_set_speed`. | + +`-mcp` also works with `-aiMatch` (to look into an AI match) or a game started from the menu. + +## Tools + +- **Time:** `game_status`, `game_pause`, `game_resume`, `game_step` (frames or seconds, then pause; waits until done), `game_set_speed` (30 = normal, 0 = as fast as possible), `game_quit`. +- **Looking:** `objects_list` (filter by player, template, kindof, status, area), `objects_get` (status, disabled, kindof, veterancy, AI state, weapons, upgrades, special powers, production, passengers, behavior modules, model conditions), `objects_templates`, `players_list`, `players_get`, `game_messages` (what the script engine and the AI reported). +- **Orders** (as the owning player would click them; recorded in the replay; carried out on the next logic frame): `orders_move`, `orders_attack`, `orders_stop`, `orders_guard`, `orders_sell`, `orders_build`, `orders_produce`, `orders_upgrade`, `orders_special_power`, `orders_purchase_science`. +- **Test changes** (direct, not in the replay): `objects_spawn`, `objects_destroy`, `objects_set` (health, position, veterancy, upgrade, status), `players_set` (money, rank, points, sciences, upgrades, instant build, special power delays). +- **Scripts:** `scripts_run`, `scripts_set_active`, `scripts_flags`, `scripts_counters`. +- **View** (window only, not minimized): `view_camera`, `view_screenshot`. +- `game_command` runs any bridge command; `list_commands` lists them. + +## Checking the bridge without MCP + +``` +python game_smoke_test.py --exe generalszh.exe [--windowed] +``` + +It starts the game headless in a sandbox and goes through every command area, then closes it. diff --git a/scripts/worldbuilder-mcp/game_server.py b/scripts/worldbuilder-mcp/game_server.py new file mode 100644 index 00000000000..adc0517093b --- /dev/null +++ b/scripts/worldbuilder-mcp/game_server.py @@ -0,0 +1,445 @@ +"""MCP server that lets an AI assistant play and inspect a running Zero Hour game. + +The game (generalszh.exe built with RTS_BUILD_OPTION_GAME_MCP) must be started with ``-mcp``; +game_launch does that. Then point an MCP client at this script (stdio). +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import time +from typing import Any, Literal + +from mcp.server.mcpserver import Image, MCPServer +from mcp.server.mcpserver.exceptions import ToolError + +from wb_bridge import WorldBuilderBridge, WorldBuilderError + +GAME_PORT = 47801 +GAME_EXE = os.environ.get("GAME_EXE", "generalszh.exe") +GAME_DIR = os.environ.get("GAME_DIR", r"F:\Operation-Firestorm-2.0") +FRAMES_PER_SECOND = 30 + +INSTRUCTIONS = """\ +Plays and inspects a running Command & Conquer Generals Zero Hour game, to test game features. + +Starting: +- game_launch starts generalszh.exe from the mod folder on a skirmish map with the local player's + faction and optional AI opponents ("FactionChina:hard"). Without opponents it is a sandbox in + which nobody wins or loses. By default it starts paused at frame 1 so nothing has happened yet. + headless=true runs only the game logic, without a window (fast; no screenshots or camera). +- A game started by hand with -mcp works too; game_status says what is running. + +Time: +- The logic runs 30 frames per second. game_pause / game_resume, game_step(frames) runs that many + frames and pauses again (it waits until they are done). game_set_speed changes how fast it runs + (0 = as fast as possible). +- Orders take effect on the next logic frame: give them while paused, then game_step. + +Looking: +- objects_list (filters: player, template, kindof, status, area) gives ids, positions, health; + objects_get gives everything about objects (status, weapons, AI state, upgrades, special powers, + production, passengers, modules). players_get: money, power, sciences, upgrades, score. +- objects_templates finds template names. game_messages shows what scripts and the AI reported. +- view_screenshot / view_camera need the window (not headless, not minimized). + +Changing: +- orders_* give the units of any player orders as that player would (recorded in the replay). +- objects_spawn / objects_destroy / objects_set / players_set change the game directly (test cheats). +- scripts_run, scripts_set_active, scripts_flags, scripts_counters work on the map's scripts. + +Coordinates are world units (10 per terrain cell, (0,0) lower left of the playable area); angles +are degrees, counterclockwise, 0 = +x. +""" + +mcp = MCPServer("game", instructions=INSTRUCTIONS) +_bridge = WorldBuilderBridge(label="the game", env_prefix="GAME_MCP", default_port=GAME_PORT, + start_hint="Start it with game_launch, or run generalszh.exe with -mcp.") +_process: subprocess.Popen | None = None + + +class _Bridge: + """Forwards to the bridge, turning its errors into ToolError so their text reaches the client.""" + + @staticmethod + def call(cmd: str, **args: Any) -> Any: + try: + return _bridge.call(cmd, **args) + except WorldBuilderError as exc: + raise ToolError(str(exc)) from exc + + +game = _Bridge() + + +def _reachable() -> bool: + try: + _bridge.call("game.status") + return True + except (WorldBuilderError, OSError): + _bridge.close() + return False + + +def _wait_paused_at(target: int, timeout: float) -> dict: + """Polls until the logic has reached target and paused again.""" + deadline = time.time() + timeout + while True: + status = game.call("game.status") + if status["frame"] >= target and status["paused"]: + return status + if not status["in_game"]: + raise ToolError(f"the game ended at frame {status['frame']}") + if time.time() > deadline: + status["note"] = f"still running after {timeout} s; it pauses at frame {target}" + return status + time.sleep(0.05) + + +# -------------------------------------------------------------------------------------------------- +# Game +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def game_launch(map: str, faction: str = "FactionAmerica", opponents: list[str] | None = None, team: int | None = None, + seed: int = 1, cash: int | None = None, headless: bool = False, start_paused: bool = True, + audio: bool = False, width: int = 1280, height: int = 720, extra_args: list[str] | None = None, + wait_seconds: float = 180) -> dict: + """Starts the game on a skirmish map with the local player and optional AI opponents. + - map: name of a multiplayer map or part of its path ("tournament desert"). + - faction: player template of the local player (FactionAmerica, FactionChina, FactionGLA, + a general like FactionAmericaAirForceGeneral, or Observer to only watch); team: its team number. + - opponents: AI players as "Faction:difficulty[:team]" (easy, normal, hard), e.g. + ["FactionChina:hard"]. None: a sandbox without opponents in which nobody wins. + - seed fixes start positions and randomness; cash is the starting money. + - headless: no window, only the game logic (no screenshots, camera or sound). + - start_paused: pause at the first frame, before anything has happened. + The game runs from GAME_DIR (default F:\\Operation-Firestorm-2.0). Waits until the map has + loaded and returns game_status. Fails if a game with the bridge is already running (game_quit).""" + global _process + if _reachable(): + raise ToolError("a game is already running with the bridge; game_quit it first") + exe = os.path.join(GAME_DIR, GAME_EXE) + if not os.path.exists(exe): + raise ToolError(f"{exe} not found (set GAME_DIR / GAME_EXE)") + local = faction if team is None else f"{faction}:{team}" + players = ",".join([local] + list(opponents or [])) + args = ["-mcp", "-mcpGame", map, "-mcpPlayers", players, "-seed", str(seed), "-quickstart", "-ignoreAsserts"] + args += ["-headless"] if headless else ["-win", "-xres", str(width), "-yres", str(height)] + if not audio and not headless: + args.append("-noaudio") + if cash: + args += ["-mcpCash", str(cash)] + if start_paused: + args.append("-mcpStartPaused") + args += list(extra_args or []) + _process = subprocess.Popen([exe] + args, cwd=GAME_DIR) + + deadline = time.time() + wait_seconds + status: dict | None = None + while time.time() < deadline: + if _process.poll() is not None: + raise ToolError(f"the game exited with code {_process.returncode} before the bridge answered " + f"(command line: {' '.join(args)})") + if _reachable(): + status = game.call("game.status") + if status.get("start_error"): + raise ToolError(f"the game did not start: {status['start_error']}") + if status["in_game"] and status["frame"] >= 1 and (status["paused"] or not start_paused): + break + time.sleep(0.5) + if status is None: + raise ToolError(f"the bridge did not answer within {wait_seconds} s") + status["pid"] = _process.pid + status["command_line"] = args + return status + + +@mcp.tool() +def game_status() -> dict: + """In game, map, frame (30 per second), seconds, paused, speed, headless, sandbox, local player.""" + return game.call("game.status") + + +@mcp.tool() +def game_pause() -> dict: + """Pauses the game logic. The window keeps drawing; inspection and orders still work.""" + return game.call("game.pause") + + +@mcp.tool() +def game_resume() -> dict: + """Lets the game logic run freely again.""" + return game.call("game.resume") + + +@mcp.tool() +def game_step(frames: int | None = None, seconds: float | None = None, wait: bool = True, timeout: float = 300) -> dict: + """Runs the game for some logic frames (or seconds of game time, 30 frames each), then pauses. + With wait it returns once they have run, with the status at that point.""" + if frames is None: + frames = max(1, round((seconds or 1 / FRAMES_PER_SECOND) * FRAMES_PER_SECOND)) + result = game.call("game.step", frames=frames) + if not wait: + return result + return _wait_paused_at(result["pause_at_frame"], timeout) + + +@mcp.tool() +def game_set_speed(fps: int) -> dict: + """Logic frames per second: 30 is normal speed, 60 double, 0 as fast as possible.""" + return game.call("game.set_speed", fps=fps) + + +@mcp.tool() +def game_messages(since: int = 0, contains: str | None = None, limit: int = 200) -> dict: + """What the script engine and the AI reported (script debug messages, warnings), newest last. + Pass next_serial of the last call as since to get only new ones.""" + return game.call("game.messages", since=since, contains=contains, limit=limit) + + +@mcp.tool() +def game_quit(wait_seconds: float = 20) -> dict: + """Ends the game and closes it.""" + result = game.call("game.quit") + _bridge.close() + if _process is not None: + try: + _process.wait(timeout=wait_seconds) + result["exit_code"] = _process.returncode + except subprocess.TimeoutExpired: + result["note"] = "the game is still closing" + return result + + +@mcp.tool() +def game_command(cmd: str, args: dict | None = None) -> Any: + """Runs any bridge command directly (list_commands lists them with their arguments).""" + return game.call(cmd, **(args or {})) + + +# -------------------------------------------------------------------------------------------------- +# Objects and players +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def objects_list(player: str | None = None, template: str | None = None, name: str | None = None, + kindof: str | None = None, status: str | None = None, x: float | None = None, y: float | None = None, + radius: float | None = None, x0: float | None = None, y0: float | None = None, x1: float | None = None, + y1: float | None = None, include_dead: bool = False, limit: int = 200) -> dict: + """Objects in the game with id, template, owner, position, angle and health. Filters: player + (internal name like "player0", display name, "local"), template (part of the name), name (script + name), kindof (e.g. INFANTRY, STRUCTURE), status (e.g. UNDER_CONSTRUCTION), circle x,y,radius or + rectangle x0,y0,x1,y1. matched counts all hits, objects holds at most limit.""" + return game.call("objects.list", player=player, template=template, name=name, kindof=kindof, status=status, + x=x, y=y, radius=radius, x0=x0, y0=y0, x1=x1, y1=y1, include_dead=include_dead or None, limit=limit) + + +ObjectSection = Literal["kindof", "model", "ai", "weapons", "upgrades", "special_powers", "production", "contain", "modules"] + + +@mcp.tool() +def objects_get(ids: list[int], sections: list[ObjectSection] | None = None) -> dict: + """Everything about objects: owner, team, position, health, damage state, status and disabled + flags, veterancy and experience, velocity, construction progress, and the sections kindof, + model (model conditions), ai (state, goal), weapons (per slot: status, ammo, range, reload), + upgrades, special_powers (ready, percent), production queue, contain (passengers) and modules + (behavior modules with their INI tags). sections limits the optional parts (default all).""" + return game.call("objects.get", ids=ids, sections=sections) + + +@mcp.tool() +def objects_templates(contains: str | None = None, side: str | None = None, kindof: str | None = None, + limit: int = 300) -> dict: + """Object templates (names for objects_spawn, orders_build, orders_produce) with side, cost and + build time.""" + return game.call("objects.templates", contains=contains, side=side, kindof=kindof, limit=limit) + + +@mcp.tool() +def players_list() -> dict: + """All players: internal name, display name, side, faction, human or computer, local, active, money.""" + return game.call("players.list") + + +@mcp.tool() +def players_get(player: str | None = None) -> dict: + """One player (default: the local one): money, power, rank, skill and science points, sciences, + player upgrades, score, relationships to the others and its objects counted by template.""" + return game.call("players.get", player=player) + + +# -------------------------------------------------------------------------------------------------- +# Orders +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def orders_move(ids: list[int], x: float, y: float, attack_move: bool = False) -> dict: + """Moves units (all of one player) to a position; attack_move fights on the way.""" + return game.call("orders.move", ids=ids, x=x, y=y, attack_move=attack_move or None) + + +@mcp.tool() +def orders_attack(ids: list[int], target: int | None = None, x: float | None = None, y: float | None = None, + force: bool = False) -> dict: + """Attacks an object (force: even an ally or neutral one), or fires at the ground at x, y.""" + return game.call("orders.attack", ids=ids, target=target, x=x, y=y, force=force or None) + + +@mcp.tool() +def orders_stop(ids: list[int]) -> dict: + """Stops units.""" + return game.call("orders.stop", ids=ids) + + +@mcp.tool() +def orders_guard(ids: list[int], target: int | None = None, x: float | None = None, y: float | None = None, + mode: int = 0) -> dict: + """Guards an object or a position (mode 0 normal, 1 without pursuit, 2 against aircraft only).""" + return game.call("orders.guard", ids=ids, target=target, x=x, y=y, mode=mode) + + +@mcp.tool() +def orders_sell(ids: list[int]) -> dict: + """Sells structures.""" + return game.call("orders.sell", ids=ids) + + +@mcp.tool() +def orders_build(dozer: int, template: str, x: float, y: float, angle: float = 0) -> dict: + """Orders a dozer or worker to build a structure at a position (costs money as usual).""" + return game.call("orders.build", dozer=dozer, template=template, x=x, y=y, angle=angle) + + +@mcp.tool() +def orders_produce(factory: int, template: str, count: int = 1) -> dict: + """Queues units in a production building (costs money as usual).""" + return game.call("orders.produce", factory=factory, template=template, count=count) + + +@mcp.tool() +def orders_upgrade(id: int, upgrade: str) -> dict: + """Queues an upgrade in an object (a building researches it, or a unit gets it).""" + return game.call("orders.upgrade", id=id, upgrade=upgrade) + + +@mcp.tool() +def orders_special_power(id: int, power: str | None = None, button: str | None = None, target: int | None = None, + x: float | None = None, y: float | None = None, angle: float | None = None, + options: int | None = None) -> dict: + """Uses a special power of an object: at an object (target), at a position (x, y, angle) or + without a target. Name the power (SpecialPower template, see objects_get special_powers) or the + command button that fires it (button also supplies the UI's command options).""" + return game.call("orders.special_power", id=id, power=power, button=button, target=target, x=x, y=y, angle=angle, + options=options) + + +@mcp.tool() +def orders_purchase_science(science: str, player: str | None = None) -> dict: + """Buys a general's power (science) with science points, as the player would.""" + return game.call("orders.purchase_science", science=science, player=player) + + +# -------------------------------------------------------------------------------------------------- +# Test changes +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def objects_spawn(template: str, x: float, y: float, player: str | None = None, team: str | None = None, + angle: float = 0, count: int = 1, spacing: float = 20, name: str | None = None) -> dict: + """Creates objects (count of them in a grid) for a player (default: the local one) at a position. + Not recorded in the replay.""" + return game.call("objects.spawn", template=template, x=x, y=y, player=player, team=team, angle=angle, count=count, + spacing=spacing, name=name) + + +@mcp.tool() +def objects_destroy(ids: list[int], how: Literal["kill", "remove"] = "kill") -> dict: + """Kills objects (they die as usual: death effects, rubble, experience) or removes them silently.""" + return game.call("objects.destroy", ids=ids, how=how) + + +@mcp.tool() +def objects_set(ids: list[int], health: float | None = None, health_percent: float | None = None, + max_health: float | None = None, x: float | None = None, y: float | None = None, + angle: float | None = None, veterancy: int | str | None = None, upgrade: str | None = None, + set_status: str | None = None, clear_status: str | None = None) -> dict: + """Changes objects directly: health (0 kills), max health, position, angle, veterancy (0..3 or + REGULAR, VETERAN, ELITE, HEROIC), gives an object upgrade, sets or clears a status flag.""" + return game.call("objects.set", ids=ids, health=health, health_percent=health_percent, max_health=max_health, + x=x, y=y, angle=angle, veterancy=veterancy, upgrade=upgrade, set_status=set_status, + clear_status=clear_status) + + +@mcp.tool() +def players_set(player: str | None = None, money: int | None = None, add_money: int | None = None, + rank: int | None = None, add_skill_points: int | None = None, add_science_points: int | None = None, + sciences: list[str] | None = None, all_sciences: bool = False, upgrades: list[str] | None = None, + instant_build: bool | None = None, special_power_delays: bool | None = None) -> dict: + """Changes a player (default: the local one): money, rank, points, grants sciences or player + upgrades, instant building; special_power_delays=false makes special powers recharge at once + (for everyone).""" + return game.call("players.set", player=player, money=money, add_money=add_money, rank=rank, + add_skill_points=add_skill_points, add_science_points=add_science_points, sciences=sciences, + all_sciences=all_sciences or None, upgrades=upgrades, instant_build=instant_build, + special_power_delays=special_power_delays) + + +# -------------------------------------------------------------------------------------------------- +# Scripts +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def scripts_run(name: str) -> dict: + """Runs a map or AI script's actions now, whatever its conditions.""" + return game.call("scripts.run", name=name) + + +@mcp.tool() +def scripts_set_active(name: str, active: bool = True) -> dict: + """Enables or disables a script or script group.""" + return game.call("scripts.set_active", name=name, active=active) + + +@mcp.tool() +def scripts_flags(contains: str | None = None, set: dict[str, bool] | None = None) -> dict: + """Script flags and their values (set changes some first).""" + return game.call("scripts.flags", contains=contains, set=set) + + +@mcp.tool() +def scripts_counters(contains: str | None = None, set: dict[str, int] | None = None) -> dict: + """Script counters and timers (timers count logic frames; set changes some first).""" + return game.call("scripts.counters", contains=contains, set=set) + + +# -------------------------------------------------------------------------------------------------- +# View +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def view_camera(id: int | None = None, x: float | None = None, y: float | None = None, angle: float | None = None, + pitch: float | None = None, zoom: float | None = None, reset: bool = False) -> dict: + """Moves the camera to an object or a position, turns or zooms it, and returns where it is.""" + return game.call("view.camera", id=id, x=x, y=y, angle=angle, pitch=pitch, zoom=zoom, reset=reset or None) + + +@mcp.tool() +def view_screenshot(id: int | None = None, x: float | None = None, y: float | None = None, zoom: float | None = None, + path: str | None = None, format: Literal["jpg", "png"] = "jpg", quality: int = 85) -> Image: + """Returns what the game window shows. Optionally moves the camera first (to an object or a + position); a moved camera needs a drawn frame, so the picture is taken a moment later. The + window must not be minimized; not available headless.""" + if id is not None or x is not None or y is not None or zoom is not None: + game.call("view.camera", id=id, x=x, y=y, zoom=zoom) + time.sleep(0.3) + if path is None: + path = os.path.join(tempfile.gettempdir(), f"game_mcp_{int(time.time() * 1000)}.{format}") + result = game.call("view.screenshot", path=path, quality=quality) + return Image(path=result["path"]) + + +if __name__ == "__main__": + mcp.run() diff --git a/scripts/worldbuilder-mcp/game_smoke_test.py b/scripts/worldbuilder-mcp/game_smoke_test.py new file mode 100644 index 00000000000..faf29cb7536 --- /dev/null +++ b/scripts/worldbuilder-mcp/game_smoke_test.py @@ -0,0 +1,156 @@ +"""End-to-end check of the game's MCP bridge, without an MCP client. + +Starts generalszh.exe headless in a sandbox (only the local player), paused at the start, and goes +through every command area: status, stepping, objects, players, orders, test changes, scripts. + + python game_smoke_test.py [--game-dir F:\\Operation-Firestorm-2.0] [--exe generalszh.exe] + [--map "armed neighbors"] [--faction FactionAmerica] [--windowed] + +The bridge serves one client, so run it while no MCP client is connected to the game. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tempfile +import time + +from wb_bridge import WorldBuilderBridge, WorldBuilderError + +PORT = 47801 + + +def check(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + print(f" ok {message}") + + +def wait_for(bridge: WorldBuilderBridge, process: subprocess.Popen, timeout: float = 240) -> dict: + deadline = time.time() + timeout + while time.time() < deadline: + if process.poll() is not None: + raise AssertionError(f"the game exited with code {process.returncode}") + try: + status = bridge.call("game.status") + if status.get("start_error"): + raise AssertionError(status["start_error"]) + if status["in_game"] and status["paused"]: + return status + except (WorldBuilderError, OSError): + bridge.close() + time.sleep(0.5) + raise AssertionError("the game did not start in time") + + +def step(bridge: WorldBuilderBridge, frames: int) -> dict: + target = bridge.call("game.step", frames=frames)["pause_at_frame"] + deadline = time.time() + 120 + while time.time() < deadline: + status = bridge.call("game.status") + if status["frame"] >= target and status["paused"]: + return status + time.sleep(0.05) + raise AssertionError(f"did not reach frame {target}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--game-dir", default=os.environ.get("GAME_DIR", r"F:\Operation-Firestorm-2.0")) + parser.add_argument("--exe", default=os.environ.get("GAME_EXE", "generalszh.exe")) + parser.add_argument("--map", default="armed neighbors") + parser.add_argument("--faction", default="FactionAmerica") + parser.add_argument("--unit", default="AmericaVehicleHumvee") + parser.add_argument("--windowed", action="store_true") + options = parser.parse_args() + + screen = ["-win", "-xres", "1024", "-yres", "768", "-noaudio"] if options.windowed else ["-headless"] + args = [os.path.join(options.game_dir, options.exe), "-mcp", "-mcpGame", options.map, "-mcpPlayers", options.faction, + "-mcpStartPaused", "-seed", "1", "-quickstart", "-ignoreAsserts"] + screen + process = subprocess.Popen(args, cwd=options.game_dir) + bridge = WorldBuilderBridge(label="the game", env_prefix="GAME_MCP", default_port=PORT) + try: + print("start") + status = wait_for(bridge, process) + check(status["sandbox"], "sandbox without opponents") + check(status["paused"] and status["frame"] <= 2, f"paused at the start (frame {status['frame']})") + names = {c["name"] for c in bridge.call("list_commands")["commands"]} + check({"game.step", "objects.get", "orders.move", "objects.spawn", "scripts.flags"} <= names, "commands registered") + + print("time") + frame = status["frame"] + status = step(bridge, 30) + check(status["frame"] == frame + 30 and status["paused"], "step runs 30 frames and pauses again") + + print("players") + players = bridge.call("players.list")["players"] + local = [p for p in players if p["local"]] + check(len(local) == 1 and local[0]["faction"] == options.faction, f"local player plays {options.faction}") + me = bridge.call("players.get") + check(me["money"] >= 0 and "sciences" in me and "objects" in me, "players.get describes the local player") + + print("objects") + mine = bridge.call("objects.list", player="local") + check(mine["matched"] > 0, f"the local player starts with objects ({mine['matched']})") + detail = bridge.call("objects.get", ids=[mine["objects"][0]["id"]])["objects"][0] + check("status" in detail and "modules" in detail, f"objects.get describes {detail['template']}") + templates = bridge.call("objects.templates", contains=options.unit)["templates"] + check(any(t["name"] == options.unit for t in templates), f"template {options.unit} exists") + + print("test changes") + spawned = bridge.call("objects.spawn", template=options.unit, x=500, y=500, count=2)["ids"] + check(len(spawned) == 2, "two units spawned") + unit = bridge.call("objects.get", ids=[spawned[0]], sections=["ai", "weapons"])["objects"][0] + check(abs(unit["position"]["x"] - 500) < 40, "spawned near the position") + bridge.call("objects.set", ids=[spawned[0]], health_percent=50) + unit = bridge.call("objects.get", ids=[spawned[0]], sections=[])["objects"][0] + check(abs(unit["health"] - unit["max_health"] / 2) < 1, "health set to half") + before = bridge.call("players.get")["money"] + bridge.call("players.set", add_money=1000) + check(bridge.call("players.get")["money"] == before + 1000, "money added") + + print("orders") + bridge.call("orders.move", ids=spawned, x=800, y=500) + step(bridge, 90) + moved = bridge.call("objects.get", ids=[spawned[0]], sections=["ai"])["objects"][0] + check(moved["position"]["x"] > unit["position"]["x"] + 20, f"unit moved toward the target (x {moved['position']['x']:.0f})") + bridge.call("orders.stop", ids=spawned) + step(bridge, 2) + + print("scripts") + flags = bridge.call("scripts.flags", set={"McpSmokeTestFlag": True}, contains="McpSmokeTest")["flags"] + check(flags.get("McpSmokeTestFlag") is True, "script flag set") + counters = bridge.call("scripts.counters", set={"McpSmokeTestCounter": 7}, contains="McpSmokeTest")["counters"] + check(counters["McpSmokeTestCounter"]["value"] == 7, "script counter set") + messages = bridge.call("game.messages") + check("next_serial" in messages, f"game.messages answers ({len(messages['messages'])} messages)") + + if options.windowed: + print("view") + bridge.call("view.camera", id=spawned[0]) + step(bridge, 2) + path = os.path.join(tempfile.gettempdir(), "game_mcp_smoke.jpg") + bridge.call("view.screenshot", path=path) + check(os.path.getsize(path) > 1000, f"screenshot written ({path})") + + print("cleanup") + bridge.call("objects.destroy", ids=spawned, how="remove") + step(bridge, 2) + check(bridge.call("objects.list", template=options.unit, player="local")["matched"] == 0, "spawned units removed") + bridge.call("game.quit") + bridge.close() + process.wait(timeout=60) + check(process.returncode == 0, "the game closed") + print("all good") + return 0 + finally: + bridge.close() + if process.poll() is None: + process.kill() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/worldbuilder-mcp/wb_bridge.py b/scripts/worldbuilder-mcp/wb_bridge.py index 5ab9fa12a20..4a1f419cfb4 100644 --- a/scripts/worldbuilder-mcp/wb_bridge.py +++ b/scripts/worldbuilder-mcp/wb_bridge.py @@ -1,7 +1,8 @@ -"""TCP client for the WorldBuilder MCP command bridge. +"""TCP client for the MCP command bridges of WorldBuilder and the game. -WorldBuilderZH.exe started with ``-mcp`` (or ``-mcpport:N``) listens on 127.0.0.1 and accepts one -JSON object per line: ``{"id": 1, "cmd": "map.info", "args": {}}``. It answers with +WorldBuilderZH.exe (port 47800) and generalszh.exe (port 47801) started with ``-mcp`` (or +``-mcpport:N``) listen on 127.0.0.1 and accept one JSON object per line: +``{"id": 1, "cmd": "map.info", "args": {}}``. They answer with ``{"id": 1, "ok": true, "result": {...}}`` or ``{"id": 1, "ok": false, "error": "..."}``. """ @@ -19,14 +20,18 @@ class WorldBuilderError(RuntimeError): - """Raised when WorldBuilder rejects a command or cannot be reached.""" + """Raised when WorldBuilder (or the game) rejects a command or cannot be reached.""" class WorldBuilderBridge: - def __init__(self, host: str | None = None, port: int | None = None, timeout: float = 150.0): - self.host = host or os.environ.get("WB_MCP_HOST", DEFAULT_HOST) - self.port = int(port or os.environ.get("WB_MCP_PORT", DEFAULT_PORT)) + def __init__(self, host: str | None = None, port: int | None = None, timeout: float = 150.0, + label: str = "WorldBuilder", env_prefix: str = "WB_MCP", default_port: int = DEFAULT_PORT, + start_hint: str = "Start WorldBuilderZH.exe with the -mcp command line flag."): + self.host = host or os.environ.get(f"{env_prefix}_HOST", DEFAULT_HOST) + self.port = int(port or os.environ.get(f"{env_prefix}_PORT", default_port)) self.timeout = timeout + self.label = label + self.start_hint = start_hint self._sock: socket.socket | None = None self._reader = None self._lock = threading.Lock() @@ -37,8 +42,7 @@ def _connect(self) -> None: sock = socket.create_connection((self.host, self.port), timeout=5.0) except OSError as exc: raise WorldBuilderError( - f"Cannot reach WorldBuilder on {self.host}:{self.port} ({exc}). " - "Start WorldBuilderZH.exe with the -mcp command line flag." + f"Cannot reach {self.label} on {self.host}:{self.port} ({exc}). {self.start_hint}" ) from exc sock.settimeout(self.timeout) self._sock = sock @@ -59,7 +63,7 @@ def _roundtrip(self, line: str) -> str: self._sock.sendall(line.encode("utf-8")) reply = self._reader.readline() if not reply: - raise ConnectionError("WorldBuilder closed the connection") + raise ConnectionError(f"{self.label} closed the connection") return reply def call(self, cmd: str, **args: Any) -> Any: @@ -71,13 +75,13 @@ def call(self, cmd: str, **args: Any) -> Any: try: reply = self._roundtrip(line) except (OSError, ConnectionError): - # WorldBuilder may have been restarted; reconnect once. + # The program may have been restarted; reconnect once. self.close() try: reply = self._roundtrip(line) except (OSError, ConnectionError) as exc: self.close() - raise WorldBuilderError(f"Lost connection to WorldBuilder: {exc}") from exc + raise WorldBuilderError(f"Lost connection to {self.label}: {exc}") from exc message = json.loads(reply) if not message.get("ok"): raise WorldBuilderError(message.get("error", "unknown error")) From 8ef20aaedb07adb93c2fc751c432ca2dd1847269 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 15:30:09 +0200 Subject: [PATCH 08/13] docs(mcp): Describes how to test game features with the game MCP Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5544eb91257..7544b1c53d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,10 +164,29 @@ A second MCP server drives the running game itself, so features can be tested by Claude <-- stdio --> scripts/worldbuilder-mcp/game_server.py <-- TCP 127.0.0.1:47801 --> generalszh.exe -mcp ``` +### Running it + - The bridge exists only in builds with `RTS_BUILD_OPTION_GAME_MCP` and only listens when the game is started with `-mcp`. Without the flag nothing changes for normal play. -- `game_launch` starts `generalszh.exe` from the mod folder (`GAME_DIR`, default `F:\Operation-Firestorm-2.0`; `GAME_EXE` picks another exe name) with `-mcpGame -mcpPlayers [,:...] [-mcpStartPaused] [-headless]`. Only one faction = sandbox (no victory check). -- Logic runs 30 frames per second. Orders (`orders_*`) go through the normal command messages and take effect on the next frame: give them while paused, then `game_step`. `objects_spawn` / `objects_set` / `players_set` change the game directly and are not in the replay. -- Headless runs only the logic (fast, no `view_*`). Windowed must not be minimized (the engine sleeps while iconic). +- `game_launch` uses `generalszh.exe` in the mod folder (`GAME_DIR`, default `F:\Operation-Firestorm-2.0`; `GAME_EXE` picks another exe name). That exe must be an MCP build: if the game starts but the bridge never answers, the installed exe is older than the feature. After changing engine code, rebuild `z_generals` and copy it there (ask first, keep a `.bak` of the user's exe). +- One game at a time: `game_launch` fails while a game with the bridge runs; `game_quit` it first. If a tool says "Cannot reach the game", no game runs with `-mcp` (or it hangs in a dialog). +- A game started by hand works too: `generalszh.exe -mcp -mcpGame "" -mcpPlayers FactionAmerica,FactionChina:hard -mcpStartPaused -win -quickstart -ignoreAsserts`, run from the mod folder. + +### Testing a feature + +1. **Start**: `game_launch(map, faction, opponents)`. `map` is a multiplayer map name or part of its path (`"armed neighbors"` is known to work). `faction` is a player template (`FactionAmerica`, `FactionChina`, `FactionGLA`, generals such as `FactionAmericaAirForceGeneral`, or `Observer`). `opponents` are AI players as `"FactionChina:hard"`; none = a **sandbox** where nobody wins or loses. It returns paused at frame 1, before anything has happened. Use `headless=true` when no picture is needed (much faster, no `view_*`); `seed` repeats a game exactly. +2. **Find things**: `players_list` gives internal player names (`player0` is usually the local player; `"local"` and `"neutral"` work everywhere). `objects_list` filters by `player`, `template` (substring), `kindof`, `status` and area; `objects_templates(contains=...)` gives exact template names. Never guess template, upgrade, science or special power names: look them up (`objects_templates`, `objects_get` sections `upgrades` / `special_powers`, or the INIs in `F:\Operation-Firestorm-2.0\Data\INI`). +3. **Set up the situation**: `objects_spawn(template, x, y, player=..., count=...)` puts units or buildings anywhere, for any player (an AI player's name makes them enemies). `objects_set` changes health, position, veterancy, gives object upgrades, sets status flags. `players_set` gives money, sciences, player upgrades, `instant_build`, and `special_power_delays=false` for instant recharge. +4. **Act**: `orders_*` (move, attack, guard, stop, build, produce, upgrade, special power, buy science, sell) as the owning player would click. Orders are queued and carried out on the **next logic frame**, so give them while paused and then step. +5. **Advance time**: `game_step(frames=N)` or `game_step(seconds=S)` runs that long and pauses again (30 frames = 1 second). `game_set_speed(0)` before long steps makes them run as fast as possible. `game_resume` lets it run freely; `game_pause` stops it. +6. **Check the result**: `objects_get(ids, sections=[...])` for health, damage state, status/disabled flags, weapons (status, ammo, range, reload frames), AI state and goal, upgrades, special powers (ready, percent), production queue, passengers, behavior modules with their INI tags, model conditions. `players_get` for money, power, sciences, upgrades, score, objects by type. `game_messages` for what scripts and the AI reported (pass `next_serial` as `since` to get only new ones). Compare numbers before and after a step to measure damage, speed, timing. +7. **Look** (windowed only): `view_screenshot(id=...)` centers on an object and returns the picture; `view_camera` moves or zooms. +8. **End**: always `game_quit` when done, so the next session can launch. + +- Coordinates are world units (10 per cell, (0,0) lower left of the playable area, z = terrain height when left out); angles are degrees, counterclockwise, 0 = +x. Take positions from the units in `objects_list` (e.g. the local command center) to place things near the base. +- `objects_spawn` / `objects_set` / `players_set` change the game directly and are not recorded in the replay; orders are. +- Scripts: `scripts_run`, `scripts_set_active`, `scripts_flags` / `scripts_counters` (read, or set with `set={...}`). +- Windowed games must not be minimized (the engine sleeps while iconic); an unfocused window is fine. +- `game_command(cmd, args)` runs any bridge command; `game_command("list_commands")` lists them with their arguments. ### Working on the game MCP From 451c7b32e2554592f8855cf6f472491878780d2a Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 16:01:29 +0200 Subject: [PATCH 09/13] bugfix(battleplan): Fixes BattlePlanBonusBehavior toggling status bit NONE for unused status entries Unused StatusToSet/StatusToClear entries default to OBJECT_STATUS_NONE (0), but add/remove checked them against -1. Every plan without a status entry therefore set and cleared status bit 0, leaving it set after a plan was removed. Co-Authored-By: Claude Opus 5.5 --- .../GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp index 6b5b28bbaff..581ca3111ce 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp @@ -319,10 +319,10 @@ void BattlePlanBonusBehavior::addBonusForType(BattlePlanStatus plan) ai->applySpeedMultiplier(d->m_movementSpeedScalarEntries[idx]); } } - if (d->m_statusToSetEntries[idx] != -1) { + if (d->m_statusToSetEntries[idx] != OBJECT_STATUS_NONE) { obj->setStatus(MAKE_OBJECT_STATUS_MASK(d->m_statusToSetEntries[idx])); } - if (d->m_statusToClearEntries[idx] != -1) { + if (d->m_statusToClearEntries[idx] != OBJECT_STATUS_NONE) { obj->setStatus(MAKE_OBJECT_STATUS_MASK(d->m_statusToClearEntries[idx]), FALSE); } @@ -376,10 +376,10 @@ void BattlePlanBonusBehavior::removeBonusForType(BattlePlanStatus plan) ai->applySpeedMultiplier(1.0f / __max(d->m_movementSpeedScalarEntries[idx], 0.01f)); } } - if (d->m_statusToSetEntries[idx] != -1) { + if (d->m_statusToSetEntries[idx] != OBJECT_STATUS_NONE) { obj->setStatus(MAKE_OBJECT_STATUS_MASK(d->m_statusToSetEntries[idx]), FALSE); } - if (d->m_statusToClearEntries[idx] != -1) { + if (d->m_statusToClearEntries[idx] != OBJECT_STATUS_NONE) { obj->setStatus(MAKE_OBJECT_STATUS_MASK(d->m_statusToClearEntries[idx])); } From e562742c5e997a45cb3d9c452d791228a9b31812 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 16:01:29 +0200 Subject: [PATCH 10/13] bugfix(battleplan): Fixes sight range bonus stacking when loading a game with active battle plans Object::xfer restores the team after the saved vision range, and Player::becomingTeamMember then applied the battle plan bonuses again. With Search & Destroy active, every load multiplied vision and shroud clearing range by the plan scalar once more, and the extra range stayed after the plan ended. Loaded objects already carry their bonuses, so nothing is applied while loading. Co-Authored-By: Claude Opus 5.5 --- GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp index 3173255fd5c..397bf93e33a 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp @@ -1077,7 +1077,9 @@ void Player::becomingTeamMember(Object *obj, Bool yes) } } - if( getNumBattlePlansActive() > 0 && obj->areModulesReady() ) + // A loaded object already carries its bonuses in its saved state (vision, body, modules); + // applying them again while it is restored to its team would stack the sight scalar. + if( getNumBattlePlansActive() > 0 && obj->areModulesReady() && !TheGameState->isInLoadGame() ) { if( yes ) { From 89a9ecd0052d7ef6b05fc1d154baacd6c72dd663 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 16:01:43 +0200 Subject: [PATCH 11/13] feat(mcp): Adds bonus state to objects.get and battle plans to players.get objects.get gets a bonuses section: weapon bonus conditions, weapon and armor set flags, armor damage scalar, vision and shroud clearing range and the AI speed multiplier. players.get reports the active battle plan sources per plan and the accumulated armor and sight scalars. This makes battle plans, upgrades and powers that change these values testable through the game MCP. Co-Authored-By: Claude Opus 5.5 --- .../Source/Common/Mcp/GameMcpCmdObjects.cpp | 32 ++++++++++++++++++- .../Source/Common/Mcp/GameMcpCmdPlayers.cpp | 15 ++++++++- scripts/worldbuilder-mcp/README.md | 2 +- scripts/worldbuilder-mcp/game_server.py | 14 +++++--- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp index c03e678fbfa..11338908862 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp @@ -35,6 +35,7 @@ #include "Common/ThingTemplate.h" #include "Common/Upgrade.h" #include "GameClient/Drawable.h" +#include "GameLogic/ArmorSet.h" #include "GameLogic/ExperienceTracker.h" #include "GameLogic/GameLogic.h" #include "GameLogic/Module/AIUpdate.h" @@ -214,6 +215,32 @@ McpJson describeUpgrades(const Object *object) return names; } +/// Stat modifiers that bonuses such as battle plans, upgrades and powers change. +McpJson describeBonuses(Object *object) +{ + McpJson result = McpJson::makeObject(); + result.set("weapon_bonus", gameMcpFlagNames(object->getWeaponBonusCondition())); + result.set("weapon_set", gameMcpFlagNames(object->getWeaponSetFlags())); + BodyModuleInterface *body = object->getBodyModule(); + if (body) { + McpJson armorSet = McpJson::makeArray(); + for (Int i = 0; i < ARMORSET_COUNT; i++) { + if (body->testArmorSetFlag((ArmorSetType)i)) { + const char *name = ArmorSetFlags::getNameFromSingleBit(i); + armorSet.push(name ? name : "?"); + } + } + result.set("armor_set", armorSet); + result.set("armor_damage_scalar", body->getDamageScalar()); + } + result.set("vision_range", object->getVisionRange()).set("shroud_clearing_range", object->getShroudClearingRange()); + const AIUpdateInterface *ai = object->getAIUpdateInterface(); + if (ai) { + result.set("speed_multiplier", ai->getSpeedMultiplier()); + } + return result; +} + bool wants(const McpJson §ions, const char *name) { if (!sections.isArray()) { @@ -271,6 +298,9 @@ McpJson describeFull(Object *object, const McpJson §ions) if (wants(sections, "upgrades")) { result.set("upgrades", describeUpgrades(object)); } + if (wants(sections, "bonuses")) { + result.set("bonuses", describeBonuses(object)); + } if (wants(sections, "special_powers")) { result.set("special_powers", describeSpecialPowers(object)); } @@ -370,7 +400,7 @@ void gameMcpRegisterObjectCommands() "{player?, template?, name?, kindof?, status?, x,y,radius | x0,y0,x1,y1, include_dead?, limit=200} " "Objects in the game: id, template, owner, position, angle (degrees), health."); gameMcpRegisterCommand("objects.get", cmdGet, - "{ids | id, sections?} Everything about objects: status, disabled, kindof, veterancy, ai, weapons, upgrades, " + "{ids | id, sections?} Everything about objects: status, disabled, kindof, veterancy, ai, weapons, upgrades, bonuses, " "special_powers, production, contain, modules, model (sections limits the optional parts)."); gameMcpRegisterCommand("objects.templates", cmdTemplates, "{contains?, side?, kindof?, limit=300} Object templates that can be spawned."); } diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp index cb49f6e2e7f..598f815e17a 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp @@ -36,6 +36,7 @@ #include "Common/ThingTemplate.h" #include "Common/Upgrade.h" #include "GameLogic/GameLogic.h" +#include "GameLogic/Module/BattlePlanUpdate.h" #include "GameLogic/Object.h" #include @@ -106,6 +107,18 @@ McpJson describeFull(Player *player) } result.set("upgrades", upgrades); + // Active battle plan sources per plan (Strategy Centers and other plan sources) and the + // accumulated army bonuses that new or captured units receive. + McpJson plans = McpJson::makeObject(); + plans.set("bombardment", player->getBattlePlansActiveSpecific(PLANSTATUS_BOMBARDMENT)); + plans.set("hold_the_line", player->getBattlePlansActiveSpecific(PLANSTATUS_HOLDTHELINE)); + plans.set("search_and_destroy", player->getBattlePlansActiveSpecific(PLANSTATUS_SEARCHANDDESTROY)); + const BattlePlanBonuses *planBonuses = player->getBattlePlanBonuses(); + if (planBonuses) { + plans.set("armor_scalar", planBonuses->m_armorScalar).set("sight_range_scalar", planBonuses->m_sightRangeScalar); + } + result.set("battle_plans", plans); + ScoreKeeper *score = player->getScoreKeeper(); result.set("score", McpJson::makeObject().set("units_built", score->getTotalUnitsBuilt()).set("units_lost", score->getTotalUnitsLost()) .set("units_destroyed", score->getTotalUnitsDestroyed()).set("buildings_built", score->getTotalBuildingsBuilt()) @@ -159,7 +172,7 @@ void gameMcpRegisterPlayerCommands() { gameMcpRegisterCommand("players.list", cmdList, "All players: name, side, faction, human or computer, local, active, money."); gameMcpRegisterCommand("players.get", cmdGet, - "{player?} One player (default: the local one): money, power, rank, sciences, upgrades, score, relationships, objects by type."); + "{player?} One player (default: the local one): money, power, rank, sciences, upgrades, battle plans, score, relationships, objects by type."); } #endif // RTS_GAME_MCP diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index aa4651b8d1c..de87da2ddd0 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -181,7 +181,7 @@ generalszh.exe -mcp -mcpGame "armed neighbors" -mcpPlayers FactionAmerica,Factio ## Tools - **Time:** `game_status`, `game_pause`, `game_resume`, `game_step` (frames or seconds, then pause; waits until done), `game_set_speed` (30 = normal, 0 = as fast as possible), `game_quit`. -- **Looking:** `objects_list` (filter by player, template, kindof, status, area), `objects_get` (status, disabled, kindof, veterancy, AI state, weapons, upgrades, special powers, production, passengers, behavior modules, model conditions), `objects_templates`, `players_list`, `players_get`, `game_messages` (what the script engine and the AI reported). +- **Looking:** `objects_list` (filter by player, template, kindof, status, area), `objects_get` (status, disabled, kindof, veterancy, AI state, weapons, upgrades, bonuses such as weapon bonus conditions and armor/sight/speed scalars, special powers, production, passengers, behavior modules, model conditions), `objects_templates`, `players_list`, `players_get` (also battle plan counts), `game_messages` (what the script engine and the AI reported). - **Orders** (as the owning player would click them; recorded in the replay; carried out on the next logic frame): `orders_move`, `orders_attack`, `orders_stop`, `orders_guard`, `orders_sell`, `orders_build`, `orders_produce`, `orders_upgrade`, `orders_special_power`, `orders_purchase_science`. - **Test changes** (direct, not in the replay): `objects_spawn`, `objects_destroy`, `objects_set` (health, position, veterancy, upgrade, status), `players_set` (money, rank, points, sciences, upgrades, instant build, special power delays). - **Scripts:** `scripts_run`, `scripts_set_active`, `scripts_flags`, `scripts_counters`. diff --git a/scripts/worldbuilder-mcp/game_server.py b/scripts/worldbuilder-mcp/game_server.py index adc0517093b..0d925b32e2c 100644 --- a/scripts/worldbuilder-mcp/game_server.py +++ b/scripts/worldbuilder-mcp/game_server.py @@ -41,7 +41,8 @@ Looking: - objects_list (filters: player, template, kindof, status, area) gives ids, positions, health; objects_get gives everything about objects (status, weapons, AI state, upgrades, special powers, - production, passengers, modules). players_get: money, power, sciences, upgrades, score. + production, passengers, modules, bonuses). players_get: money, power, sciences, upgrades, + battle plans, score. - objects_templates finds template names. game_messages shows what scripts and the AI reported. - view_screenshot / view_camera need the window (not headless, not minimized). @@ -237,7 +238,7 @@ def objects_list(player: str | None = None, template: str | None = None, name: s x=x, y=y, radius=radius, x0=x0, y0=y0, x1=x1, y1=y1, include_dead=include_dead or None, limit=limit) -ObjectSection = Literal["kindof", "model", "ai", "weapons", "upgrades", "special_powers", "production", "contain", "modules"] +ObjectSection = Literal["kindof", "model", "ai", "weapons", "upgrades", "bonuses", "special_powers", "production", "contain", "modules"] @mcp.tool() @@ -245,8 +246,10 @@ def objects_get(ids: list[int], sections: list[ObjectSection] | None = None) -> """Everything about objects: owner, team, position, health, damage state, status and disabled flags, veterancy and experience, velocity, construction progress, and the sections kindof, model (model conditions), ai (state, goal), weapons (per slot: status, ammo, range, reload), - upgrades, special_powers (ready, percent), production queue, contain (passengers) and modules - (behavior modules with their INI tags). sections limits the optional parts (default all).""" + upgrades, bonuses (weapon bonus conditions, weapon and armor set flags, armor damage scalar, + vision and shroud clearing range, speed multiplier), special_powers (ready, percent), + production queue, contain (passengers) and modules (behavior modules with their INI tags). + sections limits the optional parts (default all).""" return game.call("objects.get", ids=ids, sections=sections) @@ -267,7 +270,8 @@ def players_list() -> dict: @mcp.tool() def players_get(player: str | None = None) -> dict: """One player (default: the local one): money, power, rank, skill and science points, sciences, - player upgrades, score, relationships to the others and its objects counted by template.""" + player upgrades, battle_plans (active plan sources per plan and the accumulated armor and + sight scalars), score, relationships to the others and its objects counted by template.""" return game.call("players.get", player=player) From 1849b5fb17d914a9d0cc0d3eba748d6d63424c12 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 16:01:51 +0200 Subject: [PATCH 12/13] feat(mcp): Adds game.save and game.load commands to the game MCP Saves and loads save files in the save directory the way the in-game save/load menu does, so behavior across a save/load can be tested. Loading pauses the game afterwards. Both need a windowed game: a save also holds client state (terrain visuals, particles) that a headless game does not have. A broken save file returns an error instead of crashing the game. Co-Authored-By: Claude Opus 5.5 --- .../Source/Common/Mcp/GameMcpCmdState.cpp | 100 +++++++++++++++++- scripts/worldbuilder-mcp/README.md | 2 +- scripts/worldbuilder-mcp/game_server.py | 16 +++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp index 6c11be7d5c3..6e2f43c276e 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp @@ -17,7 +17,7 @@ */ // GameMcpCmdState.cpp -// Game state commands of the MCP bridge: status, pause, frame steps, speed, messages, quit. +// Game state commands of the MCP bridge: status, pause, frame steps, speed, messages, save, load, quit. #include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine @@ -28,6 +28,7 @@ #include "Common/FramePacer.h" #include "Common/GameEngine.h" #include "Common/GameMcp.h" +#include "Common/GameState.h" #include "Common/GlobalData.h" #include "Common/Player.h" #include "Common/PlayerList.h" @@ -155,6 +156,99 @@ McpJson cmdMessages(const McpJson &args) return McpJson::makeObject().set("messages", list).set("next_serial", TheGameMcp->getNextMessageSerial()); } +const char *saveCodeName(SaveCode code) +{ + switch (code) { + case SC_OK: return "ok"; + case SC_NO_FILE_AVAILABLE: return "no file name available"; + case SC_FILE_NOT_FOUND: return "file not found"; + case SC_UNABLE_TO_OPEN_FILE: return "unable to open file"; + case SC_INVALID_XFER: return "invalid xfer"; + case SC_UNKNOWN_BLOCK: return "unknown block"; + case SC_INVALID_DATA: return "invalid data"; + case SC_ERROR: return "error"; + default: return "invalid"; + } +} + +/// A save file name is a leaf in the save directory: no folders, and .sav is added when missing. +AsciiString saveFileArg(const McpJson &args) +{ + AsciiString filename = gameMcpArgString(args, "filename", "").c_str(); + if (filename.isEmpty()) { + return filename; + } + if (strpbrk(filename.str(), "/\\:") != nullptr) { + gameMcpFail("filename must be a plain file name in the save directory, without folders"); + } + if (!filename.endsWithNoCase(".sav")) { + filename.concat(".sav"); + } + return filename; +} + +/// Save files also hold the client side (terrain visuals, particles), which a headless game does not have. +void requireWindow() +{ + if (TheGlobalData->m_headless) { + gameMcpFail("saving and loading need the window; launch the game without headless"); + } +} + +McpJson cmdSave(const McpJson &args) +{ + requirePausable(); + requireWindow(); + const AsciiString filename = saveFileArg(args); + UnicodeString desc; + desc.translate(AsciiString(gameMcpArgString(args, "description", "MCP save").c_str())); + const SaveResult result = TheGameState->saveGame(filename, desc, SAVE_FILE_TYPE_NORMAL); + if (result.saveCode != SC_OK) { + gameMcpFail("saving failed: %s", saveCodeName(result.saveCode)); + } + return McpJson::makeObject().set("filename", result.filename.str()) + .set("path", TheGameState->getFilePathInSaveDirectory(result.filename).str()).set("frame", TheGameLogic->getFrame()); +} + +McpJson cmdLoad(const McpJson &args) +{ + gameMcpRequireGame(); + requireWindow(); + if (TheGameLogic->isInMultiplayerGame()) { + gameMcpFail("a multiplayer game cannot be loaded over"); + } + AvailableGameInfo info; + info.filename = saveFileArg(args); + info.next = info.prev = nullptr; + if (info.filename.isEmpty()) { + gameMcpFail("filename is required"); + } + if (!TheGameState->doesSaveGameExist(info.filename)) { + gameMcpFail("no save file %s in %s", info.filename.str(), TheGameState->getSaveDirectory().str()); + } + // Unlike loadGame, this reads from a full path, and it throws on a broken file. + Bool readable = TRUE; + try { + TheGameState->getSaveGameInfoFromFile(TheGameState->getFilePathInSaveDirectory(info.filename), &info.saveGameInfo); + } catch (...) { + readable = FALSE; + } + if (!readable) { + gameMcpFail("save file %s could not be read", info.filename.str()); + } + + // Same as loading from the in-game menu: the engine is reset and the saved game replaces this one. + const SaveCode code = TheGameState->loadGame(info); + if (code != SC_OK) { + gameMcpFail("loading failed: %s", saveCodeName(code)); + } + if (gameMcpArgBool(args, "paused", true)) { + TheGameLogic->setGamePaused(TRUE, FALSE, FALSE); + } + return McpJson::makeObject().set("filename", info.filename.str()).set("frame", TheGameLogic->getFrame()) + .set("paused", TheGameLogic->isGamePaused() != FALSE); +} + McpJson cmdQuit(const McpJson &) { // Leave as the main menu's quit button does. Without the client there is nothing to tell. @@ -176,6 +270,10 @@ void gameMcpRegisterStateCommands() gameMcpRegisterCommand("game.step", cmdStep, "{frames=1} Runs that many logic frames, then pauses."); gameMcpRegisterCommand("game.set_speed", cmdSetSpeed, "{fps} Logic frames per second: 30 is normal speed, 0 as fast as possible."); gameMcpRegisterCommand("game.messages", cmdMessages, "{since?, contains?, limit?} What the script engine and the AI reported (serial > since)."); + gameMcpRegisterCommand("game.save", cmdSave, + "{filename?, description?} Saves the game in the save directory, windowed only (filename is a plain name, .sav is added; none: a new numbered file)."); + gameMcpRegisterCommand("game.load", cmdLoad, + "{filename, paused=true} Loads a save from the save directory as the in-game load menu does (windowed only), then pauses."); gameMcpRegisterCommand("game.quit", cmdQuit, "Ends the game and closes it."); } diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index de87da2ddd0..1e851e9a8a2 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -180,7 +180,7 @@ generalszh.exe -mcp -mcpGame "armed neighbors" -mcpPlayers FactionAmerica,Factio ## Tools -- **Time:** `game_status`, `game_pause`, `game_resume`, `game_step` (frames or seconds, then pause; waits until done), `game_set_speed` (30 = normal, 0 = as fast as possible), `game_quit`. +- **Time:** `game_status`, `game_pause`, `game_resume`, `game_step` (frames or seconds, then pause; waits until done), `game_set_speed` (30 = normal, 0 = as fast as possible), `game_save` / `game_load` (save files in the save directory, like the in-game menu; windowed games only; load pauses), `game_quit`. - **Looking:** `objects_list` (filter by player, template, kindof, status, area), `objects_get` (status, disabled, kindof, veterancy, AI state, weapons, upgrades, bonuses such as weapon bonus conditions and armor/sight/speed scalars, special powers, production, passengers, behavior modules, model conditions), `objects_templates`, `players_list`, `players_get` (also battle plan counts), `game_messages` (what the script engine and the AI reported). - **Orders** (as the owning player would click them; recorded in the replay; carried out on the next logic frame): `orders_move`, `orders_attack`, `orders_stop`, `orders_guard`, `orders_sell`, `orders_build`, `orders_produce`, `orders_upgrade`, `orders_special_power`, `orders_purchase_science`. - **Test changes** (direct, not in the replay): `objects_spawn`, `objects_destroy`, `objects_set` (health, position, veterancy, upgrade, status), `players_set` (money, rank, points, sciences, upgrades, instant build, special power delays). diff --git a/scripts/worldbuilder-mcp/game_server.py b/scripts/worldbuilder-mcp/game_server.py index 0d925b32e2c..b638985c53f 100644 --- a/scripts/worldbuilder-mcp/game_server.py +++ b/scripts/worldbuilder-mcp/game_server.py @@ -37,6 +37,7 @@ frames and pauses again (it waits until they are done). game_set_speed changes how fast it runs (0 = as fast as possible). - Orders take effect on the next logic frame: give them while paused, then game_step. +- game_save / game_load write and read save files like the in-game menu (not headless; load pauses afterwards). Looking: - objects_list (filters: player, template, kindof, status, area) gives ids, positions, health; @@ -201,6 +202,21 @@ def game_messages(since: int = 0, contains: str | None = None, limit: int = 200) return game.call("game.messages", since=since, contains=contains, limit=limit) +@mcp.tool() +def game_save(filename: str | None = None, description: str | None = None) -> dict: + """Saves the running game like the in-game save menu, into the save directory (not headless: + a save also holds the client side). filename is a plain file name (.sav is added); without + one a new numbered file is created. Returns the file name and path.""" + return game.call("game.save", filename=filename, description=description) + + +@mcp.tool() +def game_load(filename: str, paused: bool = True) -> dict: + """Loads a save file from the save directory like the in-game load menu: the running game is + replaced by the saved one, at the saved frame. Not headless. Pauses afterwards unless paused=false.""" + return game.call("game.load", filename=filename, paused=paused) + + @mcp.tool() def game_quit(wait_seconds: float = 20) -> dict: """Ends the game and closes it.""" From 9d72a4ad3d17b0dbd9d6dcbcf516bdc04585b40b Mon Sep 17 00:00:00 2001 From: pWn3d Date: Sun, 18 Oct 2026 16:24:25 +0200 Subject: [PATCH 13/13] feat(specialpower): Adds OwnerOnlyTimer and FlashingTimer for special power countdowns OwnerOnlyTimer: a PublicTimer countdown is drawn only for the player that owns it, and it is registered for any object carrying the power, not only structures. FlashingTimer: the countdown is drawn bold and flashing like a ready superweapon the whole time. Together they let a marker object show its owner how long a temporary effect still lasts. Adds SPECIAL_ALL_BATTLE_PLANS_ACTIVE for the countdown of the all-battle-plans power. Co-Authored-By: Claude Opus 5.5 --- .../Code/GameEngine/Include/Common/SpecialPower.h | 4 ++++ .../GameEngine/Include/Common/SpecialPowerType.h | 1 + .../GameEngine/Source/Common/RTS/SpecialPower.cpp | 5 +++++ .../Code/GameEngine/Source/GameClient/InGameUI.cpp | 13 +++++++++++-- .../Object/SpecialPower/SpecialPowerModule.cpp | 13 +++++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/Common/SpecialPower.h b/GeneralsMD/Code/GameEngine/Include/Common/SpecialPower.h index eb488b2eb40..9d5ad1b1de2 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/SpecialPower.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/SpecialPower.h @@ -119,6 +119,8 @@ class SpecialPowerTemplate : public Overridable const AudioEventRTS *getInitiateSound() const { return &getFO()->m_initiateSound; } const AudioEventRTS *getInitiateAtTargetSound() const { return &getFO()->m_initiateAtLocationSound; } Bool hasPublicTimer() const { return getFO()->m_publicTimer; } + Bool isOwnerOnlyTimer() const { return getFO()->m_ownerOnlyTimer; } + Bool isFlashingTimer() const { return getFO()->m_flashingTimer; } Bool isSharedNSync() const { return getFO()->m_sharedNSync; } UnsignedInt getDetectionTime() const { return getFO()->m_detectionTime; } UnsignedInt getViewObjectDuration() const { return getFO()->m_viewObjectDuration; } @@ -155,6 +157,8 @@ class SpecialPowerTemplate : public Overridable Real m_viewObjectRange; ///< And how far that object can see. Real m_radiusCursorRadius; ///< size of radius cursor, if any Bool m_publicTimer; ///< display a countdown timer for this special power for all to see + Bool m_ownerOnlyTimer; ///< the public timer is shown only to its owner, and also for objects that are not structures + Bool m_flashingTimer; ///< the public timer flashes like a ready one the whole time, e.g. while an effect is active Bool m_sharedNSync; ///< If true, this is a special that is shared between all of a player's command centers Bool m_shortcutPower; ///< Is this shortcut power capable of being fired by the side panel? Bool m_needsTargetDesignator; ///< Is this special power only allowed to hit designated areas diff --git a/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h b/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h index d92973fba11..b0c34374d56 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h @@ -226,6 +226,7 @@ enum SpecialPowerType CPP_11(: Int) SPECIAL_JUMPJET, SPECIAL_ALL_BATTLE_PLANS, + SPECIAL_ALL_BATTLE_PLANS_ACTIVE, SPECIALPOWER_COUNT, // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp index 0087b166e7d..d324cd39876 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp @@ -225,6 +225,7 @@ const char* const SpecialPowerMaskType::s_bitNameList[] = "SPECIAL_JUMPJET", "SPECIAL_ALL_BATTLE_PLANS", + "SPECIAL_ALL_BATTLE_PLANS_ACTIVE", nullptr }; @@ -293,6 +294,8 @@ void SpecialPowerStore::parseSpecialPowerDefinition( INI *ini ) { "InitiateSound", INI::parseAudioEventRTS, nullptr, offsetof( SpecialPowerTemplate, m_initiateSound ) }, { "InitiateAtLocationSound", INI::parseAudioEventRTS, nullptr, offsetof( SpecialPowerTemplate, m_initiateAtLocationSound ) }, { "PublicTimer", INI::parseBool, nullptr, offsetof( SpecialPowerTemplate, m_publicTimer ) }, + { "OwnerOnlyTimer", INI::parseBool, nullptr, offsetof( SpecialPowerTemplate, m_ownerOnlyTimer ) }, + { "FlashingTimer", INI::parseBool, nullptr, offsetof( SpecialPowerTemplate, m_flashingTimer ) }, { "Enum", INI::parseIndexList, SpecialPowerMaskType::getBitNames(), offsetof( SpecialPowerTemplate, m_type ) }, { "DetectionTime", INI::parseDurationUnsignedInt, nullptr, offsetof( SpecialPowerTemplate, m_detectionTime ) }, { "SharedSyncedTimer", INI::parseBool, nullptr, offsetof( SpecialPowerTemplate, m_sharedNSync ) }, @@ -326,6 +329,8 @@ SpecialPowerTemplate::SpecialPowerTemplate() m_reloadTime = 0; m_requiredScience = SCIENCE_INVALID; m_publicTimer = FALSE; + m_ownerOnlyTimer = FALSE; + m_flashingTimer = FALSE; m_detectionTime = DEFAULT_DEFECTION_DETECTION_PROTECTION_TIME_LIMIT; m_sharedNSync = FALSE; m_viewObjectDuration = 0; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 2bc121e7b12..dc5dec94b95 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -4242,6 +4242,12 @@ void InGameUI::postDraw() { SuperweaponInfo *info = *listIt; DEBUG_ASSERTCRASH(info, ("No superweapon info!")); + // Owner-only timers are drawn only for the player they belong to. + if (info && info->getSpecialPowerTemplate()->isOwnerOnlyTimer() && + (ThePlayerList->getLocalPlayer() == nullptr || i != ThePlayerList->getLocalPlayer()->getPlayerIndex())) + { + continue; + } if (info && !info->m_hiddenByScript && !info->m_hiddenByScience) { //enforce bottom margin of tactical view @@ -4375,6 +4381,9 @@ void InGameUI::postDraw() info->m_evaReadyPlayed = false; // Reset Eva for next time } + // A flashing timer is drawn like a ready one while it counts down. + const Bool drawReady = isReady || module->getSpecialPowerTemplate()->isFlashingTimer(); + // draw the text if ( !m_superweaponHiddenByScript && !marginExceeded ) { @@ -4382,7 +4391,7 @@ void InGameUI::postDraw() Bool changeBolding = (readySecs != info->m_timestamp) || (isReady != info->m_ready) || info->m_forceUpdateText; if (changeBolding) { - if (isReady) + if (drawReady) { // go bold - we're good to go info->setFont( m_superweaponReadyFont, m_superweaponReadyPointSize, m_superweaponReadyBold ); @@ -4410,7 +4419,7 @@ void InGameUI::postDraw() info->setText(name, time); } - if (isReady) + if (drawReady) { if ( m_superweaponFlashDuration != 0.0f ) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp index 3a736ae9a01..2ab126c1ffe 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp @@ -151,6 +151,19 @@ SpecialPowerModule::SpecialPowerModule( Thing *thing, const ModuleData *moduleDa getSpecialPowerModuleData()->m_specialPowerTemplate ); } + // An owner-only timer is shown for any object that carries the power, not only for structures, + // e.g. an object that shows its owner how long a temporary effect still lasts. + if( m_pausedCount == 0 && + getSpecialPowerTemplate()->isSharedNSync() == FALSE && + getSpecialPowerTemplate()->hasPublicTimer() == TRUE && + getSpecialPowerTemplate()->isOwnerOnlyTimer() == TRUE && + getObject()->getControllingPlayer() ) + { + TheInGameUI->addSuperweapon( getObject()->getControllingPlayer()->getPlayerIndex(), + getPowerName(), + getObject()->getID(), + getSpecialPowerModuleData()->m_specialPowerTemplate ); + } }