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..7544b1c53d9 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,41 @@ 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 +``` + +### 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` 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 + +- 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/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/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 0d0d851eb63..d7f90d95e90 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,17 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality) delete threadData; } } + +#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 76b320ebcc6..59e11ab9e20 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 @@ -263,6 +264,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 @@ -487,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 @@ -571,6 +574,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 @@ -985,6 +989,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 @@ -1084,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 @@ -1250,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}) @@ -1272,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/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/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 7eb503a7303..b0c34374d56 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/SpecialPowerType.h @@ -225,6 +225,9 @@ enum SpecialPowerType CPP_11(: Int) SPECIAL_JUMPJET, + SPECIAL_ALL_BATTLE_PLANS, + SPECIAL_ALL_BATTLE_PLANS_ACTIVE, + 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/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/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/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..11338908862 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdObjects.cpp @@ -0,0 +1,408 @@ +/* +** 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/ArmorSet.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; +} + +/// 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()) { + 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, "bonuses")) { + result.set("bonuses", describeBonuses(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, 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."); +} + +#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..598f815e17a --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdPlayers.cpp @@ -0,0 +1,178 @@ +/* +** 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/Module/BattlePlanUpdate.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); + + // 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()) + .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, battle plans, 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..6e2f43c276e --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/Mcp/GameMcpCmdState.cpp @@ -0,0 +1,280 @@ +/* +** 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, save, load, 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/GameState.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()); +} + +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. + 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.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."); +} + +#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/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/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/Player.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp index 7a07b8f0a59..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 ) { @@ -3883,6 +3885,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 +4013,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 +4022,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 +4030,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/Common/RTS/SpecialPower.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp index ccfb0d12e97..d324cd39876 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/SpecialPower.cpp @@ -224,6 +224,9 @@ const char* const SpecialPowerMaskType::s_bitNameList[] = "SPECIAL_JUMPJET", + "SPECIAL_ALL_BATTLE_PLANS", + "SPECIAL_ALL_BATTLE_PLANS_ACTIVE", + nullptr }; static_assert(ARRAY_SIZE(SpecialPowerMaskType::s_bitNameList) == SpecialPowerMaskType::NumBits + 1, "Incorrect array size"); @@ -291,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 ) }, @@ -324,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/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/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/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/Object/Behavior/BattlePlanBonusBehavior.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Behavior/BattlePlanBonusBehavior.cpp index ef18151a195..581ca3111ce 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]); } @@ -313,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); } @@ -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]); } @@ -365,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])); } 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 ); + } } 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(); + +} 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/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); +} 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/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 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) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index ace22cd118b..1e851e9a8a2 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_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). +- **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..b638985c53f --- /dev/null +++ b/scripts/worldbuilder-mcp/game_server.py @@ -0,0 +1,465 @@ +"""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. +- 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; + objects_get gives everything about objects (status, weapons, AI state, upgrades, special powers, + 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). + +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_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.""" + 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", "bonuses", "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, 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) + + +@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, 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) + + +# -------------------------------------------------------------------------------------------------- +# 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"))