From 8bd1f3c49c056ff6e5d20619e8299eee6ee966e0 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Wed, 30 Sep 2026 18:26:10 +0200 Subject: [PATCH 01/30] feat(worldbuilder): Adds MCP bridge for AI-driven map editing WorldBuilder started with -mcp (or -mcpport:N / WB_MCP_PORT) listens on 127.0.0.1 for line-delimited JSON commands. A worker thread services the socket and hands every command to the UI thread, where it runs through the normal undo system. Commands cover map new/open/save/settings, undo/redo, camera and 3D screenshots, terrain heights, brushes, textures and passability, objects, players and teams, waypoints, trigger and water polygons, and roads. scripts/worldbuilder-mcp contains the Python MCP server (uv project) that exposes these commands as MCP tools, a smoke test, and a README. The repo .mcp.json registers it for Claude Code. Also adds -ignoreAsserts to WorldBuilder so debug-build assertions are logged instead of opening blocking dialogs, which would otherwise stall both startup with mod data and bridge commands. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 +- .mcp.json | 8 + .../Code/Tools/WorldBuilder/CMakeLists.txt | 14 + .../WorldBuilder/include/WorldBuilderDoc.h | 3 + .../WorldBuilder/include/mcp/McpCommands.h | 97 ++ .../Tools/WorldBuilder/include/mcp/McpJson.h | 89 ++ .../WorldBuilder/include/mcp/McpScreenshot.h | 27 + .../WorldBuilder/include/mcp/McpServer.h | 40 + .../Tools/WorldBuilder/include/wbview3d.h | 10 + .../Tools/WorldBuilder/src/WorldBuilder.cpp | 26 +- .../WorldBuilder/src/WorldBuilderDoc.cpp | 18 +- .../Tools/WorldBuilder/src/mcp/McpCmdMap.cpp | 339 +++++++ .../WorldBuilder/src/mcp/McpCmdObjects.cpp | 428 ++++++++ .../WorldBuilder/src/mcp/McpCmdTerrain.cpp | 495 ++++++++++ .../WorldBuilder/src/mcp/McpCmdWaypoints.cpp | 400 ++++++++ .../WorldBuilder/src/mcp/McpCommands.cpp | 490 ++++++++++ .../Tools/WorldBuilder/src/mcp/McpJson.cpp | 435 +++++++++ .../WorldBuilder/src/mcp/McpScreenshot.cpp | 136 +++ .../Tools/WorldBuilder/src/mcp/McpServer.cpp | 342 +++++++ .../Code/Tools/WorldBuilder/src/wbview3d.cpp | 21 + scripts/worldbuilder-mcp/README.md | 64 ++ scripts/worldbuilder-mcp/pyproject.toml | 12 + scripts/worldbuilder-mcp/server.py | 397 ++++++++ scripts/worldbuilder-mcp/smoke_test.py | 92 ++ scripts/worldbuilder-mcp/uv.lock | 917 ++++++++++++++++++ scripts/worldbuilder-mcp/wb_bridge.py | 84 ++ 26 files changed, 4984 insertions(+), 4 deletions(-) create mode 100644 .mcp.json create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpJson.h create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpScreenshot.h create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpServer.h create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpJson.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpScreenshot.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpServer.cpp create mode 100644 scripts/worldbuilder-mcp/README.md create mode 100644 scripts/worldbuilder-mcp/pyproject.toml create mode 100644 scripts/worldbuilder-mcp/server.py create mode 100644 scripts/worldbuilder-mcp/smoke_test.py create mode 100644 scripts/worldbuilder-mcp/uv.lock create mode 100644 scripts/worldbuilder-mcp/wb_bridge.py diff --git a/.gitignore b/.gitignore index c8d724bcaef..c2d45999a89 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !.gitattributes !.github !.gitmodules +!.mcp.json *.user *.ncb @@ -66,4 +67,5 @@ build.ninja ## Python __pycache__/ -*.pyc \ No newline at end of file +*.pyc +.venv/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000000..7a616be850e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "worldbuilder": { + "command": "uv", + "args": ["run", "--directory", "scripts/worldbuilder-mcp", "server.py"] + } + } +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index e7ca1b259d1..ebdb62136d3 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -195,6 +195,18 @@ set(WORLDBUILDER_SRC "include/WorldBuilder.h" "include/WorldBuilderDoc.h" "include/WorldBuilderView.h" + "include/mcp/McpCommands.h" + "include/mcp/McpJson.h" + "include/mcp/McpScreenshot.h" + "include/mcp/McpServer.h" + "src/mcp/McpCmdMap.cpp" + "src/mcp/McpCmdObjects.cpp" + "src/mcp/McpCmdTerrain.cpp" + "src/mcp/McpCmdWaypoints.cpp" + "src/mcp/McpCommands.cpp" + "src/mcp/McpJson.cpp" + "src/mcp/McpScreenshot.cpp" + "src/mcp/McpServer.cpp" ) add_executable(z_worldbuilder WIN32) @@ -224,6 +236,8 @@ target_link_libraries(z_worldbuilder PRIVATE z_gameengine z_gameenginedevice zi_always + stb + ws2_32 ) if(WIN32 OR "${CMAKE_SYSTEM}" MATCHES "Windows") diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h index 4bd88712af4..76af410d780 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h @@ -149,6 +149,9 @@ class CWorldBuilderDoc : public CDocument void autoSave(); void validate(); + + /// Makes the next OnNewDocument use these settings instead of asking with the New Map dialog. + static void setNewMapOverride(Int xExtent, Int yExtent, Int initialHeight, Int border); // Operations public: diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h new file mode 100644 index 00000000000..6a6e941d9e9 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -0,0 +1,97 @@ +/* +** 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 . +*/ + +// McpCommands.h +// Command table and shared helpers for the WorldBuilder MCP command bridge. +// All handlers run on the MFC UI thread. + +#pragma once + +#include "mcp/McpJson.h" + +class CWorldBuilderDoc; +class WorldHeightMapEdit; +class MapObject; +class PolygonTrigger; +class Undoable; + +/// Thrown by command handlers to report a user-facing error. +struct McpError +{ + std::string message; +}; + +typedef McpJson (*McpHandler)(const McpJson &args); + +void mcpRegisterCommand(const char *name, McpHandler handler, const char *help); + +/// Parses one request line, runs the command and returns the reply line (without newline). +std::string mcpDispatch(const std::string &requestLine); + +// Per-area registration, called once from mcpDispatch. +void mcpRegisterMapCommands(); +void mcpRegisterTerrainCommands(); +void mcpRegisterObjectCommands(); +void mcpRegisterWaypointCommands(); + +//------------------------------------------------------------------------------------------------- +// Helpers +//------------------------------------------------------------------------------------------------- + +[[noreturn]] void mcpFail(const char *fmt, ...); + +double mcpArgNumber(const McpJson &args, const char *key); +double mcpArgNumber(const McpJson &args, const char *key, double defaultValue); +int mcpArgInt(const McpJson &args, const char *key); +int mcpArgInt(const McpJson &args, const char *key, int defaultValue); +bool mcpArgBool(const McpJson &args, const char *key, bool defaultValue); +std::string mcpArgString(const McpJson &args, const char *key); +std::string mcpArgString(const McpJson &args, const char *key, const char *defaultValue); +const McpJson &mcpArgArray(const McpJson &args, const char *key); + +CWorldBuilderDoc *mcpDoc(); +WorldHeightMapEdit *mcpHeightMap(); + +/// Commits an undoable to the active document, which takes ownership. +void mcpCommit(Undoable *undo); + +/// Heightmap vertex index for a world coordinate (nearest vertex, not clamped). +int mcpWorldToIndexX(double worldX); +int mcpWorldToIndexY(double worldY); +double mcpIndexToWorldX(int ndx); +double mcpIndexToWorldY(int ndx); + +/// Case-insensitive substring test used by list filters. +bool mcpContainsNoCase(const char *haystack, const char *needle); + +/// Session-stable integer handles for map objects and polygon triggers. +int mcpObjectHandle(MapObject *obj); +MapObject *mcpFindObject(int handle); ///< Returns null if the object is no longer in the map. +MapObject *mcpRequireObject(int handle); +void mcpResetObjectHandles(); + +PolygonTrigger *mcpRequirePolygon(int id); + +/// Common JSON description of a map object. +McpJson mcpDescribeObject(MapObject *obj); + +/// Converts between engine property dictionaries and JSON objects. When writing, the type of a key +/// that already exists in the dict is kept; new keys get their type from the JSON value. +class Dict; +McpJson mcpDictToJson(const Dict &dict); +void mcpJsonToDict(const McpJson &props, Dict &dict); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpJson.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpJson.h new file mode 100644 index 00000000000..f6cebd46daf --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpJson.h @@ -0,0 +1,89 @@ +/* +** 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 . +*/ + +// McpJson.h +// Minimal JSON value used by the WorldBuilder MCP command bridge. + +#pragma once + +#include +#include + +class McpJson +{ +public: + enum Type + { + TYPE_NULL, + TYPE_BOOL, + TYPE_NUMBER, + TYPE_STRING, + TYPE_ARRAY, + TYPE_OBJECT + }; + + McpJson() : m_type(TYPE_NULL), m_bool(false), m_number(0) {} + McpJson(bool b) : m_type(TYPE_BOOL), m_bool(b), m_number(0) {} + McpJson(int n) : m_type(TYPE_NUMBER), m_bool(false), m_number(n) {} + McpJson(unsigned int n) : m_type(TYPE_NUMBER), m_bool(false), m_number(n) {} + McpJson(float n) : m_type(TYPE_NUMBER), m_bool(false), m_number(n) {} + McpJson(double n) : m_type(TYPE_NUMBER), m_bool(false), m_number(n) {} + McpJson(const char *s) : m_type(TYPE_STRING), m_bool(false), m_number(0), m_string(s ? s : "") {} + McpJson(const std::string &s) : m_type(TYPE_STRING), m_bool(false), m_number(0), m_string(s) {} + + static McpJson makeArray() { McpJson j; j.m_type = TYPE_ARRAY; return j; } + static McpJson makeObject() { McpJson j; j.m_type = TYPE_OBJECT; return j; } + + Type getType() const { return m_type; } + bool isNull() const { return m_type == TYPE_NULL; } + bool isBool() const { return m_type == TYPE_BOOL; } + bool isNumber() const { return m_type == TYPE_NUMBER; } + bool isString() const { return m_type == TYPE_STRING; } + bool isArray() const { return m_type == TYPE_ARRAY; } + bool isObject() const { return m_type == TYPE_OBJECT; } + + bool asBool() const { return m_bool; } + double asNumber() const { return m_number; } + const std::string &asString() const { return m_string; } + + // Array access. Out of range reads return a null value. + size_t size() const; + const McpJson &at(size_t ndx) const; + McpJson &push(const McpJson &value); + + // Object access. Missing keys read as a null value. + bool has(const char *key) const; + const McpJson &get(const char *key) const; + McpJson &set(const char *key, const McpJson &value); ///< Returns *this so calls can be chained. + size_t numKeys() const { return m_keys.size(); } + const std::string &keyAt(size_t ndx) const { return m_keys[ndx]; } + const McpJson &valueAt(size_t ndx) const { return m_values[ndx]; } + + std::string dump() const; + static bool parse(const std::string &text, McpJson &out, std::string &error); + +private: + void dumpTo(std::string &out) const; + + Type m_type; + bool m_bool; + double m_number; + std::string m_string; + std::vector m_keys; ///< Object keys, parallel to m_values. + std::vector m_values; ///< Array elements or object values. +}; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpScreenshot.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpScreenshot.h new file mode 100644 index 00000000000..a3ba3a46eaa --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpScreenshot.h @@ -0,0 +1,27 @@ +/* +** 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 . +*/ + +// McpScreenshot.h + +#pragma once + +/// Sets how the next capture is written. maxWidth <= 0 keeps the full size; jpegQuality <= 0 writes a PNG. +void mcpSetScreenshotOptions(int maxWidth, int jpegQuality); + +/// Writes the current D3D back buffer to an image file. Must be called between WW3D::Begin_Render and End_Render. +bool mcpWriteBackBufferImage(const char *path, int *outWidth, int *outHeight); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpServer.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpServer.h new file mode 100644 index 00000000000..ae06cf9ce71 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpServer.h @@ -0,0 +1,40 @@ +/* +** 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 . +*/ + +// McpServer.h +// Localhost TCP command server that lets an external MCP server drive WorldBuilder. +// +// Protocol: one JSON object per line in each direction. +// request: {"id":1,"cmd":"map.info","args":{}} +// reply: {"id":1,"ok":true,"result":{...}} or {"id":1,"ok":false,"error":"..."} +// The socket is serviced on a worker thread; every command runs on the UI thread. + +#pragma once + +namespace McpServer +{ + enum { DEFAULT_PORT = 47800 }; + + /// Returns the port requested by "-mcp" / "-mcpport:N" or the WB_MCP_PORT environment variable, or 0 if not enabled. + int getRequestedPort(); + + /// Must be called on the UI thread after the main window exists. + bool start(int port); + void stop(); + bool isRunning(); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/wbview3d.h b/GeneralsMD/Code/Tools/WorldBuilder/include/wbview3d.h index 953fd38c154..23579a68f04 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/wbview3d.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/wbview3d.h @@ -189,6 +189,10 @@ class WbView3d : public WbView, public DX8_CleanupHook Bool m_showWireframe; Bool m_ww3dInited; Bool m_needToLoadRoads; + const char *m_capturePath; ///< When set, render() writes the frame to this image file. + Bool m_captureOk; + Int m_captureWidth; + Int m_captureHeight; LightClass *m_globalLight[MAX_GLOBAL_LIGHTS]; RenderObjClass *m_lightFeedbackMesh[MAX_GLOBAL_LIGHTS]; @@ -265,6 +269,12 @@ class WbView3d : public WbView, public DX8_CleanupHook Vector3 getCameraSource() { return m_cameraSource; } Vector3 getCameraTarget() { return m_cameraTarget; } Real getCameraAngle() { return m_cameraAngle; } + void setCameraAngle(Real angle) { m_cameraAngle = angle; } + Real getZoomOffset() { return m_mouseWheelOffset; } + void setZoomOffset(Real offset) { m_mouseWheelOffset = offset; } + /// Renders one frame and writes it to an image file before it is presented (see mcpSetScreenshotOptions). + /// Returns false if the view could not render. + Bool captureToFile(const char *path, Int *width, Int *height); CPoint getActualWinSize() {return m_actualWinSize;} virtual MapObject *picked3dObjectInView(CPoint viewPt) override; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilder.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilder.cpp index aff95f9ce83..77d08aca9e4 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilder.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilder.cpp @@ -29,6 +29,7 @@ #include "WorldBuilderView.h" #include "WBFrameWnd.h" #include "wbview3d.h" +#include "mcp/McpServer.h" //#include #include "W3DDevice/GameClient/W3DFileSystem.h" @@ -335,7 +336,19 @@ BOOL CWorldBuilderApp::InitInstance() INI ini; - initSubsystem(TheWritableGlobalData, new GlobalData(), "Data\\INI\\Default\\GameData", "Data\\INI\\GameData"); + // -ignoreAsserts works like in the game: asserts are only logged instead of opening a blocking dialog. + // It must be set before GameData.ini is parsed, since mod data can already assert there. + Bool ignoreAsserts = false; + for (Int arg = 1; arg < __argc; arg++) { + if (_stricmp(__argv[arg], "-ignoreAsserts") == 0) { + ignoreAsserts = true; + } + } + GlobalData *globalData = new GlobalData(); +#ifdef DEBUG_CRASHING + globalData->m_debugIgnoreAsserts = ignoreAsserts; +#endif + initSubsystem(TheWritableGlobalData, globalData, "Data\\INI\\Default\\GameData", "Data\\INI\\GameData"); initSubsystem(TheWriteableMapData, new MapData()); TheFramePacer = new FramePacer(); @@ -345,7 +358,7 @@ BOOL CWorldBuilderApp::InitInstance() #endif #ifdef DEBUG_CRASHING - TheWritableGlobalData->m_debugIgnoreAsserts = false; + TheWritableGlobalData->m_debugIgnoreAsserts = ignoreAsserts; #endif DEBUG_LOG(("TheWritableGlobalData %x", TheWritableGlobalData)); @@ -470,6 +483,14 @@ BOOL CWorldBuilderApp::InitInstance() CString openDir = this->GetProfileString(APP_SECTION, OPEN_FILE_DIR); m_currentDirectory = openDir; + // Optional localhost command bridge used by the WorldBuilder MCP server (scripts/worldbuilder-mcp). + Int mcpPort = McpServer::getRequestedPort(); + if (mcpPort > 0 && !McpServer::start(mcpPort)) { + CString msg; + msg.Format("Could not start the MCP bridge on port %d. Is another WorldBuilder already using it?", mcpPort); + ::AfxMessageBox(msg, MB_OK | MB_ICONEXCLAMATION); + } + return TRUE; } @@ -629,6 +650,7 @@ void CWorldBuilderApp::OnAppAbout() int CWorldBuilderApp::ExitInstance() { + McpServer::stop(); WriteProfileString(APP_SECTION, OPEN_FILE_DIR, m_currentDirectory.str()); m_currentDirectory.clear(); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilderDoc.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilderDoc.cpp index 3ea9a6ec2b5..629cd6e02c4 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilderDoc.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilderDoc.cpp @@ -1224,6 +1224,19 @@ void CWorldBuilderDoc::Create3DView() } +static Bool s_haveNewMapOverride = false; +static TNewHeightInfo s_newMapOverride; + +void CWorldBuilderDoc::setNewMapOverride(Int xExtent, Int yExtent, Int initialHeight, Int border) +{ + s_newMapOverride.xExtent = xExtent; + s_newMapOverride.yExtent = yExtent; + s_newMapOverride.initialHeight = initialHeight; + s_newMapOverride.borderWidth = border; + s_newMapOverride.forResize = false; + s_haveNewMapOverride = true; +} + BOOL CWorldBuilderDoc::OnNewDocument() { #ifdef ONLY_ONE_AT_A_TIME @@ -1246,7 +1259,10 @@ BOOL CWorldBuilderDoc::OnNewDocument() hi.yExtent = AfxGetApp()->GetProfileInt("GameOptions", "Default Map Y-size", 100); hi.borderWidth = AfxGetApp()->GetProfileInt("GameOptions", "Default Map Border", 30); hi.forResize = false; - if (!firstTime) { + if (s_haveNewMapOverride) { + hi = s_newMapOverride; + s_haveNewMapOverride = false; + } else if (!firstTime) { CString label; label.LoadString(IDS_NEW); CNewHeightMap htDialog(&hi, label); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp new file mode 100644 index 00000000000..8ea5233f734 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp @@ -0,0 +1,339 @@ +/* +** 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 . +*/ + +// McpCmdMap.cpp +// MCP bridge commands for map management, undo and the 3D view. + +#include "StdAfx.h" +#include "mcp/McpCommands.h" +#include "mcp/McpScreenshot.h" + +#include "resource.h" +#include "WHeightMapEdit.h" +#include "WorldBuilder.h" +#include "WorldBuilderDoc.h" +#include "wbview3d.h" +#include "Common/GameType.h" +#include "Common/GlobalData.h" +#include "Common/MapObject.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/PolygonTrigger.h" + +#include + +namespace +{ + +McpJson cmdMapInfo(const McpJson &) +{ + CWorldBuilderDoc *doc = mcpDoc(); + WorldHeightMapEdit *map = mcpHeightMap(); + const Int border = map->getBorderSize(); + const Int xExtent = map->getXExtent(); + const Int yExtent = map->getYExtent(); + + McpJson j = McpJson::makeObject(); + j.set("path", (LPCTSTR)doc->GetPathName()); + j.set("title", (LPCTSTR)doc->GetTitle()); + j.set("modified", doc->IsModified() != 0); + j.set("map_name", MapObject::getWorldDict()->getAsciiString(TheKey_mapName).str()); + + McpJson grid = McpJson::makeObject(); + grid.set("x_extent", xExtent).set("y_extent", yExtent).set("border", border); + grid.set("playable_cells_x", xExtent - 1 - 2 * border).set("playable_cells_y", yExtent - 1 - 2 * border); + j.set("heightmap", grid); + + McpJson world = McpJson::makeObject(); + world.set("min_x", -border * MAP_XY_FACTOR).set("min_y", -border * MAP_XY_FACTOR); + world.set("max_x", (xExtent - 1 - border) * MAP_XY_FACTOR).set("max_y", (yExtent - 1 - border) * MAP_XY_FACTOR); + world.set("playable_max_x", (xExtent - 1 - 2 * border) * MAP_XY_FACTOR); + world.set("playable_max_y", (yExtent - 1 - 2 * border) * MAP_XY_FACTOR); + world.set("cell_size", MAP_XY_FACTOR).set("height_scale", MAP_HEIGHT_SCALE); + j.set("world", world); + + Int minH = 255, maxH = 0; + for (Int y = 0; y < yExtent; y++) { + for (Int x = 0; x < xExtent; x++) { + Int h = map->getHeight(x, y); + if (h < minH) minH = h; + if (h > maxH) maxH = h; + } + } + j.set("height_raw_min", minH).set("height_raw_max", maxH); + + Int numObjects = 0, numWaypoints = 0, numRoadPoints = 0; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (obj->isWaypoint()) numWaypoints++; + else if (obj->getFlags() & (FLAG_ROAD_FLAGS | FLAG_BRIDGE_FLAGS)) numRoadPoints++; + else numObjects++; + } + Int numPolygons = 0, numWater = 0; + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + numPolygons++; + if (trig->isWaterArea()) numWater++; + } + McpJson counts = McpJson::makeObject(); + counts.set("objects", numObjects).set("waypoints", numWaypoints).set("road_points", numRoadPoints); + counts.set("polygons", numPolygons).set("water_areas", numWater); + j.set("counts", counts); + + McpJson boundaries = McpJson::makeArray(); + for (Int i = 0; i < doc->getNumBoundaries(); i++) { + ICoord2D b; + doc->getBoundary(i, &b); + boundaries.push(McpJson::makeObject().set("width_cells", b.x).set("height_cells", b.y)); + } + j.set("boundaries", boundaries); + + Int tod = TheGlobalData->m_timeOfDay; + j.set("time_of_day", (tod >= TIME_OF_DAY_FIRST && tod < TIME_OF_DAY_COUNT) ? TimeOfDayNames[tod] : "INVALID"); + Int weather = TheGlobalData->m_weather; + j.set("weather", (weather >= 0 && weather < WEATHER_COUNT) ? WeatherNames[weather] : "INVALID"); + return j; +} + +void requireUnmodifiedOrDiscard(const McpJson &args) +{ + CWorldBuilderDoc *doc = CWorldBuilderDoc::GetActiveDoc(); + if (doc == nullptr || !doc->IsModified()) { + return; + } + if (!mcpArgBool(args, "discard_changes", false)) { + mcpFail("the open map has unsaved changes; save it first or pass discard_changes=true"); + } + doc->SetModifiedFlag(FALSE); +} + +McpJson cmdMapNew(const McpJson &args) +{ + const Int width = mcpArgInt(args, "width", 128); + const Int height = mcpArgInt(args, "height", 128); + const Int initialHeight = mcpArgInt(args, "initial_height", 16); + const Int border = mcpArgInt(args, "border", 30); + if (width < 8 || height < 8 || width > 1024 || height > 1024) { + mcpFail("width and height must be between 8 and 1024 cells"); + } + if (initialHeight < 0 || initialHeight > 255) { + mcpFail("initial_height must be 0..255"); + } + if (border < 0 || border > 100) { + mcpFail("border must be 0..100"); + } + requireUnmodifiedOrDiscard(args); + + // WorldHeightMapEdit adds the border on both sides itself. + CWorldBuilderDoc::setNewMapOverride(width, height, initialHeight, border); + AfxGetMainWnd()->SendMessage(WM_COMMAND, ID_FILE_NEW); + mcpResetObjectHandles(); + return cmdMapInfo(args); +} + +McpJson cmdMapOpen(const McpJson &args) +{ + std::string path = mcpArgString(args, "path"); + if (GetFileAttributes(path.c_str()) == INVALID_FILE_ATTRIBUTES) { + mcpFail("file not found: %s", path.c_str()); + } + requireUnmodifiedOrDiscard(args); + CDocument *opened = WbApp()->OpenDocumentFile(path.c_str()); + mcpResetObjectHandles(); + if (opened == nullptr) { + mcpFail("WorldBuilder could not open %s", path.c_str()); + } + return cmdMapInfo(args); +} + +McpJson cmdMapSave(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + CString path = mcpArgString(args, "path", "").c_str(); + if (path.IsEmpty()) { + path = doc->GetPathName(); + } + if (path.IsEmpty()) { + mcpFail("the map has never been saved; pass a full .map path"); + } + if (path.Right(4).CompareNoCase(".map") != 0) { + mcpFail("path must end in .map"); + } + if (!doc->DoSave(path, TRUE)) { + mcpFail("saving to %s failed", (LPCTSTR)path); + } + return McpJson::makeObject().set("path", (LPCTSTR)doc->GetPathName()); +} + +McpJson cmdMapSetInfo(const McpJson &args) +{ + mcpDoc(); + if (args.has("name")) { + MapObject::getWorldDict()->setAsciiString(TheKey_mapName, AsciiString(mcpArgString(args, "name").c_str())); + } + if (args.has("time_of_day")) { + std::string tod = mcpArgString(args, "time_of_day"); + Int found = -1; + for (Int i = TIME_OF_DAY_FIRST; i < TIME_OF_DAY_COUNT; i++) { + if (_stricmp(tod.c_str(), TimeOfDayNames[i]) == 0) found = i; + } + if (found < 0) { + mcpFail("time_of_day must be MORNING, AFTERNOON, EVENING or NIGHT"); + } + TheWritableGlobalData->setTimeOfDay((TimeOfDay)found); + } + if (args.has("weather")) { + std::string weather = mcpArgString(args, "weather"); + Int found = -1; + for (Int i = 0; i < WEATHER_COUNT; i++) { + if (_stricmp(weather.c_str(), WeatherNames[i]) == 0) found = i; + } + if (found < 0) { + mcpFail("weather must be NORMAL or SNOWY"); + } + TheWritableGlobalData->m_weather = (Weather)found; + } + mcpDoc()->SetModifiedFlag(); + WbView3d *view = CWorldBuilderDoc::GetActive3DView(); + if (view) { + view->resetRenderObjects(); + view->invalObjectInView(nullptr); + } + return cmdMapInfo(args); +} + +McpJson cmdUndoRedo(const McpJson &args, UINT commandId) +{ + mcpDoc(); + const Int count = mcpArgInt(args, "count", 1); + if (count < 1 || count > MAX_UNDOS) { + mcpFail("count must be 1..%d", MAX_UNDOS); + } + for (Int i = 0; i < count; i++) { + AfxGetMainWnd()->SendMessage(WM_COMMAND, commandId); + } + return McpJson::makeObject().set("steps", count); +} + +McpJson cmdUndo(const McpJson &args) +{ + return cmdUndoRedo(args, ID_EDIT_UNDO); +} + +McpJson cmdRedo(const McpJson &args) +{ + return cmdUndoRedo(args, ID_EDIT_REDO); +} + +WbView3d *require3DView() +{ + mcpDoc(); + WbView3d *view = CWorldBuilderDoc::GetActive3DView(); + if (view == nullptr) { + mcpFail("no 3D view is open"); + } + return view; +} + +McpJson describeCamera(WbView3d *view) +{ + McpJson j = McpJson::makeObject(); + j.set("angle_deg", view->getCameraAngle() * 180.0 / PI); + j.set("pitch", view->getCameraPitch()); + j.set("zoom", view->getZoomOffset()); + Vector3 target = view->getCameraTarget(); + j.set("target_x", target.X).set("target_y", target.Y).set("target_z", target.Z); + return j; +} + +void applyCamera(WbView3d *view, const McpJson &args) +{ + if (mcpArgBool(args, "reset", false)) { + view->setDefaultCamera(); + } + if (args.has("angle_deg")) { + view->setCameraAngle((Real)(mcpArgNumber(args, "angle_deg") * PI / 180.0)); + } + if (args.has("zoom")) { + view->setZoomOffset((Real)mcpArgNumber(args, "zoom")); + } + if (args.has("pitch")) { + view->setCameraPitch((Real)mcpArgNumber(args, "pitch")); + } + if (args.has("x") || args.has("y")) { + Vector3 target = view->getCameraTarget(); + Real x = (Real)mcpArgNumber(args, "x", target.X); + Real y = (Real)mcpArgNumber(args, "y", target.Y); + // The view center is kept in cell units. + view->setCenterInView(x / MAP_XY_FACTOR, y / MAP_XY_FACTOR); + } + view->redraw(); +} + +McpJson cmdSetCamera(const McpJson &args) +{ + WbView3d *view = require3DView(); + applyCamera(view, args); + return describeCamera(view); +} + +McpJson cmdScreenshot(const McpJson &args) +{ + WbView3d *view = require3DView(); + applyCamera(view, args); + + const std::string format = mcpArgString(args, "format", "jpg"); + if (format != "jpg" && format != "png") { + mcpFail("format must be jpg or png"); + } + const Int maxWidth = mcpArgInt(args, "max_width", 1280); + const Int quality = mcpArgInt(args, "quality", 85); + if (quality < 1 || quality > 100) { + mcpFail("quality must be 1..100"); + } + + std::string path = mcpArgString(args, "path", ""); + if (path.empty()) { + static Int s_shotCounter = 0; + char tempDir[MAX_PATH]; + ::GetTempPath(MAX_PATH, tempDir); + char name[MAX_PATH]; + sprintf(name, "%swb_mcp_%u_%d.%s", tempDir, (unsigned)::GetCurrentProcessId(), ++s_shotCounter, format.c_str()); + path = name; + } + mcpSetScreenshotOptions(maxWidth, format == "jpg" ? quality : 0); + Int width = 0, height = 0; + if (!view->captureToFile(path.c_str(), &width, &height)) { + mcpFail("could not capture the 3D view (is the WorldBuilder window visible and not minimized?)"); + } + McpJson j = describeCamera(view); + j.set("path", path).set("format", format).set("width", width).set("height", height); + return j; +} + +} // namespace + +void mcpRegisterMapCommands() +{ + mcpRegisterCommand("map.info", cmdMapInfo, "Map size, borders, world extents, counts, name, weather."); + mcpRegisterCommand("map.new", cmdMapNew, "{width,height,initial_height,border,discard_changes} Creates a new map (sizes in cells, excluding border)."); + mcpRegisterCommand("map.open", cmdMapOpen, "{path,discard_changes} Opens a .map file."); + mcpRegisterCommand("map.save", cmdMapSave, "{path?} Saves the map; path required for untitled maps."); + mcpRegisterCommand("map.set_info", cmdMapSetInfo, "{name?,time_of_day?,weather?} Changes map settings."); + mcpRegisterCommand("edit.undo", cmdUndo, "{count?} Undoes the last edits."); + mcpRegisterCommand("edit.redo", cmdRedo, "{count?} Redoes undone edits."); + mcpRegisterCommand("view.set_camera", cmdSetCamera, "{x?,y?,angle_deg?,pitch?,zoom?,reset?} Moves the 3D camera."); + mcpRegisterCommand("view.screenshot", cmdScreenshot, "{path?,format?:jpg|png,max_width?,quality?,x?,y?,angle_deg?,pitch?,zoom?,reset?} Renders the 3D view to an image."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp new file mode 100644 index 00000000000..32b5b99f49f --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp @@ -0,0 +1,428 @@ +/* +** 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 . +*/ + +// McpCmdObjects.cpp +// MCP bridge commands for map objects, players and teams. + +#include "StdAfx.h" +#define DEFINE_EDITOR_SORTING_NAMES +#include "mcp/McpCommands.h" + +#include "CUndoable.h" +#include "PointerTool.h" +#include "WorldBuilderDoc.h" +#include "wbview3d.h" +#include "Common/NameKeyGenerator.h" +#include "Common/PlayerTemplate.h" +#include "Common/ThingFactory.h" +#include "Common/ThingSort.h" +#include "Common/ThingTemplate.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/SidesList.h" + +#include +#include + +namespace +{ + +/// Moves, rotates or re-templates objects. The edits are applied when constructed, like ModifyObjectUndoable. +class McpModifyObjectsUndoable : public Undoable +{ +public: + McpModifyObjectsUndoable(CWorldBuilderDoc *doc) : m_doc(doc), m_list(nullptr), m_tail(nullptr), m_templateChanged(false) {} + virtual ~McpModifyObjectsUndoable() override { delete m_list; } + + MoveInfo *add(MapObject *obj) + { + MoveInfo *info = new MoveInfo(obj); + if (m_tail) m_tail->m_next = info; else m_list = info; + m_tail = info; + return info; + } + void setTemplateChanged() { m_templateChanged = true; } + + virtual void Do() override { apply(true); } + virtual void Undo() override { apply(false); } + +private: + void apply(bool forward) + { + for (MoveInfo *info = m_list; info; info = info->m_next) { + if (forward) info->DoMove(m_doc); else info->UndoMove(m_doc); + } + if (m_templateChanged) { + WbView3d *view = m_doc->GetActive3DView(); + if (view) { + view->resetRenderObjects(); + view->invalObjectInView(nullptr); + } + } + } + + CWorldBuilderDoc *m_doc; + MoveInfo *m_list; + MoveInfo *m_tail; + bool m_templateChanged; +}; + +std::vector requireObjects(const McpJson &args) +{ + std::vector objects; + if (args.has("id")) { + objects.push_back(mcpRequireObject(mcpArgInt(args, "id"))); + } + if (args.has("ids")) { + const McpJson &ids = mcpArgArray(args, "ids"); + for (size_t i = 0; i < ids.size(); i++) { + if (!ids.at(i).isNumber()) { + mcpFail("ids must be numbers"); + } + objects.push_back(mcpRequireObject((int)ids.at(i).asNumber())); + } + } + if (objects.empty()) { + mcpFail("pass 'id' or 'ids'"); + } + return objects; +} + +/// Accepts a team name ("teamPlayer_1"), a player name ("Player_1") or "" / "neutral" for the neutral team. +AsciiString resolveOwner(const std::string &owner) +{ + if (owner.empty() || _stricmp(owner.c_str(), "neutral") == 0) { + return AsciiString("team"); + } + AsciiString name(owner.c_str()); + Int ndx; + if (TheSidesList->findTeamInfo(name, &ndx)) { + return name; + } + AsciiString teamName; + teamName.format("team%s", owner.c_str()); + if (TheSidesList->findTeamInfo(teamName, &ndx)) { + return teamName; + } + mcpFail("unknown owner '%s' (see sides.list for team and player names)", owner.c_str()); + return AsciiString::TheEmptyString; +} + +const ThingTemplate *requireTemplate(const std::string &name) +{ + const ThingTemplate *tt = TheThingFactory->findTemplate(AsciiString(name.c_str()), FALSE); + if (tt == nullptr) { + mcpFail("unknown object template '%s' (see objects.list_templates)", name.c_str()); + } + return tt; +} + +const char *editorSortingName(EditorSortingType sorting) +{ + if (sorting >= ES_FIRST && sorting < ES_NUM_SORTING_TYPES && EditorSortingNames[sorting]) { + return EditorSortingNames[sorting]; + } + return "NONE"; +} + +//------------------------------------------------------------------------------------------------- +// Templates +//------------------------------------------------------------------------------------------------- + +McpJson cmdListTemplates(const McpJson &args) +{ + const std::string filter = mcpArgString(args, "filter", ""); + const std::string sorting = mcpArgString(args, "editor_sorting", ""); + const std::string side = mcpArgString(args, "side", ""); + const Int limit = mcpArgInt(args, "limit", 200); + const Int offset = mcpArgInt(args, "offset", 0); + + McpJson list = McpJson::makeArray(); + Int matched = 0; + for (const ThingTemplate *tt = TheThingFactory->firstTemplate(); tt; tt = tt->friend_getNextTemplate()) { + if (!mcpContainsNoCase(tt->getName().str(), filter.c_str())) continue; + const char *sortName = editorSortingName(tt->getEditorSorting()); + if (!sorting.empty() && _stricmp(sortName, sorting.c_str()) != 0) continue; + if (!side.empty() && _stricmp(tt->getDefaultOwningSide().str(), side.c_str()) != 0) continue; + matched++; + if (matched <= offset || (Int)list.size() >= limit) continue; + McpJson t = McpJson::makeObject(); + t.set("name", tt->getName().str()); + t.set("editor_sorting", sortName); + t.set("side", tt->getDefaultOwningSide().str()); + AsciiString displayName; + displayName.translate(tt->getDisplayName()); + if (!displayName.isEmpty()) t.set("display_name", displayName.str()); + if (tt->isBridge()) t.set("bridge", true); + list.push(t); + } + return McpJson::makeObject().set("total_matches", matched).set("templates", list); +} + +//------------------------------------------------------------------------------------------------- +// Sides +//------------------------------------------------------------------------------------------------- + +McpJson cmdListSides(const McpJson &) +{ + mcpDoc(); + McpJson players = McpJson::makeArray(); + for (Int i = 0; i < TheSidesList->getNumSides(); i++) { + Dict *d = TheSidesList->getSideInfo(i)->getDict(); + McpJson p = mcpDictToJson(*d); + AsciiString name = d->getAsciiString(TheKey_playerName); + p.set("is_neutral", name.isEmpty()); + players.push(p); + } + McpJson teams = McpJson::makeArray(); + for (Int i = 0; i < TheSidesList->getNumTeams(); i++) { + Dict *d = TheSidesList->getTeamInfo(i)->getDict(); + teams.push(McpJson::makeObject() + .set("name", d->getAsciiString(TheKey_teamName).str()) + .set("owner", d->getAsciiString(TheKey_teamOwner).str())); + } + return McpJson::makeObject().set("players", players).set("teams", teams); +} + +McpJson cmdListFactions(const McpJson &) +{ + McpJson list = McpJson::makeArray(); + for (Int i = 0; i < ThePlayerTemplateStore->getPlayerTemplateCount(); i++) { + const PlayerTemplate *pt = ThePlayerTemplateStore->getNthPlayerTemplate(i); + if (pt == nullptr) continue; + list.push(McpJson::makeObject().set("name", pt->getName().str()).set("side", pt->getSide().str())); + } + return McpJson::makeObject().set("factions", list); +} + +McpJson cmdAddPlayer(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + const std::string faction = mcpArgString(args, "faction"); + const PlayerTemplate *pt = ThePlayerTemplateStore->findPlayerTemplate(NAMEKEY(faction.c_str())); + if (pt == nullptr) { + mcpFail("unknown faction '%s' (see sides.list_factions)", faction.c_str()); + } + SidesList newSides = *TheSidesList; + if (newSides.getNumSides() >= MAX_PLAYER_COUNT - 1) { + mcpFail("the map already has the maximum number of players"); + } + + AsciiString name; + if (args.has("name")) { + name = mcpArgString(args, "name").c_str(); + if (name.isEmpty() || newSides.findSideInfo(name)) { + mcpFail("player name must be unique and not empty"); + } + } else { + Int num = 1; + do { + name.format("player%04d", num++); + } while (newSides.findSideInfo(name)); + } + UnicodeString displayName; + displayName.translate(AsciiString(mcpArgString(args, "display_name", name.str()).c_str())); + + Dict playerDict; + playerDict.setAsciiString(TheKey_playerName, name); + playerDict.setBool(TheKey_playerIsHuman, mcpArgBool(args, "is_human", true)); + playerDict.setUnicodeString(TheKey_playerDisplayName, displayName); + playerDict.setAsciiString(TheKey_playerFaction, pt->getName()); + playerDict.setAsciiString(TheKey_playerEnemies, AsciiString(mcpArgString(args, "enemies", "").c_str())); + playerDict.setAsciiString(TheKey_playerAllies, AsciiString(mcpArgString(args, "allies", "").c_str())); + newSides.addSide(&playerDict); + newSides.validateSides(); + + mcpCommit(new SidesListUndoable(newSides, doc)); + AsciiString teamName; + teamName.format("team%s", name.str()); + return McpJson::makeObject().set("player", name.str()).set("default_team", teamName.str()); +} + +//------------------------------------------------------------------------------------------------- +// Objects +//------------------------------------------------------------------------------------------------- + +McpJson cmdListObjects(const McpJson &args) +{ + mcpDoc(); + const std::string templateFilter = mcpArgString(args, "template", ""); + const std::string owner = mcpArgString(args, "owner", ""); + const bool includeAll = mcpArgBool(args, "include_waypoints_and_roads", false); + const Int limit = mcpArgInt(args, "limit", 500); + const bool hasRect = args.has("x0"); + double x0 = 0, y0 = 0, x1 = 0, y1 = 0; + if (hasRect) { + x0 = mcpArgNumber(args, "x0"); + y0 = mcpArgNumber(args, "y0"); + x1 = mcpArgNumber(args, "x1"); + y1 = mcpArgNumber(args, "y1"); + } + + McpJson list = McpJson::makeArray(); + Int matched = 0; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (!includeAll && (obj->isWaypoint() || (obj->getFlags() & (FLAG_ROAD_FLAGS | FLAG_BRIDGE_FLAGS)))) continue; + if (!mcpContainsNoCase(obj->getName().str(), templateFilter.c_str())) continue; + if (!owner.empty() && _stricmp(obj->getProperties()->getAsciiString(TheKey_originalOwner).str(), owner.c_str()) != 0) continue; + if (hasRect) { + const Coord3D *loc = obj->getLocation(); + if (loc->x < x0 || loc->x > x1 || loc->y < y0 || loc->y > y1) continue; + } + matched++; + if ((Int)list.size() < limit) { + list.push(mcpDescribeObject(obj)); + } + } + return McpJson::makeObject().set("total_matches", matched).set("objects", list); +} + +McpJson cmdGetObject(const McpJson &args) +{ + MapObject *obj = mcpRequireObject(mcpArgInt(args, "id")); + McpJson j = mcpDescribeObject(obj); + j.set("properties", mcpDictToJson(*obj->getProperties())); + return j; +} + +McpJson cmdPlaceObject(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + const ThingTemplate *tt = requireTemplate(mcpArgString(args, "template")); + Coord3D loc; + loc.x = (Real)mcpArgNumber(args, "x"); + loc.y = (Real)mcpArgNumber(args, "y"); + loc.z = (Real)mcpArgNumber(args, "z", 0); // 0 means "on the ground". + Real angle = args.has("angle_deg") ? (Real)(mcpArgNumber(args, "angle_deg") * PI / 180.0) : tt->getPlacementViewAngle(); + AsciiString owner = resolveOwner(mcpArgString(args, "owner", "")); + + Dict props; + if (args.has("properties")) { + mcpJsonToDict(args.get("properties"), props); + } + props.setAsciiString(TheKey_originalOwner, owner); + if (args.has("name")) { + props.setAsciiString(TheKey_objectName, AsciiString(mcpArgString(args, "name").c_str())); + } + + MapObject *obj = newInstance(MapObject)(loc, tt->getName(), angle, 0, &props, tt); + PointerTool::clearSelection(); + mcpCommit(new AddObjectUndoable(doc, obj)); + return mcpDescribeObject(obj); +} + +McpJson cmdModifyObjects(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + std::vector objects = requireObjects(args); + const ThingTemplate *tt = args.has("template") ? requireTemplate(mcpArgString(args, "template")) : nullptr; + if ((args.has("x") || args.has("y")) && objects.size() > 1) { + mcpFail("absolute x/y can only be set for one object; use dx/dy for several"); + } + + McpModifyObjectsUndoable *undo = new McpModifyObjectsUndoable(doc); + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < objects.size(); i++) { + MoveInfo *info = undo->add(objects[i]); + info->m_newLocation.x = (Real)mcpArgNumber(args, "x", info->m_oldLocation.x) + (Real)mcpArgNumber(args, "dx", 0); + info->m_newLocation.y = (Real)mcpArgNumber(args, "y", info->m_oldLocation.y) + (Real)mcpArgNumber(args, "dy", 0); + info->m_newLocation.z = (Real)mcpArgNumber(args, "z", info->m_oldLocation.z); + if (args.has("angle_deg")) { + info->m_newAngle = (Real)(mcpArgNumber(args, "angle_deg") * PI / 180.0); + } else if (args.has("rotate_deg")) { + info->m_newAngle = info->m_oldAngle + (Real)(mcpArgNumber(args, "rotate_deg") * PI / 180.0); + } + if (tt) { + info->m_newThing = tt; + info->m_newName = tt->getName(); + info->m_oldName = objects[i]->getName(); + undo->setTemplateChanged(); + } + } + mcpCommit(undo); + for (size_t i = 0; i < objects.size(); i++) { + list.push(mcpDescribeObject(objects[i])); + } + return McpJson::makeObject().set("objects", list); +} + +McpJson cmdSetProperties(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + std::vector objects = requireObjects(args); + McpJson props = args.get("properties"); + if (props.isNull()) { + props = McpJson::makeObject(); + } + if (args.has("owner")) { + props.set("originalOwner", McpJson(resolveOwner(mcpArgString(args, "owner")).str())); + } + if (args.has("name")) { + props.set("objectName", McpJson(mcpArgString(args, "name"))); + } + if (props.numKeys() == 0) { + mcpFail("nothing to change; pass properties, owner or name"); + } + + MultipleUndoable *undo = new MultipleUndoable; + for (size_t i = 0; i < objects.size(); i++) { + Dict newDict = *objects[i]->getProperties(); + try { + mcpJsonToDict(props, newDict); + } catch (const McpError &) { + REF_PTR_RELEASE(undo); + throw; + } + Dict *target = objects[i]->getProperties(); + undo->addUndoable(new DictItemUndoable(&target, newDict, NAMEKEY_INVALID, 1, doc, true)); + } + mcpCommit(undo); + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < objects.size(); i++) { + list.push(mcpDescribeObject(objects[i])); + } + return McpJson::makeObject().set("objects", list); +} + +McpJson cmdDeleteObjects(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + std::vector objects = requireObjects(args); + PointerTool::clearSelection(); + for (size_t i = 0; i < objects.size(); i++) { + objects[i]->setSelected(true); + } + mcpCommit(new DeleteObjectUndoable(doc)); + PointerTool::clearSelection(); + return McpJson::makeObject().set("deleted", (int)objects.size()); +} + +} // namespace + +void mcpRegisterObjectCommands() +{ + mcpRegisterCommand("objects.list_templates", cmdListTemplates, "{filter?,editor_sorting?,side?,limit?,offset?} Object templates that can be placed."); + mcpRegisterCommand("objects.list", cmdListObjects, "{template?,owner?,x0,y0,x1,y1?,include_waypoints_and_roads?,limit?} Objects in the map."); + mcpRegisterCommand("objects.get", cmdGetObject, "{id} One object with all its properties."); + mcpRegisterCommand("objects.place", cmdPlaceObject, "{template,x,y,z?,angle_deg?,owner?,name?,properties?} Places an object."); + mcpRegisterCommand("objects.modify", cmdModifyObjects, "{id|ids, x?,y?,dx?,dy?,z?,angle_deg?,rotate_deg?,template?} Moves, rotates or swaps objects."); + mcpRegisterCommand("objects.set_properties", cmdSetProperties, "{id|ids, properties?, owner?, name?} Changes object properties (null removes a key)."); + mcpRegisterCommand("objects.delete", cmdDeleteObjects, "{id|ids} Deletes objects."); + mcpRegisterCommand("sides.list", cmdListSides, "Players and teams; object owners are team names."); + mcpRegisterCommand("sides.list_factions", cmdListFactions, "Player templates usable with sides.add_player."); + mcpRegisterCommand("sides.add_player", cmdAddPlayer, "{faction,name?,display_name?,is_human?,allies?,enemies?} Adds a player and its default team."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp new file mode 100644 index 00000000000..5067c3dc5ba --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp @@ -0,0 +1,495 @@ +/* +** 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 . +*/ + +// McpCmdTerrain.cpp +// MCP bridge commands for terrain heights, textures and passability. +// +// Every edit follows the pattern of the interactive tools: duplicate the heightmap, modify the +// copy, refresh the views from it, then commit it with a WBDocUndoable so it can be undone. + +#include "StdAfx.h" +#include "mcp/McpCommands.h" + +#include "CUndoable.h" +#include "TerrainMaterial.h" +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" + +#include +#include + +namespace +{ + +const Int MAX_HEIGHT_VALUES = 256 * 256; + +/// Tracks the touched vertex range of an edit so only that part of the views is refreshed. +struct EditRange +{ + EditRange() : minX(0x7fffffff), minY(0x7fffffff), maxX(-1), maxY(-1), fullUpdate(false) {} + + void add(Int x, Int y) + { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + bool empty() const { return maxX < 0; } + + Int minX, minY, maxX, maxY; + bool fullUpdate; +}; + +/// Refreshes the views from the edited copy and commits it as one undo step. +McpJson commitHeightMapEdit(WorldHeightMapEdit *copy, const EditRange &range) +{ + CWorldBuilderDoc *doc = mcpDoc(); + McpJson result = McpJson::makeObject(); + if (range.empty()) { + REF_PTR_RELEASE(copy); + result.set("changed", false); + return result; + } + if (range.fullUpdate) { + copy->optimizeTiles(); + } + IRegion2D partialRange; + partialRange.lo.x = range.minX; + partialRange.lo.y = range.minY; + partialRange.hi.x = range.maxX + 1; + partialRange.hi.y = range.maxY + 1; + doc->updateHeightMap(copy, !range.fullUpdate, partialRange); + mcpCommit(new WBDocUndoable(doc, copy)); + REF_PTR_RELEASE(copy); + if (range.fullUpdate) { + TerrainMaterial::updateTextures(doc->GetHeightMap()); + } + result.set("changed", true); + result.set("index_min_x", range.minX).set("index_min_y", range.minY); + result.set("index_max_x", range.maxX).set("index_max_y", range.maxY); + return result; +} + +UnsignedByte clampHeight(double h) +{ + if (h < 0) return 0; + if (h > 255) return 255; + return (UnsignedByte)floor(h + 0.5); +} + +Int findTextureClass(const McpJson &args, const char *key) +{ + const McpJson &v = args.get(key); + if (v.isNumber()) { + Int ndx = (Int)v.asNumber(); + if (ndx < 0 || ndx >= WorldHeightMapEdit::getNumTexClasses()) { + mcpFail("texture index %d out of range", ndx); + } + return ndx; + } + if (!v.isString()) { + mcpFail("argument '%s' must be a texture name or index (see terrain.list_textures)", key); + } + for (Int i = 0; i < WorldHeightMapEdit::getNumTexClasses(); i++) { + if (_stricmp(WorldHeightMapEdit::getTexClassName(i).str(), v.asString().c_str()) == 0) { + return i; + } + } + for (Int i = 0; i < WorldHeightMapEdit::getNumTexClasses(); i++) { + if (_stricmp(WorldHeightMapEdit::getTexClassUiName(i).str(), v.asString().c_str()) == 0) { + return i; + } + } + mcpFail("unknown texture '%s' (see terrain.list_textures)", v.asString().c_str()); + return -1; +} + +/// A world-space shape for area operations. Circles fall off linearly over 'feather'. +struct Shape +{ + bool circle; + double cx, cy, radius, feather; + double x0, y0, x1, y1; + + void parse(const McpJson &args, bool allowFeather) + { + std::string kind = mcpArgString(args, "shape", args.has("radius") ? "circle" : "rect"); + feather = allowFeather ? mcpArgNumber(args, "feather", 0) : 0; + if (feather < 0) feather = 0; + if (kind == "circle") { + circle = true; + cx = mcpArgNumber(args, "x"); + cy = mcpArgNumber(args, "y"); + radius = mcpArgNumber(args, "radius"); + if (radius < 0) mcpFail("radius must be >= 0"); + x0 = cx - radius - feather; + y0 = cy - radius - feather; + x1 = cx + radius + feather; + y1 = cy + radius + feather; + } else if (kind == "rect") { + circle = false; + x0 = mcpArgNumber(args, "x0"); + y0 = mcpArgNumber(args, "y0"); + x1 = mcpArgNumber(args, "x1"); + y1 = mcpArgNumber(args, "y1"); + if (x1 < x0) { double t = x0; x0 = x1; x1 = t; } + if (y1 < y0) { double t = y0; y0 = y1; y1 = t; } + cx = (x0 + x1) / 2; + cy = (y0 + y1) / 2; + radius = 0; + x0 -= feather; y0 -= feather; x1 += feather; y1 += feather; + } else { + mcpFail("shape must be 'circle' (x,y,radius) or 'rect' (x0,y0,x1,y1)"); + } + } + + /// 1 inside the shape, falling to 0 across the feather band, 0 outside. + double weight(double wx, double wy) const + { + double dist; + if (circle) { + double dx = wx - cx, dy = wy - cy; + dist = sqrt(dx * dx + dy * dy) - radius; + } else { + double dx = 0, dy = 0; + if (wx < x0 + feather) dx = x0 + feather - wx; + if (wx > x1 - feather) dx = wx - (x1 - feather); + if (wy < y0 + feather) dy = y0 + feather - wy; + if (wy > y1 - feather) dy = wy - (y1 - feather); + dist = sqrt(dx * dx + dy * dy); + } + const double CLOSE_ENOUGH = 0.01; + if (dist <= CLOSE_ENOUGH) return 1.0; + if (feather <= 0 || dist >= feather) return 0.0; + return (feather - dist) / feather; + } + + void indexRange(WorldHeightMapEdit *map, Int &ix0, Int &iy0, Int &ix1, Int &iy1) const + { + ix0 = (Int)floor(x0 / MAP_XY_FACTOR) + map->getBorderSize(); + iy0 = (Int)floor(y0 / MAP_XY_FACTOR) + map->getBorderSize(); + ix1 = (Int)ceil(x1 / MAP_XY_FACTOR) + map->getBorderSize(); + iy1 = (Int)ceil(y1 / MAP_XY_FACTOR) + map->getBorderSize(); + if (ix0 < 0) ix0 = 0; + if (iy0 < 0) iy0 = 0; + if (ix1 > map->getXExtent() - 1) ix1 = map->getXExtent() - 1; + if (iy1 > map->getYExtent() - 1) iy1 = map->getYExtent() - 1; + } +}; + +//------------------------------------------------------------------------------------------------- +// Heights +//------------------------------------------------------------------------------------------------- + +McpJson cmdGetHeights(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const Int step = mcpArgInt(args, "step", 1); + const Int x0 = mcpArgInt(args, "x0", 0); + const Int y0 = mcpArgInt(args, "y0", 0); + const Int w = mcpArgInt(args, "w", map->getXExtent() - x0); + const Int h = mcpArgInt(args, "h", map->getYExtent() - y0); + if (step < 1) mcpFail("step must be >= 1"); + if (x0 < 0 || y0 < 0 || w < 1 || h < 1 || x0 + w > map->getXExtent() || y0 + h > map->getYExtent()) { + mcpFail("region is outside the heightmap (%d x %d vertices)", map->getXExtent(), map->getYExtent()); + } + const Int cols = (w + step - 1) / step; + const Int rows = (h + step - 1) / step; + if (cols * rows > MAX_HEIGHT_VALUES) { + mcpFail("region too large (%d values); use a larger step or a smaller region", cols * rows); + } + McpJson data = McpJson::makeArray(); + for (Int y = y0; y < y0 + h; y += step) { + McpJson row = McpJson::makeArray(); + for (Int x = x0; x < x0 + w; x += step) { + row.push((int)map->getHeight(x, y)); + } + data.push(row); + } + McpJson j = McpJson::makeObject(); + j.set("x0", x0).set("y0", y0).set("step", step).set("cols", cols).set("rows", rows); + j.set("heights", data); + return j; +} + +McpJson cmdSetHeights(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const Int x0 = mcpArgInt(args, "x0"); + const Int y0 = mcpArgInt(args, "y0"); + const McpJson &rows = mcpArgArray(args, "heights"); + WorldHeightMapEdit *copy = map->duplicate(); + EditRange range; + for (size_t r = 0; r < rows.size(); r++) { + const McpJson &row = rows.at(r); + if (!row.isArray()) { + REF_PTR_RELEASE(copy); + mcpFail("heights must be an array of rows (arrays of numbers)"); + } + const Int y = y0 + (Int)r; + for (size_t c = 0; c < row.size(); c++) { + const Int x = x0 + (Int)c; + if (x < 0 || y < 0 || x >= copy->getXExtent() || y >= copy->getYExtent()) { + continue; + } + const McpJson &v = row.at(c); + if (!v.isNumber()) { + continue; // null leaves the vertex unchanged. + } + copy->setHeight(x, y, clampHeight(v.asNumber())); + range.add(x, y); + } + } + return commitHeightMapEdit(copy, range); +} + +McpJson cmdBrush(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const std::string op = mcpArgString(args, "op"); + Shape shape; + shape.parse(args, true); + const double strength = mcpArgNumber(args, "strength", 1.0); + const Int iterations = mcpArgInt(args, "iterations", 1); + if (iterations < 1 || iterations > 50) mcpFail("iterations must be 1..50"); + + double amount = 0; + double target = 0; + UnsignedInt seed = 0; + if (op == "raise" || op == "lower" || op == "noise") { + amount = mcpArgNumber(args, "amount"); + if (op == "lower") amount = -amount; + seed = (UnsignedInt)mcpArgInt(args, "seed", 1); + } else if (op == "set") { + target = mcpArgNumber(args, "height"); + } else if (op == "flatten") { + if (args.has("height")) { + target = mcpArgNumber(args, "height"); + } else { + target = map->getHeight(mcpWorldToIndexX(shape.cx), mcpWorldToIndexY(shape.cy)); + } + } else if (op != "smooth") { + mcpFail("op must be raise, lower, set, flatten, smooth or noise"); + } + + Int ix0, iy0, ix1, iy1; + shape.indexRange(map, ix0, iy0, ix1, iy1); + WorldHeightMapEdit *copy = map->duplicate(); + EditRange range; + for (Int pass = 0; pass < iterations; pass++) { + // Smoothing reads from a snapshot so the result does not depend on iteration order. + std::vector snapshot; + if (op == "smooth") { + snapshot.resize((ix1 - ix0 + 3) * (iy1 - iy0 + 3)); + for (Int y = iy0 - 1; y <= iy1 + 1; y++) { + for (Int x = ix0 - 1; x <= ix1 + 1; x++) { + // Clamp so vertices on the map edge are not pulled toward zero. + Int sx = x < 0 ? 0 : (x >= copy->getXExtent() ? copy->getXExtent() - 1 : x); + Int sy = y < 0 ? 0 : (y >= copy->getYExtent() ? copy->getYExtent() - 1 : y); + snapshot[(y - iy0 + 1) * (ix1 - ix0 + 3) + (x - ix0 + 1)] = copy->getHeight(sx, sy); + } + } + } + for (Int y = iy0; y <= iy1; y++) { + for (Int x = ix0; x <= ix1; x++) { + const double wgt = shape.weight(mcpIndexToWorldX(x), mcpIndexToWorldY(y)) * strength; + if (wgt <= 0) { + continue; + } + const double cur = copy->getHeight(x, y); + double next = cur; + if (op == "raise" || op == "lower") { + next = cur + amount * wgt; + } else if (op == "noise") { + // Hash the vertex so the same seed gives the same terrain. + UnsignedInt hsh = (UnsignedInt)x * 73856093u ^ (UnsignedInt)y * 19349663u ^ (seed + pass) * 83492791u; + hsh = (hsh ^ (hsh >> 13)) * 1274126177u; + const double r = ((hsh >> 8) & 0xFFFF) / 65535.0 * 2.0 - 1.0; + next = cur + amount * r * wgt; + } else if (op == "set" || op == "flatten") { + next = cur + (target - cur) * (wgt > 1 ? 1 : wgt); + } else { + double sum = 0; + const Int stride = ix1 - ix0 + 3; + for (Int dy = -1; dy <= 1; dy++) { + for (Int dx = -1; dx <= 1; dx++) { + sum += snapshot[(y + dy - iy0 + 1) * stride + (x + dx - ix0 + 1)]; + } + } + next = cur + (sum / 9.0 - cur) * (wgt > 1 ? 1 : wgt); + } + UnsignedByte newHeight = clampHeight(next); + if (newHeight != copy->getHeight(x, y)) { + copy->setHeight(x, y, newHeight); + range.add(x, y); + } + } + } + } + return commitHeightMapEdit(copy, range); +} + +//------------------------------------------------------------------------------------------------- +// Textures and passability +//------------------------------------------------------------------------------------------------- + +McpJson cmdListTextures(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + std::string filter = mcpArgString(args, "filter", ""); + McpJson list = McpJson::makeArray(); + for (Int i = 0; i < WorldHeightMapEdit::getNumTexClasses(); i++) { + AsciiString name = WorldHeightMapEdit::getTexClassName(i); + AsciiString uiName = WorldHeightMapEdit::getTexClassUiName(i); + if (!mcpContainsNoCase(name.str(), filter.c_str()) && !mcpContainsNoCase(uiName.str(), filter.c_str())) { + continue; + } + McpJson t = McpJson::makeObject(); + t.set("index", i).set("name", name.str()).set("ui_name", uiName.str()); + t.set("used_in_map", map->isTexClassUsed(i) != 0); + t.set("can_fit", map->canFitTexture(i) != 0); + t.set("blend_edge", WorldHeightMapEdit::getTexClassIsBlendEdge(i) != 0); + list.push(t); + } + return McpJson::makeObject().set("textures", list); +} + +McpJson cmdPaintTexture(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const Int texClass = findTextureClass(args, "texture"); + Shape shape; + shape.parse(args, false); // Tiles are either painted or not. + if (!map->isTexClassUsed(texClass) && !map->canFitTexture(texClass)) { + mcpFail("the map has no room for another texture; paint over or remove an existing one first"); + } + Int ix0, iy0, ix1, iy1; + shape.indexRange(map, ix0, iy0, ix1, iy1); + WorldHeightMapEdit *copy = map->duplicate(); + EditRange range; + for (Int y = iy0; y <= iy1; y++) { + for (Int x = ix0; x <= ix1; x++) { + // Tiles are addressed by their lower left vertex; test the tile center. + if (shape.weight(mcpIndexToWorldX(x) + MAP_XY_FACTOR / 2, mcpIndexToWorldY(y) + MAP_XY_FACTOR / 2) <= 0) { + continue; + } + if (copy->setTileNdx(x, y, texClass, false)) { + range.fullUpdate = true; + } + range.add(x, y); + } + } + return commitHeightMapEdit(copy, range); +} + +McpJson cmdFloodFill(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const Int texClass = findTextureClass(args, "texture"); + const Int x = mcpWorldToIndexX(mcpArgNumber(args, "x")); + const Int y = mcpWorldToIndexY(mcpArgNumber(args, "y")); + if (x < 0 || y < 0 || x >= map->getXExtent() || y >= map->getYExtent()) { + mcpFail("point is outside the map"); + } + WorldHeightMapEdit *copy = map->duplicate(); + EditRange range; + if (copy->floodFill(x, y, texClass, mcpArgBool(args, "replace_all", false))) { + range.add(0, 0); + range.add(copy->getXExtent() - 1, copy->getYExtent() - 1); + range.fullUpdate = true; + } + return commitHeightMapEdit(copy, range); +} + +McpJson cmdAutoBlend(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const Int x = mcpWorldToIndexX(mcpArgNumber(args, "x")); + const Int y = mcpWorldToIndexY(mcpArgNumber(args, "y")); + if (x < 0 || y < 0 || x >= map->getXExtent() || y >= map->getYExtent()) { + mcpFail("point is outside the map"); + } + const Int edgeClass = args.has("edge_texture") ? findTextureClass(args, "edge_texture") : -1; + WorldHeightMapEdit *copy = map->duplicate(); + copy->autoBlendOut(x, y, edgeClass); + EditRange range; + range.add(0, 0); + range.add(copy->getXExtent() - 1, copy->getYExtent() - 1); + range.fullUpdate = true; + return commitHeightMapEdit(copy, range); +} + +McpJson cmdSetPassability(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const bool impassable = mcpArgBool(args, "impassable", true); + Shape shape; + shape.parse(args, false); + Int ix0, iy0, ix1, iy1; + shape.indexRange(map, ix0, iy0, ix1, iy1); + WorldHeightMapEdit *copy = map->duplicate(); + EditRange range; + for (Int y = iy0; y <= iy1; y++) { + for (Int x = ix0; x <= ix1; x++) { + if (shape.weight(mcpIndexToWorldX(x) + MAP_XY_FACTOR / 2, mcpIndexToWorldY(y) + MAP_XY_FACTOR / 2) <= 0) { + continue; + } + copy->setCliff(x, y, impassable); + range.add(x, y); + } + } + return commitHeightMapEdit(copy, range); +} + +McpJson cmdSample(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const double wx = mcpArgNumber(args, "x"); + const double wy = mcpArgNumber(args, "y"); + const Int x = mcpWorldToIndexX(wx); + const Int y = mcpWorldToIndexY(wy); + if (x < 0 || y < 0 || x >= map->getXExtent() || y >= map->getYExtent()) { + mcpFail("point is outside the map"); + } + McpJson j = McpJson::makeObject(); + j.set("index_x", x).set("index_y", y); + const Int raw = map->getHeight(x, y); + j.set("height_raw", raw).set("height_world", raw * MAP_HEIGHT_SCALE); + const Int tex = map->getTextureClass(x, y, true); + if (tex >= 0 && tex < WorldHeightMapEdit::getNumTexClasses()) { + j.set("texture", WorldHeightMapEdit::getTexClassName(tex).str()); + } + j.set("impassable", map->getCliffState(x, y) != 0); + return j; +} + +} // namespace + +void mcpRegisterTerrainCommands() +{ + mcpRegisterCommand("terrain.get_heights", cmdGetHeights, "{x0?,y0?,w?,h?,step?} Raw vertex heights (0..255) as rows; indices include the border."); + mcpRegisterCommand("terrain.set_heights", cmdSetHeights, "{x0,y0,heights:[[...]]} Writes raw vertex heights; null entries are skipped."); + mcpRegisterCommand("terrain.brush", cmdBrush, "{op:raise|lower|set|flatten|smooth|noise, shape, x,y,radius | x0,y0,x1,y1, feather?, amount?, height?, strength?, iterations?, seed?}"); + mcpRegisterCommand("terrain.list_textures", cmdListTextures, "{filter?} Available terrain texture classes."); + mcpRegisterCommand("terrain.paint_texture", cmdPaintTexture, "{texture, shape, x,y,radius | x0,y0,x1,y1} Paints a texture."); + mcpRegisterCommand("terrain.flood_fill", cmdFloodFill, "{texture,x,y,replace_all?} Flood fills the texture region under a point."); + mcpRegisterCommand("terrain.auto_blend", cmdAutoBlend, "{x,y,edge_texture?} Blends the edges of the texture region under a point."); + mcpRegisterCommand("terrain.set_passability", cmdSetPassability, "{impassable, shape, x,y,radius | x0,y0,x1,y1} Paints impassable cells."); + mcpRegisterCommand("terrain.sample", cmdSample, "{x,y} Height, texture and passability at a world point."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp new file mode 100644 index 00000000000..1a4159d2d98 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp @@ -0,0 +1,400 @@ +/* +** 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 . +*/ + +// McpCmdWaypoints.cpp +// MCP bridge commands for waypoints, polygon triggers (including water areas) and roads. + +#include "StdAfx.h" +#include "mcp/McpCommands.h" + +#include "CUndoable.h" +#include "PointerTool.h" +#include "WaypointOptions.h" +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" +#include "mapobjectprops.h" +#include "Common/GlobalData.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "Common/WellKnownKeys.h" +#include "GameClient/TerrainRoads.h" +#include "GameLogic/PolygonTrigger.h" + +#include + +namespace +{ + +//------------------------------------------------------------------------------------------------- +// Waypoints +//------------------------------------------------------------------------------------------------- + +MapObject *findWaypointByName(const std::string &name) +{ + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (obj->isWaypoint() && _stricmp(obj->getWaypointName().str(), name.c_str()) == 0) { + return obj; + } + } + return nullptr; +} + +/// Accepts a waypoint name or its numeric waypoint id. +MapObject *requireWaypoint(const McpJson &args, const char *key) +{ + const McpJson &v = args.get(key); + MapObject *obj = nullptr; + if (v.isNumber()) { + obj = mcpDoc()->getWaypointByID((Int)v.asNumber()); + } else if (v.isString()) { + obj = findWaypointByName(v.asString()); + } else { + mcpFail("argument '%s' must be a waypoint name or waypoint id", key); + } + if (obj == nullptr) { + mcpFail("waypoint '%s' not found", v.isString() ? v.asString().c_str() : "(id)"); + } + return obj; +} + +McpJson describeWaypoint(MapObject *obj) +{ + McpJson j = mcpDescribeObject(obj); + Dict *props = obj->getProperties(); + McpJson labels = McpJson::makeArray(); + const NameKeyType labelKeys[3] = { TheKey_waypointPathLabel1, TheKey_waypointPathLabel2, TheKey_waypointPathLabel3 }; + for (Int i = 0; i < 3; i++) { + Bool exists = false; + AsciiString label = props->getAsciiString(labelKeys[i], &exists); + if (exists && !label.isEmpty()) { + labels.push(label.str()); + } + } + j.set("path_labels", labels); + return j; +} + +McpJson cmdListWaypoints(const McpJson &) +{ + CWorldBuilderDoc *doc = mcpDoc(); + McpJson waypoints = McpJson::makeArray(); + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (obj->isWaypoint()) { + waypoints.push(describeWaypoint(obj)); + } + } + McpJson links = McpJson::makeArray(); + for (Int i = 0; i < doc->getNumWaypointLinks(); i++) { + Int from, to; + doc->getWaypointLink(i, &from, &to); + MapObject *fromObj = doc->getWaypointByID(from); + MapObject *toObj = doc->getWaypointByID(to); + McpJson link = McpJson::makeObject().set("from", from).set("to", to); + if (fromObj) link.set("from_name", fromObj->getWaypointName().str()); + if (toObj) link.set("to_name", toObj->getWaypointName().str()); + links.push(link); + } + return McpJson::makeObject().set("waypoints", waypoints).set("links", links); +} + +McpJson cmdAddWaypoint(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + Coord3D loc; + loc.x = (Real)mcpArgNumber(args, "x"); + loc.y = (Real)mcpArgNumber(args, "y"); + loc.z = 0; + + AsciiString name; + if (args.has("name")) { + std::string requested = mcpArgString(args, "name"); + if (requested.empty() || findWaypointByName(requested)) { + mcpFail("waypoint name must be unique and not empty"); + } + name = requested.c_str(); + } + + MapObject *linkFrom = args.has("link_from") ? requireWaypoint(args, "link_from") : nullptr; + + MapObject *obj = newInstance(MapObject)(loc, "*Waypoints/Waypoint", 0, 0, nullptr, nullptr); + const Int id = doc->getNextWaypointID(); + if (name.isEmpty()) { + name = WaypointOptions::GenerateUniqueName(id); + } + obj->setIsWaypoint(); + obj->setWaypointID(id); + obj->setWaypointName(name); + obj->getProperties()->setAsciiString(TheKey_originalOwner, "team"); + + const McpJson &labels = args.get("path_labels"); + if (labels.isArray()) { + const NameKeyType labelKeys[3] = { TheKey_waypointPathLabel1, TheKey_waypointPathLabel2, TheKey_waypointPathLabel3 }; + for (size_t i = 0; i < labels.size() && i < 3; i++) { + if (labels.at(i).isString()) { + obj->getProperties()->setAsciiString(labelKeys[i], AsciiString(labels.at(i).asString().c_str())); + } + } + } + PointerTool::clearSelection(); + mcpCommit(new AddObjectUndoable(doc, obj)); + + if (linkFrom) { + doc->addWaypointLink(linkFrom->getWaypointID(), id); + doc->updateLinkedWaypointLabels(linkFrom); + } + return describeWaypoint(obj); +} + +McpJson cmdLinkWaypoints(const McpJson &args, bool link) +{ + CWorldBuilderDoc *doc = mcpDoc(); + MapObject *from = requireWaypoint(args, "from"); + MapObject *to = requireWaypoint(args, "to"); + const Int fromId = from->getWaypointID(); + const Int toId = to->getWaypointID(); + if (fromId == toId) { + mcpFail("cannot link a waypoint to itself"); + } + if (link) { + if (!doc->waypointLinkExists(fromId, toId)) { + doc->addWaypointLink(fromId, toId); + } + } else { + doc->removeWaypointLink(fromId, toId); + } + doc->updateLinkedWaypointLabels(from); + // Links are not part of the undo system, so flag the change directly. + doc->SetModifiedFlag(); + doc->invalObject(nullptr); + return McpJson::makeObject().set("from", fromId).set("to", toId).set("linked", doc->waypointLinkExists(fromId, toId) != 0); +} + +McpJson cmdLink(const McpJson &args) +{ + return cmdLinkWaypoints(args, true); +} + +McpJson cmdUnlink(const McpJson &args) +{ + return cmdLinkWaypoints(args, false); +} + +//------------------------------------------------------------------------------------------------- +// Polygon triggers and water +//------------------------------------------------------------------------------------------------- + +McpJson describePolygon(PolygonTrigger *trig, bool withPoints) +{ + McpJson j = McpJson::makeObject(); + j.set("id", trig->getID()); + j.set("name", trig->getTriggerName().str()); + j.set("kind", trig->isRiver() ? "river" : (trig->isWaterArea() ? "water" : "area")); + if (!trig->getLayerName().isEmpty()) { + j.set("layer", trig->getLayerName().str()); + } + j.set("num_points", trig->getNumPoints()); + if (trig->isWaterArea() && trig->getNumPoints() > 0) { + j.set("water_height", trig->getPoint(0)->z); + } + if (withPoints) { + McpJson points = McpJson::makeArray(); + for (Int i = 0; i < trig->getNumPoints(); i++) { + const ICoord3D *pt = trig->getPoint(i); + McpJson p = McpJson::makeArray(); + p.push(pt->x); + p.push(pt->y); + p.push(pt->z); + points.push(p); + } + j.set("points", points); + } + return j; +} + +McpJson cmdListPolygons(const McpJson &args) +{ + mcpDoc(); + const bool withPoints = mcpArgBool(args, "include_points", false); + McpJson list = McpJson::makeArray(); + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + list.push(describePolygon(trig, withPoints)); + } + return McpJson::makeObject().set("polygons", list); +} + +McpJson cmdAddPolygon(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const McpJson &points = mcpArgArray(args, "points"); + const std::string kind = mcpArgString(args, "kind", "area"); + const bool isWater = (kind == "water" || kind == "river"); + if (!isWater && kind != "area") { + mcpFail("kind must be area, water or river"); + } + if (points.size() < 3) { + mcpFail("a polygon needs at least 3 points"); + } + const Int waterHeight = (Int)floor(mcpArgNumber(args, "water_height", TheGlobalData->m_waterPositionZ) + 0.5); + + PolygonTrigger *trig = newInstance(PolygonTrigger)((Int)points.size()); + for (size_t i = 0; i < points.size(); i++) { + const McpJson &p = points.at(i); + if (!p.isArray() || p.size() < 2 || !p.at(0).isNumber() || !p.at(1).isNumber()) { + deleteInstance(trig); + mcpFail("points must be [x, y] pairs in world units"); + } + ICoord3D pt; + pt.x = (Int)floor(p.at(0).asNumber() + 0.5); + pt.y = (Int)floor(p.at(1).asNumber() + 0.5); + if (isWater) { + pt.z = waterHeight; + } else { + // Trigger areas sit on the terrain, like the ones drawn with the polygon tool. + Int ix = mcpWorldToIndexX(pt.x); + Int iy = mcpWorldToIndexY(pt.y); + pt.z = (Int)floor(map->getHeight(ix, iy) * MAP_HEIGHT_SCALE + 0.5); + } + trig->addPoint(pt); + } + AsciiString name; + if (args.has("name")) { + name = mcpArgString(args, "name").c_str(); + } else { + name.format("%s %d", isWater ? "Water Area" : "Area", trig->getID()); + } + trig->setTriggerName(name); + trig->setWaterArea(isWater); + trig->setRiver(kind == "river"); + if (kind == "river") { + trig->setRiverStart(mcpArgInt(args, "river_start", 0)); + } + mcpCommit(new AddPolygonUndoable(trig)); + return describePolygon(trig, true); +} + +McpJson cmdDeletePolygon(const McpJson &args) +{ + mcpDoc(); + PolygonTrigger *trig = mcpRequirePolygon(mcpArgInt(args, "id")); + mcpCommit(new DeletePolygonUndoable(trig)); + return McpJson::makeObject().set("deleted", mcpArgInt(args, "id")); +} + +//------------------------------------------------------------------------------------------------- +// Roads +//------------------------------------------------------------------------------------------------- + +McpJson cmdListRoadTypes(const McpJson &) +{ + McpJson roads = McpJson::makeArray(); + for (TerrainRoadType *road = TheTerrainRoads->firstRoad(); road; road = TheTerrainRoads->nextRoad(road)) { + roads.push(road->getName().str()); + } + McpJson bridges = McpJson::makeArray(); + for (TerrainRoadType *bridge = TheTerrainRoads->firstBridge(); bridge; bridge = TheTerrainRoads->nextBridge(bridge)) { + McpJson b = McpJson::makeObject().set("name", bridge->getName().str()); + const ThingTemplate *tt = TheThingFactory->findTemplate(bridge->getName(), FALSE); + b.set("landmark", tt != nullptr && tt->isBridge()); + bridges.push(b); + } + return McpJson::makeObject().set("roads", roads).set("bridges", bridges); +} + +McpJson cmdAddRoad(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + const std::string type = mcpArgString(args, "type"); + AsciiString roadName(type.c_str()); + const bool isBridge = TheTerrainRoads->findBridge(roadName) != nullptr; + if (!isBridge && TheTerrainRoads->findRoad(roadName) == nullptr) { + mcpFail("unknown road type '%s' (see roads.list_types)", type.c_str()); + } + const ThingTemplate *tt = isBridge ? TheThingFactory->findTemplate(roadName, FALSE) : nullptr; + if (tt && tt->isBridge()) { + mcpFail("'%s' is a landmark bridge; place it with objects.place", type.c_str()); + } + const McpJson &points = mcpArgArray(args, "points"); + if (points.size() < 2) { + mcpFail("a road needs at least 2 points"); + } + if (isBridge && points.size() != 2) { + mcpFail("a bridge is a single segment with exactly 2 points"); + } + const std::string corner = mcpArgString(args, "corners", "curved"); + Int cornerFlag = 0; + if (corner == "angled") cornerFlag = FLAG_ROAD_CORNER_ANGLED; + else if (corner == "tight") cornerFlag = FLAG_ROAD_CORNER_TIGHT; + else if (corner != "curved") mcpFail("corners must be curved, angled or tight"); + + // Each segment is a pair of map objects; consecutive segments share end locations so the + // renderer joins them. + MapObject *head = nullptr; + MapObject *tail = nullptr; + for (size_t i = 0; i + 1 < points.size(); i++) { + Coord3D loc[2]; + for (Int k = 0; k < 2; k++) { + const McpJson &p = points.at(i + k); + if (!p.isArray() || p.size() < 2 || !p.at(0).isNumber() || !p.at(1).isNumber()) { + if (head) deleteInstance(head); + mcpFail("points must be [x, y] pairs in world units"); + } + loc[k].x = (Real)p.at(0).asNumber(); + loc[k].y = (Real)p.at(1).asNumber(); + loc[k].z = 0; // roads stick to the terrain. + } + MapObject *p1 = newInstance(MapObject)(loc[0], roadName, 0.0f, 0, nullptr, nullptr); + MapObject *p2 = newInstance(MapObject)(loc[1], roadName, 0.0f, 0, nullptr, nullptr); + p1->setColor(RGB(255, 255, 0)); + p2->setColor(RGB(255, 255, 0)); + p1->setFlag(isBridge ? FLAG_BRIDGE_POINT1 : FLAG_ROAD_POINT1); + p2->setFlag(isBridge ? FLAG_BRIDGE_POINT2 : FLAG_ROAD_POINT2); + if (!isBridge && cornerFlag) { + p1->setFlag(cornerFlag); + p2->setFlag(cornerFlag); + } + p1->getProperties()->setAsciiString(TheKey_originalOwner, NEUTRAL_TEAM_INTERNAL_STR); + p2->getProperties()->setAsciiString(TheKey_originalOwner, NEUTRAL_TEAM_INTERNAL_STR); + p1->setNextMap(p2); + if (tail) tail->setNextMap(p1); else head = p1; + tail = p2; + } + PointerTool::clearSelection(); + mcpCommit(new AddObjectUndoable(doc, head)); + + McpJson ids = McpJson::makeArray(); + for (MapObject *obj = head; obj; obj = obj->getNext()) { + ids.push(mcpObjectHandle(obj)); + if (obj == tail) break; + } + return McpJson::makeObject().set("type", type).set("bridge", isBridge).set("segments", (int)points.size() - 1).set("point_ids", ids); +} + +} // namespace + +void mcpRegisterWaypointCommands() +{ + mcpRegisterCommand("waypoints.list", cmdListWaypoints, "All waypoints and the links between them."); + mcpRegisterCommand("waypoints.add", cmdAddWaypoint, "{x,y,name?,path_labels?,link_from?} Adds a waypoint."); + mcpRegisterCommand("waypoints.link", cmdLink, "{from,to} Links two waypoints (names or waypoint ids). Not undoable."); + mcpRegisterCommand("waypoints.unlink", cmdUnlink, "{from,to} Removes a waypoint link. Not undoable."); + mcpRegisterCommand("polygons.list", cmdListPolygons, "{include_points?} Trigger areas and water areas."); + mcpRegisterCommand("polygons.add", cmdAddPolygon, "{points:[[x,y]...],kind:area|water|river,name?,water_height?,river_start?} Adds a polygon."); + mcpRegisterCommand("polygons.delete", cmdDeletePolygon, "{id} Deletes a polygon."); + mcpRegisterCommand("roads.list_types", cmdListRoadTypes, "Road and bridge types."); + mcpRegisterCommand("roads.add", cmdAddRoad, "{type,points:[[x,y]...],corners?:curved|angled|tight} Adds a road polyline or a 2-point bridge."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp new file mode 100644 index 00000000000..d45c82a4d47 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -0,0 +1,490 @@ +/* +** 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 . +*/ + +// McpCommands.cpp +// Command table, request dispatch and shared helpers for the WorldBuilder MCP command bridge. + +#include "StdAfx.h" +#include "mcp/McpCommands.h" + +#include "CUndoable.h" +#include "MainFrm.h" +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" +#include "Common/Dict.h" +#include "Common/NameKeyGenerator.h" +#include "Common/ThingTemplate.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/PolygonTrigger.h" + +#include +#include +#include + +namespace +{ + +struct CommandEntry +{ + McpHandler handler; + std::string help; +}; + +typedef std::map CommandTable; + +CommandTable &commandTable() +{ + static CommandTable table; + return table; +} + +std::map s_objectToHandle; +std::map s_handleToObject; +int s_nextHandle = 1; + +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); +} + +void registerAllCommands() +{ + static bool registered = false; + if (registered) { + return; + } + registered = true; + mcpRegisterCommand("list_commands", cmdListCommands, "Lists all bridge commands."); + mcpRegisterMapCommands(); + mcpRegisterTerrainCommands(); + mcpRegisterObjectCommands(); + mcpRegisterWaypointCommands(); +} + +std::string makeErrorReply(const McpJson &id, const std::string &message) +{ + McpJson reply = McpJson::makeObject(); + reply.set("id", id).set("ok", false).set("error", message); + return reply.dump(); +} + +/// Returns a reason string when WorldBuilder cannot safely run a command right now. +const char *getBusyReason() +{ + CWnd *mainWnd = AfxGetMainWnd(); + if (mainWnd == nullptr || mainWnd->GetSafeHwnd() == nullptr) { + return "WorldBuilder main window is not available"; + } + if (!mainWnd->IsWindowEnabled()) { + return "WorldBuilder is busy (a modal dialog is open). Close it and retry."; + } + if (::GetCapture() != nullptr) { + return "WorldBuilder is busy (a mouse drag is in progress). Retry shortly."; + } + return nullptr; +} + +} // namespace + +void mcpRegisterCommand(const char *name, McpHandler handler, const char *help) +{ + CommandEntry entry; + entry.handler = handler; + entry.help = help ? help : ""; + commandTable()[name] = entry; +} + +std::string mcpDispatch(const std::string &requestLine) +{ + registerAllCommands(); + + McpJson request; + std::string parseError; + if (!McpJson::parse(requestLine, request, parseError)) { + return makeErrorReply(McpJson(), "invalid JSON: " + parseError); + } + const McpJson &id = request.get("id"); + const McpJson &cmd = request.get("cmd"); + if (!cmd.isString()) { + return makeErrorReply(id, "request needs a string 'cmd'"); + } + + CommandTable::const_iterator it = commandTable().find(cmd.asString()); + if (it == commandTable().end()) { + return makeErrorReply(id, "unknown command '" + cmd.asString() + "' (use list_commands)"); + } + + const char *busy = getBusyReason(); + if (busy != nullptr) { + return makeErrorReply(id, busy); + } + + McpJson args = request.get("args"); + if (args.isNull()) { + args = McpJson::makeObject(); + } + if (!args.isObject()) { + return makeErrorReply(id, "'args' must be an object"); + } + + try { + McpJson result = it->second.handler(args); + McpJson reply = McpJson::makeObject(); + reply.set("id", id).set("ok", true).set("result", result); + return reply.dump(); + } catch (const McpError &err) { + return makeErrorReply(id, err.message); + } +} + +//------------------------------------------------------------------------------------------------- +// Argument helpers +//------------------------------------------------------------------------------------------------- + +void mcpFail(const char *fmt, ...) +{ + char buf[1024]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + buf[sizeof(buf) - 1] = 0; + McpError err; + err.message = buf; + throw err; +} + +double mcpArgNumber(const McpJson &args, const char *key) +{ + const McpJson &v = args.get(key); + if (!v.isNumber()) { + mcpFail("argument '%s' must be a number", key); + } + return v.asNumber(); +} + +double mcpArgNumber(const McpJson &args, const char *key, double defaultValue) +{ + if (!args.has(key)) { + return defaultValue; + } + return mcpArgNumber(args, key); +} + +int mcpArgInt(const McpJson &args, const char *key) +{ + return (int)floor(mcpArgNumber(args, key) + 0.5); +} + +int mcpArgInt(const McpJson &args, const char *key, int defaultValue) +{ + if (!args.has(key)) { + return defaultValue; + } + return mcpArgInt(args, key); +} + +bool mcpArgBool(const McpJson &args, const char *key, bool defaultValue) +{ + if (!args.has(key)) { + return defaultValue; + } + const McpJson &v = args.get(key); + if (!v.isBool()) { + mcpFail("argument '%s' must be true or false", key); + } + return v.asBool(); +} + +std::string mcpArgString(const McpJson &args, const char *key) +{ + const McpJson &v = args.get(key); + if (!v.isString()) { + mcpFail("argument '%s' must be a string", key); + } + return v.asString(); +} + +std::string mcpArgString(const McpJson &args, const char *key, const char *defaultValue) +{ + if (!args.has(key)) { + return defaultValue; + } + return mcpArgString(args, key); +} + +const McpJson &mcpArgArray(const McpJson &args, const char *key) +{ + const McpJson &v = args.get(key); + if (!v.isArray()) { + mcpFail("argument '%s' must be an array", key); + } + return v; +} + +//------------------------------------------------------------------------------------------------- +// Document helpers +//------------------------------------------------------------------------------------------------- + +CWorldBuilderDoc *mcpDoc() +{ + CWorldBuilderDoc *doc = CWorldBuilderDoc::GetActiveDoc(); + if (doc == nullptr) { + mcpFail("no map is open"); + } + return doc; +} + +WorldHeightMapEdit *mcpHeightMap() +{ + WorldHeightMapEdit *map = mcpDoc()->GetHeightMap(); + if (map == nullptr) { + mcpFail("the open map has no heightmap"); + } + return map; +} + +void mcpCommit(Undoable *undo) +{ + mcpDoc()->AddAndDoUndoable(undo); + REF_PTR_RELEASE(undo); // belongs to the doc now. +} + +int mcpWorldToIndexX(double worldX) +{ + return (int)floor(worldX / MAP_XY_FACTOR + 0.5) + mcpHeightMap()->getBorderSize(); +} + +int mcpWorldToIndexY(double worldY) +{ + return (int)floor(worldY / MAP_XY_FACTOR + 0.5) + mcpHeightMap()->getBorderSize(); +} + +double mcpIndexToWorldX(int ndx) +{ + return (ndx - mcpHeightMap()->getBorderSize()) * MAP_XY_FACTOR; +} + +double mcpIndexToWorldY(int ndx) +{ + return (ndx - mcpHeightMap()->getBorderSize()) * MAP_XY_FACTOR; +} + +bool mcpContainsNoCase(const char *haystack, const char *needle) +{ + if (needle == nullptr || *needle == 0) { + return true; + } + if (haystack == nullptr) { + return false; + } + size_t needleLen = strlen(needle); + for (const char *p = haystack; *p; p++) { + if (_strnicmp(p, needle, needleLen) == 0) { + return true; + } + } + return false; +} + +//------------------------------------------------------------------------------------------------- +// Object handles +//------------------------------------------------------------------------------------------------- + +int mcpObjectHandle(MapObject *obj) +{ + std::map::iterator it = s_objectToHandle.find(obj); + if (it != s_objectToHandle.end()) { + return it->second; + } + int handle = s_nextHandle++; + s_objectToHandle[obj] = handle; + s_handleToObject[handle] = obj; + return handle; +} + +MapObject *mcpFindObject(int handle) +{ + std::map::iterator it = s_handleToObject.find(handle); + if (it == s_handleToObject.end()) { + return nullptr; + } + // The pointer may be stale (deleted or undone), so only trust it if it is still in the map list. + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (obj == it->second) { + return obj; + } + } + return nullptr; +} + +MapObject *mcpRequireObject(int handle) +{ + MapObject *obj = mcpFindObject(handle); + if (obj == nullptr) { + mcpFail("no object with id %d in the map (list objects again to get current ids)", handle); + } + return obj; +} + +void mcpResetObjectHandles() +{ + s_objectToHandle.clear(); + s_handleToObject.clear(); +} + +PolygonTrigger *mcpRequirePolygon(int id) +{ + PolygonTrigger *trig = PolygonTrigger::getPolygonTriggerByID(id); + if (trig == nullptr) { + mcpFail("no polygon trigger with id %d", id); + } + return trig; +} + +McpJson mcpDescribeObject(MapObject *obj) +{ + const Coord3D *loc = obj->getLocation(); + McpJson j = McpJson::makeObject(); + j.set("id", mcpObjectHandle(obj)); + j.set("template", obj->getName().str()); + j.set("x", loc->x).set("y", loc->y).set("z", loc->z); + j.set("angle_deg", obj->getAngle() * 180.0 / PI); + Dict *props = obj->getProperties(); + j.set("owner", props->getAsciiString(TheKey_originalOwner).str()); + Bool exists = false; + AsciiString name = props->getAsciiString(TheKey_objectName, &exists); + if (exists && !name.isEmpty()) { + j.set("name", name.str()); + } + AsciiString uniqueID = props->getAsciiString(TheKey_uniqueID, &exists); + if (exists && !uniqueID.isEmpty()) { + j.set("unique_id", uniqueID.str()); + } + if (obj->isWaypoint()) { + j.set("kind", "waypoint"); + j.set("waypoint_id", (int)obj->getWaypointID()); + j.set("waypoint_name", obj->getWaypointName().str()); + } else if (obj->getFlags() & FLAG_ROAD_FLAGS) { + j.set("kind", "road_point"); + } else if (obj->getFlags() & FLAG_BRIDGE_FLAGS) { + j.set("kind", "bridge_point"); + } else if (obj->isLight()) { + j.set("kind", "light"); + } else if (obj->isScorch()) { + j.set("kind", "scorch"); + } else { + j.set("kind", obj->getThingTemplate() ? "object" : "unknown_template"); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// Dict conversion +//------------------------------------------------------------------------------------------------- + +McpJson mcpDictToJson(const Dict &dict) +{ + McpJson out = McpJson::makeObject(); + for (Int i = 0; i < dict.getPairCount(); i++) { + AsciiString key = TheNameKeyGenerator->keyToName(dict.getNthKey(i)); + switch (dict.getNthType(i)) { + case Dict::DICT_BOOL: + out.set(key.str(), (bool)(dict.getNthBool(i) != 0)); + break; + case Dict::DICT_INT: + out.set(key.str(), (int)dict.getNthInt(i)); + break; + case Dict::DICT_REAL: + out.set(key.str(), (double)dict.getNthReal(i)); + break; + case Dict::DICT_ASCIISTRING: + out.set(key.str(), dict.getNthAsciiString(i).str()); + break; + case Dict::DICT_UNICODESTRING: { + AsciiString s; + s.translate(dict.getNthUnicodeString(i)); + out.set(key.str(), s.str()); + break; + } + default: + break; + } + } + return out; +} + +void mcpJsonToDict(const McpJson &props, Dict &dict) +{ + if (!props.isObject()) { + mcpFail("properties must be a JSON object"); + } + for (size_t i = 0; i < props.numKeys(); i++) { + const std::string &keyName = props.keyAt(i); + const McpJson &value = props.valueAt(i); + NameKeyType key = TheNameKeyGenerator->nameToKey(keyName.c_str()); + if (value.isNull()) { + dict.remove(key); + continue; + } + Dict::DataType type = dict.getType(key); + if (type == Dict::DICT_NONE) { + if (value.isBool()) { + type = Dict::DICT_BOOL; + } else if (value.isNumber()) { + type = (value.asNumber() == floor(value.asNumber())) ? Dict::DICT_INT : Dict::DICT_REAL; + } else if (value.isString()) { + type = Dict::DICT_ASCIISTRING; + } else { + mcpFail("property '%s' must be a bool, number or string", keyName.c_str()); + } + } + switch (type) { + case Dict::DICT_BOOL: + if (!value.isBool()) mcpFail("property '%s' must be a bool", keyName.c_str()); + dict.setBool(key, value.asBool()); + break; + case Dict::DICT_INT: + if (!value.isNumber()) mcpFail("property '%s' must be a number", keyName.c_str()); + dict.setInt(key, (Int)floor(value.asNumber() + 0.5)); + break; + case Dict::DICT_REAL: + if (!value.isNumber()) mcpFail("property '%s' must be a number", keyName.c_str()); + dict.setReal(key, (Real)value.asNumber()); + break; + case Dict::DICT_ASCIISTRING: + if (!value.isString()) mcpFail("property '%s' must be a string", keyName.c_str()); + dict.setAsciiString(key, AsciiString(value.asString().c_str())); + break; + case Dict::DICT_UNICODESTRING: { + if (!value.isString()) mcpFail("property '%s' must be a string", keyName.c_str()); + UnicodeString u; + u.translate(AsciiString(value.asString().c_str())); + dict.setUnicodeString(key, u); + break; + } + default: + break; + } + } +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpJson.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpJson.cpp new file mode 100644 index 00000000000..3a623fe47b1 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpJson.cpp @@ -0,0 +1,435 @@ +/* +** 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 . +*/ + +// McpJson.cpp +// Minimal JSON value used by the WorldBuilder MCP command bridge. + +#include "StdAfx.h" +#include "mcp/McpJson.h" + +#include +#include +#include + +static const McpJson s_nullJson; + +size_t McpJson::size() const +{ + if (m_type == TYPE_ARRAY || m_type == TYPE_OBJECT) { + return m_values.size(); + } + return 0; +} + +const McpJson &McpJson::at(size_t ndx) const +{ + if (m_type != TYPE_ARRAY || ndx >= m_values.size()) { + return s_nullJson; + } + return m_values[ndx]; +} + +McpJson &McpJson::push(const McpJson &value) +{ + if (m_type != TYPE_ARRAY) { + *this = makeArray(); + } + m_values.push_back(value); + return m_values.back(); +} + +bool McpJson::has(const char *key) const +{ + if (m_type != TYPE_OBJECT) { + return false; + } + for (size_t i = 0; i < m_keys.size(); i++) { + if (m_keys[i] == key) { + return !m_values[i].isNull(); + } + } + return false; +} + +const McpJson &McpJson::get(const char *key) const +{ + if (m_type == TYPE_OBJECT) { + for (size_t i = 0; i < m_keys.size(); i++) { + if (m_keys[i] == key) { + return m_values[i]; + } + } + } + return s_nullJson; +} + +McpJson &McpJson::set(const char *key, const McpJson &value) +{ + if (m_type != TYPE_OBJECT) { + *this = makeObject(); + } + for (size_t i = 0; i < m_keys.size(); i++) { + if (m_keys[i] == key) { + m_values[i] = value; + return *this; + } + } + m_keys.push_back(key); + m_values.push_back(value); + return *this; +} + +//------------------------------------------------------------------------------------------------- +// Serialization +//------------------------------------------------------------------------------------------------- + +static void dumpString(std::string &out, const std::string &s) +{ + out += '"'; + for (size_t i = 0; i < s.size(); i++) { + unsigned char c = (unsigned char)s[i]; + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + default: + if (c < 0x20) { + char buf[8]; + sprintf(buf, "\\u%04x", c); + out += buf; + } else if (c >= 0x80) { + // Game strings are Windows-1252, map them to their code point so the output stays valid UTF-8 JSON. + char buf[8]; + sprintf(buf, "\\u%04x", c); + out += buf; + } else { + out += (char)c; + } + break; + } + } + out += '"'; +} + +void McpJson::dumpTo(std::string &out) const +{ + switch (m_type) { + case TYPE_NULL: + out += "null"; + break; + case TYPE_BOOL: + out += m_bool ? "true" : "false"; + break; + case TYPE_NUMBER: { + char buf[64]; + if (!_finite(m_number)) { + out += "null"; + } else if (m_number == floor(m_number) && fabs(m_number) < 1e15) { + sprintf(buf, "%.0f", m_number); + out += buf; + } else { + sprintf(buf, "%.9g", m_number); + out += buf; + } + break; + } + case TYPE_STRING: + dumpString(out, m_string); + break; + case TYPE_ARRAY: + out += '['; + for (size_t i = 0; i < m_values.size(); i++) { + if (i > 0) out += ','; + m_values[i].dumpTo(out); + } + out += ']'; + break; + case TYPE_OBJECT: + out += '{'; + for (size_t i = 0; i < m_keys.size(); i++) { + if (i > 0) out += ','; + dumpString(out, m_keys[i]); + out += ':'; + m_values[i].dumpTo(out); + } + out += '}'; + break; + } +} + +std::string McpJson::dump() const +{ + std::string out; + dumpTo(out); + return out; +} + +//------------------------------------------------------------------------------------------------- +// Parsing +//------------------------------------------------------------------------------------------------- + +namespace +{ + +class JsonParser +{ +public: + JsonParser(const std::string &text) : m_text(text), m_pos(0) {} + + bool parseDocument(McpJson &out, std::string &error) + { + if (!parseValue(out, 0)) { + error = m_error; + return false; + } + skipSpace(); + if (m_pos != m_text.size()) { + error = "trailing characters after JSON value"; + return false; + } + return true; + } + +private: + enum { MAX_DEPTH = 64 }; + + const std::string &m_text; + size_t m_pos; + std::string m_error; + + bool fail(const char *msg) + { + char buf[128]; + sprintf(buf, "%s at offset %u", msg, (unsigned)m_pos); + m_error = buf; + return false; + } + + void skipSpace() + { + while (m_pos < m_text.size()) { + char c = m_text[m_pos]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + m_pos++; + } else { + break; + } + } + } + + bool match(const char *word) + { + size_t len = strlen(word); + if (m_text.compare(m_pos, len, word) == 0) { + m_pos += len; + return true; + } + return false; + } + + static void appendUtf8(std::string &out, unsigned int cp) + { + if (cp < 0x80) { + out += (char)cp; + } else if (cp < 0x800) { + out += (char)(0xC0 | (cp >> 6)); + out += (char)(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += (char)(0xE0 | (cp >> 12)); + out += (char)(0x80 | ((cp >> 6) & 0x3F)); + out += (char)(0x80 | (cp & 0x3F)); + } else { + out += (char)(0xF0 | (cp >> 18)); + out += (char)(0x80 | ((cp >> 12) & 0x3F)); + out += (char)(0x80 | ((cp >> 6) & 0x3F)); + out += (char)(0x80 | (cp & 0x3F)); + } + } + + bool parseHex4(unsigned int &cp) + { + if (m_pos + 4 > m_text.size()) { + return fail("truncated \\u escape"); + } + cp = 0; + for (int i = 0; i < 4; i++) { + char c = m_text[m_pos++]; + cp <<= 4; + if (c >= '0' && c <= '9') cp |= c - '0'; + else if (c >= 'a' && c <= 'f') cp |= c - 'a' + 10; + else if (c >= 'A' && c <= 'F') cp |= c - 'A' + 10; + else return fail("bad \\u escape"); + } + return true; + } + + bool parseString(std::string &out) + { + // Caller has consumed the opening quote. + while (m_pos < m_text.size()) { + char c = m_text[m_pos++]; + if (c == '"') { + return true; + } + if (c != '\\') { + out += c; + continue; + } + if (m_pos >= m_text.size()) { + break; + } + char e = m_text[m_pos++]; + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + unsigned int cp; + if (!parseHex4(cp)) return false; + if (cp >= 0xD800 && cp <= 0xDBFF && m_pos + 6 <= m_text.size() && m_text[m_pos] == '\\' && m_text[m_pos + 1] == 'u') { + m_pos += 2; + unsigned int lo; + if (!parseHex4(lo)) return false; + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + } + appendUtf8(out, cp); + break; + } + default: + return fail("bad escape sequence"); + } + } + return fail("unterminated string"); + } + + bool parseNumber(McpJson &out) + { + const char *start = m_text.c_str() + m_pos; + char *end = nullptr; + double value = strtod(start, &end); + if (end == start) { + return fail("bad number"); + } + m_pos += end - start; + out = McpJson(value); + return true; + } + + bool parseValue(McpJson &out, int depth) + { + if (depth > MAX_DEPTH) { + return fail("nesting too deep"); + } + skipSpace(); + if (m_pos >= m_text.size()) { + return fail("unexpected end of input"); + } + char c = m_text[m_pos]; + if (c == '{') { + m_pos++; + out = McpJson::makeObject(); + skipSpace(); + if (m_pos < m_text.size() && m_text[m_pos] == '}') { + m_pos++; + return true; + } + for (;;) { + skipSpace(); + if (m_pos >= m_text.size() || m_text[m_pos] != '"') { + return fail("expected object key"); + } + m_pos++; + std::string key; + if (!parseString(key)) return false; + skipSpace(); + if (m_pos >= m_text.size() || m_text[m_pos] != ':') { + return fail("expected ':'"); + } + m_pos++; + McpJson value; + if (!parseValue(value, depth + 1)) return false; + out.set(key.c_str(), value); + skipSpace(); + if (m_pos < m_text.size() && m_text[m_pos] == ',') { + m_pos++; + continue; + } + if (m_pos < m_text.size() && m_text[m_pos] == '}') { + m_pos++; + return true; + } + return fail("expected ',' or '}'"); + } + } + if (c == '[') { + m_pos++; + out = McpJson::makeArray(); + skipSpace(); + if (m_pos < m_text.size() && m_text[m_pos] == ']') { + m_pos++; + return true; + } + for (;;) { + McpJson value; + if (!parseValue(value, depth + 1)) return false; + out.push(value); + skipSpace(); + if (m_pos < m_text.size() && m_text[m_pos] == ',') { + m_pos++; + continue; + } + if (m_pos < m_text.size() && m_text[m_pos] == ']') { + m_pos++; + return true; + } + return fail("expected ',' or ']'"); + } + } + if (c == '"') { + m_pos++; + std::string s; + if (!parseString(s)) return false; + out = McpJson(s); + return true; + } + if (match("true")) { out = McpJson(true); return true; } + if (match("false")) { out = McpJson(false); return true; } + if (match("null")) { out = McpJson(); return true; } + if (c == '-' || (c >= '0' && c <= '9')) { + return parseNumber(out); + } + return fail("unexpected character"); + } +}; + +} // namespace + +bool McpJson::parse(const std::string &text, McpJson &out, std::string &error) +{ + JsonParser parser(text); + return parser.parseDocument(out, error); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpScreenshot.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpScreenshot.cpp new file mode 100644 index 00000000000..7a6d5848af3 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpScreenshot.cpp @@ -0,0 +1,136 @@ +/* +** 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 . +*/ + +// McpScreenshot.cpp +// Writes the current 3D view back buffer to a PNG or JPEG file for the MCP command bridge. + +#include "StdAfx.h" +#include "mcp/McpScreenshot.h" + +#include "WW3D2/dx8wrapper.h" +#include "WW3D2/surfaceclass.h" +#include + +static int s_maxWidth = 0; +static int s_jpegQuality = 0; + +void mcpSetScreenshotOptions(int maxWidth, int jpegQuality) +{ + s_maxWidth = maxWidth; + s_jpegQuality = jpegQuality; +} + +/// Box-filter downscale of an RGB image by an integer factor. +static unsigned char *downscale(const unsigned char *src, unsigned int width, unsigned int height, unsigned int factor, unsigned int *outWidth, unsigned int *outHeight) +{ + const unsigned int w = width / factor; + const unsigned int h = height / factor; + unsigned char *dst = new unsigned char[3 * w * h]; + for (unsigned int y = 0; y < h; ++y) { + for (unsigned int x = 0; x < w; ++x) { + unsigned int sum[3] = { 0, 0, 0 }; + for (unsigned int sy = 0; sy < factor; ++sy) { + const unsigned char *row = src + 3 * ((y * factor + sy) * width + x * factor); + for (unsigned int sx = 0; sx < factor; ++sx) { + sum[0] += row[3 * sx + 0]; + sum[1] += row[3 * sx + 1]; + sum[2] += row[3 * sx + 2]; + } + } + unsigned char *out = dst + 3 * (y * w + x); + for (int c = 0; c < 3; ++c) { + out[c] = (unsigned char)(sum[c] / (factor * factor)); + } + } + } + *outWidth = w; + *outHeight = h; + return dst; +} + +bool mcpWriteBackBufferImage(const char *path, int *outWidth, int *outHeight) +{ + SurfaceClass *surface = DX8Wrapper::_Get_DX8_Back_Buffer(); + if (surface == nullptr) { + return false; + } + SurfaceClass::SurfaceDescription surfaceDesc; + surface->Get_Description(surfaceDesc); + + const bool is32Bit = surfaceDesc.Format == WW3D_FORMAT_A8R8G8B8 || surfaceDesc.Format == WW3D_FORMAT_X8R8G8B8; + const bool is16Bit = surfaceDesc.Format == WW3D_FORMAT_R5G6B5; + if (!is32Bit && !is16Bit) { + surface->Release_Ref(); + return false; + } + + // Copy to a lockable surface; the back buffer itself usually cannot be locked. + SurfaceClass *surfaceCopy = NEW_REF(SurfaceClass, (DX8Wrapper::_Create_DX8_Surface(surfaceDesc.Width, surfaceDesc.Height, surfaceDesc.Format))); + DX8Wrapper::_Copy_DX8_Rects(surface->Peek_D3D_Surface(), nullptr, 0, surfaceCopy->Peek_D3D_Surface(), nullptr); + surface->Release_Ref(); + surface = nullptr; + + int pitch = 0; + const unsigned char *bits = (const unsigned char *)surfaceCopy->Lock(&pitch); + if (bits == nullptr) { + surfaceCopy->Release_Ref(); + return false; + } + + unsigned int width = surfaceDesc.Width; + unsigned int height = surfaceDesc.Height; + unsigned char *image = new unsigned char[3 * width * height]; + for (unsigned int y = 0; y < height; ++y) { + for (unsigned int x = 0; x < width; ++x) { + unsigned char *dst = image + 3 * (x + y * width); + if (is32Bit) { + const unsigned int argb = ((const unsigned int *)(bits + y * pitch))[x]; + dst[0] = (unsigned char)(argb >> 16); + dst[1] = (unsigned char)(argb >> 8); + dst[2] = (unsigned char)(argb >> 0); + } else { + const unsigned short rgb = ((const unsigned short *)(bits + y * pitch))[x]; + dst[0] = (unsigned char)((rgb & 0xF800) >> 8); + dst[1] = (unsigned char)((rgb & 0x07E0) >> 3); + dst[2] = (unsigned char)((rgb & 0x001F) << 3); + } + } + } + surfaceCopy->Unlock(); + surfaceCopy->Release_Ref(); + + // Full-size frames are several MB as PNG, too large to hand to a model, so shrink them first. + if (s_maxWidth > 0 && width > (unsigned int)s_maxWidth) { + const unsigned int factor = (width + s_maxWidth - 1) / s_maxWidth; + unsigned char *smaller = downscale(image, width, height, factor, &width, &height); + delete[] image; + image = smaller; + } + + int success; + if (s_jpegQuality > 0) { + success = stbi_write_jpg(path, width, height, 3, image, s_jpegQuality); + } else { + success = stbi_write_png(path, width, height, 3, image, width * 3); + } + delete[] image; + + if (outWidth) *outWidth = width; + if (outHeight) *outHeight = height; + return success != 0; +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpServer.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpServer.cpp new file mode 100644 index 00000000000..ff666bacbf2 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpServer.cpp @@ -0,0 +1,342 @@ +/* +** 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 . +*/ + +// McpServer.cpp +// Localhost TCP command server that lets an external MCP server drive WorldBuilder. + +#include "StdAfx.h" +#include +#include + +#include "mcp/McpServer.h" +#include "mcp/McpCommands.h" +#include "Common/Debug.h" + +#include +#include +#include +#include + +namespace +{ + +const UINT WM_MCP_COMMAND = WM_APP + 0x4D; +const DWORD COMMAND_TIMEOUT_MS = 120000; +const size_t MAX_LINE_LENGTH = 32 * 1024 * 1024; +const char *const WINDOW_CLASS_NAME = "WorldBuilderMcpBridge"; + +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; + +HWND s_window = nullptr; +HANDLE s_thread = nullptr; +HANDLE s_stopEvent = nullptr; +volatile LONG s_stopping = 0; +SOCKET s_listenSocket = INVALID_SOCKET; +SOCKET s_clientSocket = INVALID_SOCKET; +int s_port = 0; +bool s_inDispatch = 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; +} + +/// Runs on the UI thread. +void drainQueue() +{ + // A command can pump messages (e.g. while loading a map), so don't recurse into the queue. + if (s_inDispatch) { + return; + } + s_inDispatch = true; + for (;;) { + RequestPtr req = popRequest(); + if (!req) { + break; + } + req->reply = mcpDispatch(req->line); + ::SetEvent(req->done); + } + s_inDispatch = false; +} + +LRESULT CALLBACK bridgeWindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) +{ + if (msg == WM_MCP_COMMAND) { + drainQueue(); + return 0; + } + return ::DefWindowProc(hwnd, msg, wParam, lParam); +} + +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 WorldBuilder to run the command"); + return reply.dump(); +} + +/// Hands one request line to the UI thread and waits for its reply. +std::string runOnUiThread(const std::string &line) +{ + RequestPtr req(new Request); + req->line = line; + ::EnterCriticalSection(&s_queueLock); + s_queue.push_back(req); + ::LeaveCriticalSection(&s_queueLock); + ::PostMessage(s_window, WM_MCP_COMMAND, 0, 0); + + 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 = runOnUiThread(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; + } + s_clientSocket = client; + serveClient(client); + s_clientSocket = INVALID_SOCKET; + ::closesocket(client); + } + return 0; +} + +} // namespace + +int McpServer::getRequestedPort() +{ + int port = 0; + for (int i = 1; i < __argc; i++) { + const char *arg = __argv[i]; + if (_stricmp(arg, "-mcp") == 0 || _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); + } + } + if (port == 0) { + const char *env = getenv("WB_MCP_PORT"); + if (env != nullptr && *env) { + port = atoi(env); + } + } + if (port < 0 || port > 65535) { + port = 0; + } + return port; +} + +bool McpServer::start(int port) +{ + if (s_thread != nullptr) { + return true; + } + if (!s_queueLockInited) { + ::InitializeCriticalSection(&s_queueLock); + s_queueLockInited = true; + } + + WNDCLASS wc; + memset(&wc, 0, sizeof(wc)); + wc.lpfnWndProc = bridgeWindowProc; + wc.hInstance = AfxGetInstanceHandle(); + wc.lpszClassName = WINDOW_CLASS_NAME; + ::RegisterClass(&wc); + s_window = ::CreateWindow(WINDOW_CLASS_NAME, "", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, wc.hInstance, nullptr); + if (s_window == nullptr) { + DEBUG_LOG(("MCP bridge: could not create message window")); + return false; + } + + WSADATA wsaData; + if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { + DEBUG_LOG(("MCP bridge: WSAStartup failed")); + ::DestroyWindow(s_window); + s_window = nullptr; + 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 edit and save files, 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(("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(); + ::DestroyWindow(s_window); + s_window = nullptr; + return false; + } + + s_stopping = 0; + s_stopEvent = ::CreateEvent(nullptr, TRUE, FALSE, nullptr); + s_port = port; + s_thread = (HANDLE)_beginthreadex(nullptr, 0, serverThread, nullptr, 0, nullptr); + if (s_thread == nullptr) { + stop(); + return false; + } + DEBUG_LOG(("MCP bridge: listening on 127.0.0.1:%d", port)); + return true; +} + +void McpServer::stop() +{ + if (s_window == nullptr) { + 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; + } + if (s_listenSocket != INVALID_SOCKET) { + ::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); + ::DestroyWindow(s_window); + s_window = nullptr; + s_port = 0; +} + +bool McpServer::isRunning() +{ + return s_thread != nullptr; +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/wbview3d.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/wbview3d.cpp index f44587f231c..e77cce6a642 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/wbview3d.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/wbview3d.cpp @@ -96,6 +96,8 @@ #include +#include "mcp/McpScreenshot.h" + // ---------------------------------------------------------------------------- // Misc. Forward Declarations @@ -385,6 +387,10 @@ WbView3d::WbView3d() : m_time(0), m_updateCount(0), m_needToLoadRoads(0), + m_capturePath(nullptr), + m_captureOk(false), + m_captureWidth(0), + m_captureHeight(0), m_timer(0), m_drawObject(nullptr), m_layer(nullptr), @@ -2084,6 +2090,18 @@ void WbView3d::redraw() m_time = ::GetTickCount(); } +// ---------------------------------------------------------------------------- +Bool WbView3d::captureToFile(const char *path, Int *width, Int *height) +{ + m_capturePath = path; + m_captureOk = false; + redraw(); + m_capturePath = nullptr; + if (width) *width = m_captureWidth; + if (height) *height = m_captureHeight; + return m_captureOk; +} + // ---------------------------------------------------------------------------- void WbView3d::render() { @@ -2143,6 +2161,9 @@ void WbView3d::render() drawLabels(nullptr); } + if (m_capturePath) { + m_captureOk = mcpWriteBackBufferImage(m_capturePath, &m_captureWidth, &m_captureHeight); + } WW3D::End_Render(); } diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md new file mode 100644 index 00000000000..61377493266 --- /dev/null +++ b/scripts/worldbuilder-mcp/README.md @@ -0,0 +1,64 @@ +# WorldBuilder MCP server + +Lets an MCP client (Claude Code, Claude Desktop, ...) edit the map that is open in Zero Hour +WorldBuilder: terrain heights and textures, objects, players, waypoints, water and trigger areas, +roads, map settings, save/load and 3D screenshots. + +``` +MCP client <-- stdio --> server.py <-- TCP 127.0.0.1:47800, JSON lines --> WorldBuilderZH.exe -mcp +``` + +The C++ side lives in `GeneralsMD/Code/Tools/WorldBuilder/src/mcp/`. WorldBuilder runs every command +on its UI thread and records each edit as a normal undo step. + +## Setup + +1. Build `z_worldbuilder` and start it with the bridge enabled: + + ``` + WorldBuilderZH.exe -mcp # port 47800 + WorldBuilderZH.exe -mcpport:47801 # custom port + ``` + + Setting the `WB_MCP_PORT` environment variable also enables it. The bridge only listens on + localhost. + + Add `-ignoreAsserts` to log debug-build assertions instead of showing blocking dialogs. A + dialog opened during a command stalls the bridge until the command times out. + +2. Install [uv](https://docs.astral.sh/uv/) (`winget install astral-sh.uv`). The dependencies are + declared in `pyproject.toml`. uv creates `scripts/worldbuilder-mcp/.venv` and keeps it in sync + on first run, and `uv.lock` pins the exact versions. + + ``` + uv sync --directory scripts/worldbuilder-mcp + ``` + +3. Register the server. The repo root `.mcp.json` already does this for Claude Code + (`uv run --directory scripts/worldbuilder-mcp server.py`). Other clients can use the same + command over stdio. Set `WB_MCP_PORT` in the server's environment if you use a custom port. + +## Checking the bridge without MCP + +``` +uv run --directory scripts/worldbuilder-mcp smoke_test.py +``` + +This creates a new map and discards unsaved changes in the open one. It then exercises every +command area, saves the map to a temp folder, reopens it and verifies it. + +## Protocol + +Each request is one line: `{"id": 1, "cmd": "map.info", "args": {}}`. The reply is +`{"id": 1, "ok": true, "result": {...}}` or `{"id": 1, "ok": false, "error": "..."}`. +`list_commands` lists every command with a short argument summary. + +WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in progress. + +## Notes + +- **World coordinates.** One terrain cell is 10 world units. (0,0) is the lower-left corner of the + playable area. +- **Heights.** Raw heights are 0..255; world height is raw × 0.625. +- **Undo.** WorldBuilder keeps 15 undo steps. Waypoint links are not undoable (same as in the UI). +- **Screenshots.** They render the 3D view, so the WorldBuilder window must not be minimized. diff --git a/scripts/worldbuilder-mcp/pyproject.toml b/scripts/worldbuilder-mcp/pyproject.toml new file mode 100644 index 00000000000..0a23f1d700c --- /dev/null +++ b/scripts/worldbuilder-mcp/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "worldbuilder-mcp" +version = "0.1.0" +description = "MCP server that drives Zero Hour WorldBuilder through its localhost command bridge" +requires-python = ">=3.10" +dependencies = [ + "mcp>=2.2,<3", +] + +[tool.uv] +# Scripts only, nothing to build or install as a package. +package = false diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py new file mode 100644 index 00000000000..a152e7637c1 --- /dev/null +++ b/scripts/worldbuilder-mcp/server.py @@ -0,0 +1,397 @@ +"""MCP server that lets an AI assistant edit the map open in Zero Hour WorldBuilder. + +Run WorldBuilderZH.exe with ``-mcp`` first, then point an MCP client at this script (stdio). +""" + +from __future__ import annotations + +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 + +INSTRUCTIONS = """\ +Edits the map that is open in Command & Conquer Generals Zero Hour WorldBuilder, live. + +Coordinates: +- World units: 10 per terrain cell. (0,0) is the lower left corner of the playable area; the + border extends to negative coordinates. Call map_info for the exact extents. +- Heights are raw values 0..255 on the heightmap vertices; world height = raw * 0.625. + terrain_get_heights/terrain_set_heights use heightmap vertex indices (which include the border); + every other command uses world units. +- Object angles are in degrees, counterclockwise, 0 = facing +x. +- Object z is the height above the terrain; 0 means on the ground. + +Workflow tips: +- Start with map_info. Use objects_list_templates / terrain_list_textures / roads_list_types to find + valid names instead of guessing. +- Object owners are team names from sides_list ("team" is neutral, "teamplayer0001" belongs to + player0001). Add players with sides_add_player. +- Every edit is one WorldBuilder undo step (edit_undo reverts it). WorldBuilder keeps only 15. +- Use view_screenshot to look at the result. +""" + +mcp = MCPServer("worldbuilder", instructions=INSTRUCTIONS) +_bridge = WorldBuilderBridge() + + +class _Bridge: + """Forwards to the bridge, turning its errors into ToolError so their text reaches the client + (the SDK hides the message of any other exception).""" + + @staticmethod + def call(cmd: str, **args: Any) -> Any: + try: + return _bridge.call(cmd, **args) + except WorldBuilderError as exc: + raise ToolError(str(exc)) from exc + + +wb = _Bridge() + +Shape = Literal["circle", "rect"] + + +def _shape_args(shape: Shape | None, x: float | None, y: float | None, radius: float | None, + x0: float | None, y0: float | None, x1: float | None, y1: float | None) -> dict[str, Any]: + return {"shape": shape, "x": x, "y": y, "radius": radius, "x0": x0, "y0": y0, "x1": x1, "y1": y1} + + +# -------------------------------------------------------------------------------------------------- +# Map +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def map_info() -> dict: + """Map file, size, border, world extents, height range, object counts, name, weather, time of day.""" + return wb.call("map.info") + + +@mcp.tool() +def map_new(width: int = 128, height: int = 128, initial_height: int = 16, border: int = 30, + discard_changes: bool = False) -> dict: + """Creates a new empty map. width/height are playable cells (excluding the border on each side). + Fails if the open map has unsaved changes unless discard_changes is true.""" + return wb.call("map.new", width=width, height=height, initial_height=initial_height, border=border, + discard_changes=discard_changes) + + +@mcp.tool() +def map_open(path: str, discard_changes: bool = False) -> dict: + """Opens a .map file (absolute path).""" + return wb.call("map.open", path=path, discard_changes=discard_changes) + + +@mcp.tool() +def map_save(path: str | None = None) -> dict: + """Saves the map. Without a path it saves to the current file; untitled maps need a full .map path. + WorldBuilder expects maps at /Maps//.map to show up in the game.""" + return wb.call("map.save", path=path) + + +@mcp.tool() +def map_set_info(name: str | None = None, + time_of_day: Literal["MORNING", "AFTERNOON", "EVENING", "NIGHT"] | None = None, + weather: Literal["NORMAL", "SNOWY"] | None = None) -> dict: + """Changes the map name, time of day or weather.""" + return wb.call("map.set_info", name=name, time_of_day=time_of_day, weather=weather) + + +@mcp.tool() +def edit_undo(count: int = 1) -> dict: + """Undoes the last `count` edits (max 15).""" + return wb.call("edit.undo", count=count) + + +@mcp.tool() +def edit_redo(count: int = 1) -> dict: + """Redoes `count` undone edits.""" + return wb.call("edit.redo", count=count) + + +# -------------------------------------------------------------------------------------------------- +# View +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def view_set_camera(x: float | None = None, y: float | None = None, angle_deg: float | None = None, + pitch: float | None = None, zoom: float | None = None, reset: bool = False) -> dict: + """Moves the 3D camera. x/y: world point to center on. angle_deg: camera yaw. pitch: 1.0 is the + default tilt, smaller looks more top-down. zoom: mouse wheel offset, 0 is default, positive zooms in + (about +-1200 is a strong zoom). reset restores the default camera first.""" + return wb.call("view.set_camera", x=x, y=y, angle_deg=angle_deg, pitch=pitch, zoom=zoom, reset=reset or None) + + +@mcp.tool() +def view_screenshot(x: float | None = None, y: float | None = None, angle_deg: float | None = None, + pitch: float | None = None, zoom: float | None = None, reset: bool = False, + max_width: int = 1280, format: Literal["jpg", "png"] = "jpg", quality: int = 85) -> Image: + """Renders the WorldBuilder 3D view and returns it as an image. Takes the same optional camera + arguments as view_set_camera. The frame is downscaled to at most max_width pixels (0 = full size). + The WorldBuilder window must be visible (not minimized).""" + result = wb.call("view.screenshot", x=x, y=y, angle_deg=angle_deg, pitch=pitch, zoom=zoom, + reset=reset or None, max_width=max_width, format=format, quality=quality) + return Image(path=result["path"]) + + +# -------------------------------------------------------------------------------------------------- +# Terrain +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def terrain_get_heights(x0: int = 0, y0: int = 0, w: int | None = None, h: int | None = None, + step: int = 1) -> dict: + """Reads raw vertex heights (0..255) as rows (row 0 = y0). Indices are heightmap vertex indices + including the border; vertex (border, border) is world (0,0). Use step>1 to downsample large areas. + At most 65536 values per call.""" + return wb.call("terrain.get_heights", x0=x0, y0=y0, w=w, h=h, step=step) + + +@mcp.tool() +def terrain_set_heights(x0: int, y0: int, heights: list[list[int | None]]) -> dict: + """Writes raw vertex heights (0..255). heights[r][c] goes to vertex (x0+c, y0+r); null leaves a + vertex unchanged. One undo step.""" + return wb.call("terrain.set_heights", x0=x0, y0=y0, heights=heights) + + +@mcp.tool() +def terrain_brush(op: Literal["raise", "lower", "set", "flatten", "smooth", "noise"], + shape: Shape | 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, + feather: float = 0, amount: float | None = None, height: float | None = None, + strength: float = 1.0, iterations: int = 1, seed: int | None = None) -> dict: + """Shapes terrain in a world-space area: a circle (x, y, radius) or a rect (x0, y0, x1, y1). + feather: world-unit falloff band outside the shape. + - raise/lower: change height by `amount` raw units (16 raw = 10 world units). + - set: move heights to `height` (raw). + - flatten: like set; without `height` it uses the height at the center. + - smooth: average neighbours; raise `iterations` for stronger smoothing. + - noise: random +-`amount` raw variation (deterministic per `seed`). + strength (0..1) scales the effect.""" + return wb.call("terrain.brush", op=op, feather=feather, amount=amount, height=height, strength=strength, + iterations=iterations, seed=seed, **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + +@mcp.tool() +def terrain_list_textures(filter: str | None = None) -> dict: + """Terrain texture classes (name, ui_name, whether already used / can still fit in this map).""" + return wb.call("terrain.list_textures", filter=filter) + + +@mcp.tool() +def terrain_paint_texture(texture: str, shape: Shape | 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) -> dict: + """Paints a terrain texture (name from terrain_list_textures) over a circle or rect in world units. + Follow up with terrain_auto_blend to soften hard edges.""" + return wb.call("terrain.paint_texture", texture=texture, **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + +@mcp.tool() +def terrain_flood_fill(texture: str, x: float, y: float, replace_all: bool = False) -> dict: + """Flood fills the connected texture region under world point (x, y). replace_all replaces that + texture everywhere on the map.""" + return wb.call("terrain.flood_fill", texture=texture, x=x, y=y, replace_all=replace_all) + + +@mcp.tool() +def terrain_auto_blend(x: float, y: float, edge_texture: str | None = None) -> dict: + """Blends the edges of the texture region under world point (x, y) into its neighbours, optionally + using a blend-edge texture.""" + return wb.call("terrain.auto_blend", x=x, y=y, edge_texture=edge_texture) + + +@mcp.tool() +def terrain_set_passability(impassable: bool = True, shape: Shape | 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) -> dict: + """Marks cells impassable (or passable again) over a circle or rect in world units.""" + return wb.call("terrain.set_passability", impassable=impassable, + **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + +@mcp.tool() +def terrain_sample(x: float, y: float) -> dict: + """Height, texture and passability at a world point.""" + return wb.call("terrain.sample", x=x, y=y) + + +# -------------------------------------------------------------------------------------------------- +# Objects and sides +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def objects_list_templates(filter: str | None = None, editor_sorting: str | None = None, side: str | None = None, + limit: int = 200, offset: int = 0) -> dict: + """Searches placeable object templates. filter: substring of the template name. + editor_sorting: STRUCTURE, INFANTRY, VEHICLE, SHRUBBERY, MISC_MAN_MADE, MISC_NATURAL, DEBRIS, SYSTEM, + AUDIO, TEST, FOR_REVIEW. side: e.g. America, China, GLA, Civilian.""" + return wb.call("objects.list_templates", filter=filter, editor_sorting=editor_sorting, side=side, + limit=limit, offset=offset) + + +@mcp.tool() +def objects_list(template: str | None = None, owner: str | None = None, + x0: float | None = None, y0: float | None = None, x1: float | None = None, y1: float | None = None, + include_waypoints_and_roads: bool = False, limit: int = 500) -> dict: + """Lists objects in the map with their ids (ids are stable for this WorldBuilder session). + Filter by template substring, exact owner team, or a world rect (x0, y0, x1, y1).""" + return wb.call("objects.list", template=template, owner=owner, x0=x0, y0=y0, x1=x1, y1=y1, + include_waypoints_and_roads=include_waypoints_and_roads, limit=limit) + + +@mcp.tool() +def objects_get(id: int) -> dict: + """One object including its full property dictionary.""" + return wb.call("objects.get", id=id) + + +@mcp.tool() +def objects_place(template: str, x: float, y: float, z: float = 0, angle_deg: float | None = None, + owner: str | None = None, name: str | None = None, + properties: dict[str, Any] | None = None) -> dict: + """Places an object. owner: team name, player name or "neutral" (default). name: script name + (objectName). properties: extra keys such as objectInitialHealth (int %), objectVeterancy (0-3), + objectAggressiveness, objectIndestructible, objectUnsellable, objectEnabled, objectPowered. + angle_deg defaults to the template's placement angle.""" + return wb.call("objects.place", template=template, x=x, y=y, z=z, angle_deg=angle_deg, owner=owner, + name=name, properties=properties) + + +@mcp.tool() +def objects_place_many(objects: list[dict[str, Any]]) -> dict: + """Places several objects; each entry takes the same fields as objects_place. Each placement is + its own undo step, so keep batches small if you may need to undo.""" + placed, errors = [], [] + for i, spec in enumerate(objects): + try: + placed.append(wb.call("objects.place", **spec)) + except Exception as exc: # report per-object errors and keep going + errors.append({"index": i, "error": str(exc)}) + return {"placed": placed, "errors": errors} + + +@mcp.tool() +def objects_modify(ids: list[int], x: float | None = None, y: float | None = None, + dx: float | None = None, dy: float | None = None, z: float | None = None, + angle_deg: float | None = None, rotate_deg: float | None = None, + template: str | None = None) -> dict: + """Moves (absolute x/y for a single object, or relative dx/dy), sets z above ground, rotates + (absolute angle_deg or relative rotate_deg) or swaps the template of objects. One undo step.""" + return wb.call("objects.modify", ids=ids, x=x, y=y, dx=dx, dy=dy, z=z, angle_deg=angle_deg, + rotate_deg=rotate_deg, template=template) + + +@mcp.tool() +def objects_set_properties(ids: list[int], properties: dict[str, Any] | None = None, + owner: str | None = None, name: str | None = None) -> dict: + """Sets object properties (see objects_get for keys). A null value removes a key. owner/name are + shortcuts for originalOwner/objectName.""" + return wb.call("objects.set_properties", ids=ids, properties=properties, owner=owner, name=name) + + +@mcp.tool() +def objects_delete(ids: list[int]) -> dict: + """Deletes objects (also waypoints or road points by id).""" + return wb.call("objects.delete", ids=ids) + + +@mcp.tool() +def sides_list() -> dict: + """Players (with their property dicts) and teams. Object owners are team names.""" + return wb.call("sides.list") + + +@mcp.tool() +def sides_list_factions() -> dict: + """Player templates (factions) for sides_add_player, e.g. FactionAmerica, FactionChina, FactionGLA.""" + return wb.call("sides.list_factions") + + +@mcp.tool() +def sides_add_player(faction: str, name: str | None = None, display_name: str | None = None, + is_human: bool = True, allies: str | None = None, enemies: str | None = None) -> dict: + """Adds a player (and its default team "team"). allies/enemies are space separated player names.""" + return wb.call("sides.add_player", faction=faction, name=name, display_name=display_name, is_human=is_human, + allies=allies, enemies=enemies) + + +# -------------------------------------------------------------------------------------------------- +# Waypoints, polygons, roads +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def waypoints_list() -> dict: + """All waypoints (with object ids, waypoint ids, names, path labels) and the links between them.""" + return wb.call("waypoints.list") + + +@mcp.tool() +def waypoints_add(x: float, y: float, name: str | None = None, path_labels: list[str] | None = None, + link_from: str | int | None = None) -> dict: + """Adds a waypoint. Special names include Player_1_Start .. Player_8_Start for skirmish start + positions. link_from: name or waypoint id of an existing waypoint to link to the new one.""" + return wb.call("waypoints.add", x=x, y=y, name=name, path_labels=path_labels, link_from=link_from) + + +@mcp.tool() +def waypoints_link(from_waypoint: str | int, to_waypoint: str | int) -> dict: + """Links two waypoints (names or waypoint ids) into a path. Not undoable.""" + return wb.call("waypoints.link", **{"from": from_waypoint, "to": to_waypoint}) + + +@mcp.tool() +def waypoints_unlink(from_waypoint: str | int, to_waypoint: str | int) -> dict: + """Removes a waypoint link. Not undoable.""" + return wb.call("waypoints.unlink", **{"from": from_waypoint, "to": to_waypoint}) + + +@mcp.tool() +def polygons_list(include_points: bool = False) -> dict: + """Trigger areas and water areas.""" + return wb.call("polygons.list", include_points=include_points) + + +@mcp.tool() +def polygons_add(points: list[list[float]], kind: Literal["area", "water", "river"] = "area", + name: str | None = None, water_height: float | None = None, river_start: int | None = None) -> dict: + """Adds a polygon from [x, y] world points. area: script trigger area. water: a water surface at + water_height (world units; compare with terrain world heights). river: flowing water, starting at + point index river_start.""" + return wb.call("polygons.add", points=points, kind=kind, name=name, water_height=water_height, + river_start=river_start) + + +@mcp.tool() +def polygons_delete(id: int) -> dict: + """Deletes a polygon by id.""" + return wb.call("polygons.delete", id=id) + + +@mcp.tool() +def roads_list_types() -> dict: + """Road types and bridge types. Landmark bridges are placed with objects_place instead.""" + return wb.call("roads.list_types") + + +@mcp.tool() +def roads_add(type: str, points: list[list[float]], + corners: Literal["curved", "angled", "tight"] = "curved") -> dict: + """Adds a road along [x, y] world points (one segment per consecutive pair), or a bridge with + exactly 2 points.""" + return wb.call("roads.add", type=type, points=points, corners=corners) + + +@mcp.tool() +def worldbuilder_command(cmd: str, args: dict[str, Any] | None = None) -> Any: + """Runs a raw bridge command (see list_commands) for anything not covered by the other tools.""" + return wb.call(cmd, **(args or {})) + + +if __name__ == "__main__": + mcp.run() diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py new file mode 100644 index 00000000000..fe10bb4d023 --- /dev/null +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -0,0 +1,92 @@ +"""End-to-end check of the WorldBuilder command bridge, without MCP. + +Start WorldBuilderZH.exe -mcp, then run: python smoke_test.py [--keep] +It creates a new map (discarding unsaved changes in the open one), edits it, saves it to a temp +folder, reopens it and checks the result. +""" + +from __future__ import annotations + +import os +import sys +import tempfile + +from wb_bridge import WorldBuilderBridge, WorldBuilderError + + +def main() -> int: + wb = WorldBuilderBridge() + commands = {c["name"] for c in wb.call("list_commands")["commands"]} + print(f"{len(commands)} bridge commands") + + info = wb.call("map.new", width=96, height=96, initial_height=20, border=10, discard_changes=True) + print("new map:", info["heightmap"], info["world"]) + + r = wb.call("terrain.brush", op="raise", x=300, y=300, radius=80, feather=60, amount=40) + assert r["changed"], r + sample = wb.call("terrain.sample", x=300, y=300) + assert sample["height_raw"] == 60, sample + wb.call("terrain.brush", op="smooth", x=300, y=300, radius=150, iterations=3) + + textures = wb.call("terrain.list_textures")["textures"] + assert textures, "no textures loaded" + wb.call("terrain.paint_texture", texture=textures[0]["name"], x=600, y=600, radius=100) + + player = wb.call("sides.add_player", faction="FactionAmerica") + team = player["default_team"] + sides = wb.call("sides.list") + assert any(t["name"] == team for t in sides["teams"]), sides + + templates = wb.call("objects.list_templates", filter="AmericaBarracks", limit=5)["templates"] + assert templates, "AmericaBarracks template not found" + barracks = wb.call("objects.place", template=templates[0]["name"], x=300, y=300, angle_deg=45, owner=team) + trees = wb.call("objects.list_templates", editor_sorting="SHRUBBERY", limit=1)["templates"] + if trees: + wb.call("objects.place", template=trees[0]["name"], x=500, y=200) + wb.call("objects.modify", ids=[barracks["id"]], dx=20, rotate_deg=45) + moved = wb.call("objects.get", id=barracks["id"]) + assert abs(moved["x"] - 320) < 0.01 and abs(moved["angle_deg"] - 90) < 0.1, moved + wb.call("objects.set_properties", ids=[barracks["id"]], name="MyBarracks", properties={"objectInitialHealth": 50}) + + wb.call("waypoints.add", x=100, y=100, name="Player_1_Start") + wb.call("waypoints.add", x=200, y=100, name="PathA") + wb.call("waypoints.add", x=300, y=150, name="PathB", link_from="PathA") + wp = wb.call("waypoints.list") + assert len(wp["waypoints"]) == 3 and len(wp["links"]) == 1, wp + + wb.call("polygons.add", kind="water", points=[[700, 100], [900, 100], [900, 300], [700, 300]], water_height=20) + wb.call("polygons.add", kind="area", name="BaseArea", points=[[250, 250], [350, 250], [350, 350], [250, 350]]) + + roads = wb.call("roads.list_types")["roads"] + if roads: + wb.call("roads.add", type=roads[0], points=[[50, 500], [300, 500], [400, 700]]) + + shot = wb.call("view.screenshot", x=400, y=400) + print("screenshot:", shot["path"], shot["width"], "x", shot["height"]) + + # Undo/redo round trip on the last edit. + before = wb.call("map.info")["counts"] + wb.call("edit.undo") + wb.call("edit.redo") + assert wb.call("map.info")["counts"] == before + + out_dir = tempfile.mkdtemp(prefix="wb_mcp_") + path = os.path.join(out_dir, "mcp_smoke.map") + wb.call("map.save", path=path) + counts = wb.call("map.info")["counts"] + wb.call("map.open", path=path) + reopened = wb.call("map.info")["counts"] + assert reopened == counts, (counts, reopened) + names = [o.get("name") for o in wb.call("objects.list")["objects"]] + assert "MyBarracks" in names, names + print("saved and reopened:", path, reopened) + print("OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except WorldBuilderError as exc: + print("FAILED:", exc) + sys.exit(1) diff --git a/scripts/worldbuilder-mcp/uv.lock b/scripts/worldbuilder-mcp/uv.lock new file mode 100644 index 00000000000..8dcc0768f08 --- /dev/null +++ b/scripts/worldbuilder-mcp/uv.lock @@ -0,0 +1,917 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/dc/e12c1fe1ed8a7b7149777127b1a0e12ce5bd5a81d97408bedc2128c260f5/opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7", size = 72115, upload-time = "2026-09-25T12:32:28.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/040d1a1c7836922828e6480cd2366bb8fe0ebf75b413d2bb51a9b0e7f78f/opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3", size = 60020, upload-time = "2026-09-25T12:32:03.192Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[[package]] +name = "worldbuilder-mcp" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", specifier = ">=2.2,<3" }] diff --git a/scripts/worldbuilder-mcp/wb_bridge.py b/scripts/worldbuilder-mcp/wb_bridge.py new file mode 100644 index 00000000000..5ab9fa12a20 --- /dev/null +++ b/scripts/worldbuilder-mcp/wb_bridge.py @@ -0,0 +1,84 @@ +"""TCP client for the WorldBuilder MCP command bridge. + +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 +``{"id": 1, "ok": true, "result": {...}}`` or ``{"id": 1, "ok": false, "error": "..."}``. +""" + +from __future__ import annotations + +import itertools +import json +import os +import socket +import threading +from typing import Any + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 47800 + + +class WorldBuilderError(RuntimeError): + """Raised when WorldBuilder 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)) + self.timeout = timeout + self._sock: socket.socket | None = None + self._reader = None + self._lock = threading.Lock() + self._ids = itertools.count(1) + + def _connect(self) -> None: + try: + 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." + ) from exc + sock.settimeout(self.timeout) + self._sock = sock + self._reader = sock.makefile("r", encoding="utf-8", newline="\n") + + def close(self) -> None: + if self._reader is not None: + self._reader.close() + if self._sock is not None: + self._sock.close() + self._sock = None + self._reader = None + + def _roundtrip(self, line: str) -> str: + if self._sock is None: + self._connect() + assert self._sock is not None and self._reader is not None + self._sock.sendall(line.encode("utf-8")) + reply = self._reader.readline() + if not reply: + raise ConnectionError("WorldBuilder closed the connection") + return reply + + def call(self, cmd: str, **args: Any) -> Any: + """Runs one bridge command and returns its result. None-valued args are omitted.""" + args = {k: v for k, v in args.items() if v is not None} + with self._lock: + request_id = next(self._ids) + line = json.dumps({"id": request_id, "cmd": cmd, "args": args}) + "\n" + try: + reply = self._roundtrip(line) + except (OSError, ConnectionError): + # WorldBuilder 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 + message = json.loads(reply) + if not message.get("ok"): + raise WorldBuilderError(message.get("error", "unknown error")) + return message.get("result") From bb0b9e5101b8c15702257d355fa4673350e83a8a Mon Sep 17 00:00:00 2001 From: pWn3d Date: Wed, 30 Sep 2026 18:51:07 +0200 Subject: [PATCH 02/30] fix(worldbuilder): Fixes MCP flood fill replace and camera pitch terrain.flood_fill with replace_all, or with a texture that no longer fits in the map, silently did nothing: WorldHeightMapEdit::floodFill opened a confirmation message box, which blocks a bridge command. It now takes an askToConfirm flag; the interactive tool keeps prompting, the bridge skips the prompt and reports clear errors instead. The camera pitch was documented backwards (larger is steeper), and with pitch != 1 the requested x/y was no longer at the screen center, because WorldBuilder tilts the view ray away from the camera target. The bridge now compensates so x/y is the point in the middle of the screen, reports that point back, and rejects pitch <= 0 before changing the camera. Co-Authored-By: Claude Opus 5.5 --- .../WorldBuilder/include/WHeightMapEdit.h | 2 +- .../Tools/WorldBuilder/src/WHeightMapEdit.cpp | 17 ++++--- .../Tools/WorldBuilder/src/mcp/McpCmdMap.cpp | 46 +++++++++++++++---- .../WorldBuilder/src/mcp/McpCmdTerrain.cpp | 20 +++++++- scripts/worldbuilder-mcp/server.py | 14 ++++-- 5 files changed, 77 insertions(+), 22 deletions(-) diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h index c51ffb3a4d0..31d1a2c72f7 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h @@ -119,7 +119,7 @@ class WorldHeightMapEdit : public WorldHeightMap void setHeight(Int xIndex, Int yIndex, UnsignedByte height); void setCliff(Int xIndex, Int yIndex, Bool impassable) {setCliffState(xIndex, yIndex, impassable);} Bool setTileNdx(Int xIndex, Int yIndex, Int textureClass, Bool singleTile); - Bool floodFill(Int xIndex, Int yIndex, Int textureClass, Bool doReplace); + Bool floodFill(Int xIndex, Int yIndex, Int textureClass, Bool doReplace, Bool askToConfirm = true); static Int getNumTexClasses() {return m_numGlobalTextureClasses;}; static AsciiString getTexClassName(int ndx) {return m_globalTextureClasses[ndx].name;} static AsciiString getTexClassUiName(int ndx) ; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp index 2b0faa17a1e..28150d7a0e7 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp @@ -1435,7 +1435,7 @@ void WorldHeightMapEdit::blendToThisClass(Int xIndex, Int yIndex, Fills the region at xIndex, yIndex with the specified texture. DoReplace forces texture to be replaced on entire map. */ -Bool WorldHeightMapEdit::floodFill(Int xIndex, Int yIndex, Int textureClass, Bool doReplace) +Bool WorldHeightMapEdit::floodFill(Int xIndex, Int yIndex, Int textureClass, Bool doReplace, Bool askToConfirm) { Int ndx = (yIndex*m_width)+xIndex; Int curTileClass = getTextureClass(xIndex, yIndex, true); @@ -1455,11 +1455,16 @@ Bool WorldHeightMapEdit::floodFill(Int xIndex, Int yIndex, Int textureClass, Boo } } if (!canFitTexture(textureClass) || doReplace) { - CString confirm; - confirm.Format(IDS_CONFIRM_REPLACE_TEXTURE, m_globalTextureClasses[curTileClass].name.str()); - Int msg = ::AfxMessageBox(confirm, MB_YESNO); - ::AfxGetMainWnd()->SetFocus(); - if (msg == IDNO) { + if (askToConfirm) { + CString confirm; + confirm.Format(IDS_CONFIRM_REPLACE_TEXTURE, m_globalTextureClasses[curTileClass].name.str()); + Int msg = ::AfxMessageBox(confirm, MB_YESNO); + ::AfxGetMainWnd()->SetFocus(); + if (msg == IDNO) { + return false; + } + } else if (!doReplace) { + // Without a prompt, only replace everywhere when the caller asked for it. return false; } doReplace = true; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp index 8ea5233f734..4c2a7788f99 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp @@ -247,19 +247,42 @@ WbView3d *require3DView() return view; } +/// Ground point at the center of the screen. WorldBuilder applies pitch by tilting the view ray away +/// from the camera target (see WbView3d::setupCamera), so for pitch != 1 the screen center lies at +/// target + (source - target) * (1 - 1/pitch) rather than at the target itself. +void getLookAtPoint(WbView3d *view, Real *x, Real *y) +{ + const Vector3 source = view->getCameraSource(); + const Vector3 target = view->getCameraTarget(); + const Real k = 1.0f - 1.0f / view->getCameraPitch(); + *x = target.X + (source.X - target.X) * k; + *y = target.Y + (source.Y - target.Y) * k; +} + McpJson describeCamera(WbView3d *view) { McpJson j = McpJson::makeObject(); j.set("angle_deg", view->getCameraAngle() * 180.0 / PI); j.set("pitch", view->getCameraPitch()); j.set("zoom", view->getZoomOffset()); - Vector3 target = view->getCameraTarget(); - j.set("target_x", target.X).set("target_y", target.Y).set("target_z", target.Z); + Real lookX, lookY; + getLookAtPoint(view, &lookX, &lookY); + j.set("x", lookX).set("y", lookY); return j; } void applyCamera(WbView3d *view, const McpJson &args) { + // Validate before changing anything so a bad argument leaves the camera untouched. + const Real pitch = (Real)mcpArgNumber(args, "pitch", 1.0); + if (pitch <= 0) { + mcpFail("pitch must be > 0 (1.0 is the default tilt, larger looks more top-down)"); + } + Real lookX, lookY; + getLookAtPoint(view, &lookX, &lookY); + const Real x = (Real)mcpArgNumber(args, "x", lookX); + const Real y = (Real)mcpArgNumber(args, "y", lookY); + if (mcpArgBool(args, "reset", false)) { view->setDefaultCamera(); } @@ -270,14 +293,19 @@ void applyCamera(WbView3d *view, const McpJson &args) view->setZoomOffset((Real)mcpArgNumber(args, "zoom")); } if (args.has("pitch")) { - view->setCameraPitch((Real)mcpArgNumber(args, "pitch")); + view->setCameraPitch(pitch); } - if (args.has("x") || args.has("y")) { - Vector3 target = view->getCameraTarget(); - Real x = (Real)mcpArgNumber(args, "x", target.X); - Real y = (Real)mcpArgNumber(args, "y", target.Y); + // Keep the requested (or previous) point in the middle of the screen. The camera offset depends a + // little on the ground height under the new center, so refine it a few times. + for (Int i = 0; i < 3; i++) { + view->redraw(); + const Vector3 source = view->getCameraSource(); + const Vector3 target = view->getCameraTarget(); + const Real k = 1.0f - 1.0f / view->getCameraPitch(); + const Real centerX = x - (source.X - target.X) * k; + const Real centerY = y - (source.Y - target.Y) * k; // The view center is kept in cell units. - view->setCenterInView(x / MAP_XY_FACTOR, y / MAP_XY_FACTOR); + view->setCenterInView(centerX / MAP_XY_FACTOR, centerY / MAP_XY_FACTOR); } view->redraw(); } @@ -334,6 +362,6 @@ void mcpRegisterMapCommands() mcpRegisterCommand("map.set_info", cmdMapSetInfo, "{name?,time_of_day?,weather?} Changes map settings."); mcpRegisterCommand("edit.undo", cmdUndo, "{count?} Undoes the last edits."); mcpRegisterCommand("edit.redo", cmdRedo, "{count?} Redoes undone edits."); - mcpRegisterCommand("view.set_camera", cmdSetCamera, "{x?,y?,angle_deg?,pitch?,zoom?,reset?} Moves the 3D camera."); + mcpRegisterCommand("view.set_camera", cmdSetCamera, "{x?,y?,angle_deg?,pitch?,zoom?,reset?} Moves the 3D camera. pitch: 1 default, larger = more top-down. zoom: wheel offset, negative zooms out."); mcpRegisterCommand("view.screenshot", cmdScreenshot, "{path?,format?:jpg|png,max_width?,quality?,x?,y?,angle_deg?,pitch?,zoom?,reset?} Renders the 3D view to an image."); } diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp index 5067c3dc5ba..2701cb64385 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp @@ -407,12 +407,30 @@ McpJson cmdFloodFill(const McpJson &args) if (x < 0 || y < 0 || x >= map->getXExtent() || y >= map->getYExtent()) { mcpFail("point is outside the map"); } + const bool replaceAll = mcpArgBool(args, "replace_all", false); + const Int curClass = map->getTextureClass(x, y, true); + if (curClass < 0) { + mcpFail("the cell under the point has no single base texture to fill"); + } + if (curClass == texClass) { + return McpJson::makeObject().set("changed", false).set("reason", "the region already has this texture"); + } + if (!replaceAll && !map->isTexClassUsed(texClass) && !map->canFitTexture(texClass)) { + mcpFail("the map has no room for another texture; pass replace_all=true to swap '%s' for it everywhere", + WorldHeightMapEdit::getTexClassName(curClass).str()); + } + WorldHeightMapEdit *copy = map->duplicate(); EditRange range; - if (copy->floodFill(x, y, texClass, mcpArgBool(args, "replace_all", false))) { + // Never let WorldHeightMapEdit ask for confirmation: a message box would block the bridge. + if (copy->floodFill(x, y, texClass, replaceAll, false)) { range.add(0, 0); range.add(copy->getXExtent() - 1, copy->getYExtent() - 1); range.fullUpdate = true; + } else { + REF_PTR_RELEASE(copy); + mcpFail("flood fill failed (with replace_all, the new texture may need more tile space than '%s')", + WorldHeightMapEdit::getTexClassName(curClass).str()); } return commitHeightMapEdit(copy, range); } diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index a152e7637c1..4ce7f5eed07 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -118,9 +118,12 @@ def edit_redo(count: int = 1) -> dict: @mcp.tool() def view_set_camera(x: float | None = None, y: float | None = None, angle_deg: float | None = None, pitch: float | None = None, zoom: float | None = None, reset: bool = False) -> dict: - """Moves the 3D camera. x/y: world point to center on. angle_deg: camera yaw. pitch: 1.0 is the - default tilt, smaller looks more top-down. zoom: mouse wheel offset, 0 is default, positive zooms in - (about +-1200 is a strong zoom). reset restores the default camera first.""" + """Moves the 3D camera. x/y: world point shown in the middle of the screen (kept when omitted); + the result reports the current one. angle_deg: camera yaw. + pitch: must be > 0; 1.0 is the default tilt, larger values look more straight down (about 2.5 is + nearly top-down), smaller values flatten toward the horizon. zoom: mouse wheel offset, 0 is default, + positive zooms in, negative zooms out (about -3500 shows a whole 240x240 map). + reset restores the default camera first; the other arguments are applied after it.""" return wb.call("view.set_camera", x=x, y=y, angle_deg=angle_deg, pitch=pitch, zoom=zoom, reset=reset or None) @@ -193,8 +196,9 @@ def terrain_paint_texture(texture: str, shape: Shape | None = None, @mcp.tool() def terrain_flood_fill(texture: str, x: float, y: float, replace_all: bool = False) -> dict: - """Flood fills the connected texture region under world point (x, y). replace_all replaces that - texture everywhere on the map.""" + """Flood fills the connected texture region under world point (x, y) with `texture`. + replace_all instead replaces the texture under the point everywhere on the map; use it when the map + has no room for another texture (it swaps one texture for the other).""" return wb.call("terrain.flood_fill", texture=texture, x=x, y=y, replace_all=replace_all) From da4f8edff5a2bcb726b805bb54ea8224ba9219be Mon Sep 17 00:00:00 2001 From: pWn3d Date: Wed, 30 Sep 2026 19:28:40 +0200 Subject: [PATCH 03/30] feat(worldbuilder): Adds MCP heightmap and mask image import/export New bridge commands exchange terrain data with image files (north up): - terrain.export_heightmap / import_heightmap: one pixel per vertex, raw heights, 16-bit import, set or add mode with a height range, stretch/exact/center fit, optional smoothing. - terrain.export_mask / import_mask: one pixel per cell for passability, a texture or water coverage; import paints a texture or sets passability where the mask passes a threshold. Images are read with stb_image, compiled only into WorldBuilder. The MCP tools resolve relative paths and can return exported images so the model can inspect the terrain. Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 1 + .../WorldBuilder/include/mcp/McpCommands.h | 8 + .../WorldBuilder/src/mcp/McpCmdImage.cpp | 437 ++++++++++++++++++ .../WorldBuilder/src/mcp/McpCmdTerrain.cpp | 14 + .../WorldBuilder/src/mcp/McpCommands.cpp | 1 + scripts/worldbuilder-mcp/README.md | 4 + scripts/worldbuilder-mcp/server.py | 68 +++ scripts/worldbuilder-mcp/smoke_test.py | 11 + 8 files changed, 544 insertions(+) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index ebdb62136d3..8e6eda61747 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -199,6 +199,7 @@ set(WORLDBUILDER_SRC "include/mcp/McpJson.h" "include/mcp/McpScreenshot.h" "include/mcp/McpServer.h" + "src/mcp/McpCmdImage.cpp" "src/mcp/McpCmdMap.cpp" "src/mcp/McpCmdObjects.cpp" "src/mcp/McpCmdTerrain.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index 6a6e941d9e9..40fd41a3104 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -48,6 +48,7 @@ void mcpRegisterMapCommands(); void mcpRegisterTerrainCommands(); void mcpRegisterObjectCommands(); void mcpRegisterWaypointCommands(); +void mcpRegisterImageCommands(); //------------------------------------------------------------------------------------------------- // Helpers @@ -70,6 +71,13 @@ WorldHeightMapEdit *mcpHeightMap(); /// Commits an undoable to the active document, which takes ownership. void mcpCommit(Undoable *undo); +/// Refreshes the views from an edited heightmap copy and commits it as one undo step (releases the copy). +/// Pass texturesChanged when tiles were repainted, so the tile set is optimized and the texture list refreshed. +McpJson mcpCommitHeightMapEdit(WorldHeightMapEdit *copy, bool texturesChanged); + +/// Texture class from a name or index argument (see terrain.list_textures). +int mcpFindTextureClass(const McpJson &args, const char *key); + /// Heightmap vertex index for a world coordinate (nearest vertex, not clamped). int mcpWorldToIndexX(double worldX); int mcpWorldToIndexY(double worldY); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp new file mode 100644 index 00000000000..0a6b392c69a --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp @@ -0,0 +1,437 @@ +/* +** 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 . +*/ + +// McpCmdImage.cpp +// MCP bridge commands that exchange terrain data with image files: heightmaps and cell masks. +// +// Image conventions: row 0 is the north edge (+y), so images look like the top-down view. +// Heightmap images have one pixel per heightmap vertex; mask images have one pixel per cell. + +#include "StdAfx.h" +#include "mcp/McpCommands.h" + +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" +#include "GameLogic/PolygonTrigger.h" + +#include +#include + +// Only the stb writer is compiled into the engine; the reader is only needed here. +#pragma warning(push, 0) +#define STB_IMAGE_IMPLEMENTATION +#define STBI_ONLY_PNG +#define STBI_ONLY_BMP +#define STBI_ONLY_TGA +#include +#pragma warning(pop) +#include + +namespace +{ + +/// A rectangle of heightmap vertices that an image is mapped onto. +struct VertexRegion +{ + Int x0, y0, w, h; +}; + +VertexRegion getRegion(WorldHeightMapEdit *map, bool includeBorder) +{ + VertexRegion r; + const Int border = includeBorder ? 0 : map->getBorderSize(); + r.x0 = border; + r.y0 = border; + r.w = map->getXExtent() - 2 * border; + r.h = map->getYExtent() - 2 * border; + if (r.w < 2 || r.h < 2) { + mcpFail("map area is too small"); + } + return r; +} + +/// An RGBA image with 16 bits per channel; 8-bit files are expanded on load. +class Image +{ +public: + Image() : m_pixels(nullptr), m_width(0), m_height(0), m_channel(-1) {} + ~Image() { if (m_pixels) stbi_image_free(m_pixels); } + + void load(const std::string &path, const std::string &channel) + { + if (GetFileAttributes(path.c_str()) == INVALID_FILE_ATTRIBUTES) { + mcpFail("file not found: %s", path.c_str()); + } + int comp = 0; + m_pixels = stbi_load_16(path.c_str(), &m_width, &m_height, &comp, 4); + if (m_pixels == nullptr) { + mcpFail("could not read image %s (%s); PNG, BMP and TGA are supported", path.c_str(), stbi_failure_reason()); + } + if (channel == "luma") m_channel = -1; + else if (channel == "r") m_channel = 0; + else if (channel == "g") m_channel = 1; + else if (channel == "b") m_channel = 2; + else if (channel == "a") m_channel = 3; + else mcpFail("channel must be luma, r, g, b or a"); + } + + int width() const { return m_width; } + int height() const { return m_height; } + + /// Pixel value in 0..1 for the selected channel. + double at(int x, int y) const + { + if (x < 0) x = 0; + if (y < 0) y = 0; + if (x >= m_width) x = m_width - 1; + if (y >= m_height) y = m_height - 1; + const unsigned short *p = m_pixels + 4 * (y * m_width + x); + if (m_channel >= 0) { + return p[m_channel] / 65535.0; + } + return (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) / 65535.0; + } + + double bilinear(double u, double v) const + { + const int x = (int)floor(u); + const int y = (int)floor(v); + const double fx = u - x; + const double fy = v - y; + const double top = at(x, y) * (1 - fx) + at(x + 1, y) * fx; + const double bottom = at(x, y + 1) * (1 - fx) + at(x + 1, y + 1) * fx; + return top * (1 - fy) + bottom * fy; + } + +private: + unsigned short *m_pixels; + int m_width; + int m_height; + int m_channel; +}; + +enum FitMode +{ + FIT_STRETCH, + FIT_EXACT, + FIT_CENTER +}; + +FitMode parseFit(const McpJson &args) +{ + const std::string fit = mcpArgString(args, "fit", "stretch"); + if (fit == "stretch") return FIT_STRETCH; + if (fit == "exact") return FIT_EXACT; + if (fit == "center") return FIT_CENTER; + mcpFail("fit must be stretch, exact or center"); + return FIT_STRETCH; +} + +/// Maps a grid position (0..count-1 along one axis, image row order already applied) to an image +/// coordinate. Returns false when the position falls outside the image (center fit only). +bool mapAxis(FitMode fit, double pos, int count, int imageSize, bool samplesAreCells, double *out) +{ + switch (fit) { + case FIT_STRETCH: + if (samplesAreCells) { + *out = (pos + 0.5) * imageSize / count - 0.5; + } else { + *out = count > 1 ? pos * (imageSize - 1) / (double)(count - 1) : 0; + } + return true; + case FIT_EXACT: + *out = pos; + return true; + case FIT_CENTER: + default: + *out = pos - (count - imageSize) / 2; + return *out > -0.5 && *out < imageSize - 0.5; + } +} + +void requireExactSize(FitMode fit, const Image &img, int w, int h, const char *what) +{ + if (fit == FIT_EXACT && (img.width() != w || img.height() != h)) { + mcpFail("fit=exact needs a %dx%d image (one pixel per %s), got %dx%d", w, h, what, img.width(), img.height()); + } +} + +std::string defaultImagePath(const char *kind) +{ + static Int s_counter = 0; + char tempDir[MAX_PATH]; + ::GetTempPath(MAX_PATH, tempDir); + char name[MAX_PATH]; + sprintf(name, "%swb_mcp_%s_%u_%d.png", tempDir, kind, (unsigned)::GetCurrentProcessId(), ++s_counter); + return name; +} + +void writeGrayPng(const std::string &path, int w, int h, const std::vector &pixels) +{ + if (!stbi_write_png(path.c_str(), w, h, 1, &pixels[0], w)) { + mcpFail("could not write %s", path.c_str()); + } +} + +//------------------------------------------------------------------------------------------------- +// Heightmaps +//------------------------------------------------------------------------------------------------- + +McpJson cmdExportHeightmap(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const bool includeBorder = mcpArgBool(args, "include_border", true); + const bool normalize = mcpArgBool(args, "normalize", false); + const VertexRegion r = getRegion(map, includeBorder); + std::string path = mcpArgString(args, "path", ""); + if (path.empty()) { + path = defaultImagePath("height"); + } + + Int minH = 255, maxH = 0; + for (Int y = 0; y < r.h; y++) { + for (Int x = 0; x < r.w; x++) { + const Int h = map->getHeight(r.x0 + x, r.y0 + y); + if (h < minH) minH = h; + if (h > maxH) maxH = h; + } + } + std::vector pixels(r.w * r.h); + for (Int y = 0; y < r.h; y++) { + for (Int x = 0; x < r.w; x++) { + Int h = map->getHeight(r.x0 + x, r.y0 + y); + if (normalize) { + h = maxH > minH ? (h - minH) * 255 / (maxH - minH) : 0; + } + // Row 0 is the north edge. + pixels[(r.h - 1 - y) * r.w + x] = (unsigned char)h; + } + } + writeGrayPng(path, r.w, r.h, pixels); + + McpJson j = McpJson::makeObject(); + j.set("path", path).set("width", r.w).set("height", r.h); + j.set("height_raw_min", minH).set("height_raw_max", maxH).set("normalized", normalize); + j.set("include_border", includeBorder); + return j; +} + +McpJson cmdImportHeightmap(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const std::string mode = mcpArgString(args, "mode", "set"); + if (mode != "set" && mode != "add") { + mcpFail("mode must be set or add"); + } + double lo = mode == "set" ? 0 : -64; + double hi = mode == "set" ? 255 : 64; + if (args.has("range")) { + const McpJson &range = mcpArgArray(args, "range"); + if (range.size() != 2 || !range.at(0).isNumber() || !range.at(1).isNumber()) { + mcpFail("range must be [low, high] in raw height units"); + } + lo = range.at(0).asNumber(); + hi = range.at(1).asNumber(); + } + const FitMode fit = parseFit(args); + const Int smooth = mcpArgInt(args, "smooth", 0); + if (smooth < 0 || smooth > 20) { + mcpFail("smooth must be 0..20"); + } + const VertexRegion r = getRegion(map, mcpArgBool(args, "include_border", true)); + + Image img; + img.load(mcpArgString(args, "path"), mcpArgString(args, "channel", "luma")); + requireExactSize(fit, img, r.w, r.h, "heightmap vertex"); + + // Compute the new heights in a float buffer so smoothing sees unclamped values. + std::vector heights(r.w * r.h); + std::vector touched(r.w * r.h, 0); + Int numTouched = 0; + for (Int y = 0; y < r.h; y++) { + for (Int x = 0; x < r.w; x++) { + const Int ndx = y * r.w + x; + heights[ndx] = map->getHeight(r.x0 + x, r.y0 + y); + double u, v; + if (!mapAxis(fit, x, r.w, img.width(), false, &u) || !mapAxis(fit, r.h - 1 - y, r.h, img.height(), false, &v)) { + continue; + } + const double value = lo + img.bilinear(u, v) * (hi - lo); + heights[ndx] = mode == "set" ? value : heights[ndx] + value; + touched[ndx] = 1; + numTouched++; + } + } + if (numTouched == 0) { + mcpFail("the image does not overlap the map"); + } + for (Int pass = 0; pass < smooth; pass++) { + std::vector prev = heights; + for (Int y = 0; y < r.h; y++) { + for (Int x = 0; x < r.w; x++) { + if (!touched[y * r.w + x]) continue; + double sum = 0; + Int count = 0; + for (Int dy = -1; dy <= 1; dy++) { + for (Int dx = -1; dx <= 1; dx++) { + const Int sx = x + dx, sy = y + dy; + if (sx < 0 || sy < 0 || sx >= r.w || sy >= r.h) continue; + sum += prev[sy * r.w + sx]; + count++; + } + } + heights[y * r.w + x] = sum / count; + } + } + } + + WorldHeightMapEdit *copy = map->duplicate(); + for (Int y = 0; y < r.h; y++) { + for (Int x = 0; x < r.w; x++) { + if (!touched[y * r.w + x]) continue; + double h = floor(heights[y * r.w + x] + 0.5); + if (h < 0) h = 0; + if (h > 255) h = 255; + copy->setHeight(r.x0 + x, r.y0 + y, (UnsignedByte)h); + } + } + McpJson j = mcpCommitHeightMapEdit(copy, false); + j.set("image_width", img.width()).set("image_height", img.height()).set("vertices_changed", numTouched); + return j; +} + +//------------------------------------------------------------------------------------------------- +// Cell masks +//------------------------------------------------------------------------------------------------- + +/// True if the cell is covered by a water polygon whose surface is above the terrain. +bool isCellUnderWater(WorldHeightMapEdit *map, Int cx, Int cy) +{ + const Int border = map->getBorderSize(); + ICoord3D pt; + pt.x = (Int)((cx - border + 0.5) * MAP_XY_FACTOR); + pt.y = (Int)((cy - border + 0.5) * MAP_XY_FACTOR); + pt.z = 0; + const double ground = (map->getHeight(cx, cy) + map->getHeight(cx + 1, cy) + map->getHeight(cx, cy + 1) + map->getHeight(cx + 1, cy + 1)) / 4.0 * MAP_HEIGHT_SCALE; + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + if (!trig->isWaterArea() || trig->getNumPoints() < 3) continue; + if (trig->getPoint(0)->z > ground && trig->pointInTrigger(pt)) { + return true; + } + } + return false; +} + +McpJson cmdExportMask(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const std::string kind = mcpArgString(args, "kind"); + Int texClass = -1; + if (kind == "texture") { + texClass = mcpFindTextureClass(args, "texture"); + } else if (kind != "passability" && kind != "water") { + mcpFail("kind must be passability, texture or water"); + } + const VertexRegion r = getRegion(map, mcpArgBool(args, "include_border", true)); + const Int cw = r.w - 1, ch = r.h - 1; + std::string path = mcpArgString(args, "path", ""); + if (path.empty()) { + path = defaultImagePath("mask"); + } + + std::vector pixels(cw * ch); + Int numSet = 0; + for (Int y = 0; y < ch; y++) { + for (Int x = 0; x < cw; x++) { + const Int cx = r.x0 + x, cy = r.y0 + y; + bool set; + if (kind == "passability") set = map->getCliffState(cx, cy) != 0; + else if (kind == "texture") set = map->getTextureClass(cx, cy, true) == texClass; + else set = isCellUnderWater(map, cx, cy); + pixels[(ch - 1 - y) * cw + x] = set ? 255 : 0; + if (set) numSet++; + } + } + writeGrayPng(path, cw, ch, pixels); + return McpJson::makeObject().set("path", path).set("width", cw).set("height", ch).set("cells_set", numSet); +} + +McpJson cmdImportMask(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const std::string target = mcpArgString(args, "target"); + Int texClass = -1; + if (target == "texture") { + texClass = mcpFindTextureClass(args, "texture"); + if (!map->isTexClassUsed(texClass) && !map->canFitTexture(texClass)) { + mcpFail("the map has no room for another texture; paint over or replace an existing one first"); + } + } else if (target != "passability") { + mcpFail("target must be passability or texture"); + } + const Int threshold = mcpArgInt(args, "threshold", 128); + const bool invert = mcpArgBool(args, "invert", false); + const bool impassable = mcpArgBool(args, "impassable", true); + const FitMode fit = parseFit(args); + const VertexRegion r = getRegion(map, mcpArgBool(args, "include_border", true)); + const Int cw = r.w - 1, ch = r.h - 1; + + Image img; + img.load(mcpArgString(args, "path"), mcpArgString(args, "channel", "luma")); + requireExactSize(fit, img, cw, ch, "cell"); + + WorldHeightMapEdit *copy = map->duplicate(); + Int numSet = 0; + bool tilesAdded = false; + for (Int y = 0; y < ch; y++) { + for (Int x = 0; x < cw; x++) { + double u, v; + if (!mapAxis(fit, x, cw, img.width(), true, &u) || !mapAxis(fit, ch - 1 - y, ch, img.height(), true, &v)) { + continue; + } + // Masks are sampled nearest so edges stay crisp. + bool set = img.at((int)floor(u + 0.5), (int)floor(v + 0.5)) * 255.0 >= threshold; + if (invert) set = !set; + if (!set) continue; + const Int cx = r.x0 + x, cy = r.y0 + y; + if (target == "passability") { + copy->setCliff(cx, cy, impassable); + } else if (copy->setTileNdx(cx, cy, texClass, false)) { + tilesAdded = true; + } + numSet++; + } + } + if (numSet == 0) { + REF_PTR_RELEASE(copy); + return McpJson::makeObject().set("changed", false).set("reason", "no mask pixels passed the threshold"); + } + McpJson j = mcpCommitHeightMapEdit(copy, target == "texture" || tilesAdded); + j.set("cells_set", numSet).set("image_width", img.width()).set("image_height", img.height()); + return j; +} + +} // namespace + +void mcpRegisterImageCommands() +{ + mcpRegisterCommand("terrain.export_heightmap", cmdExportHeightmap, "{path?,normalize?,include_border?} Writes the heightmap as an 8-bit grayscale PNG (north up, one pixel per vertex)."); + mcpRegisterCommand("terrain.import_heightmap", cmdImportHeightmap, "{path,mode?:set|add,range?:[lo,hi],channel?,fit?:stretch|exact|center,include_border?,smooth?} Loads heights from an image."); + mcpRegisterCommand("terrain.export_mask", cmdExportMask, "{kind:passability|texture|water,texture?,path?,include_border?} Writes a black/white PNG, one pixel per cell."); + mcpRegisterCommand("terrain.import_mask", cmdImportMask, "{path,target:passability|texture,texture?,threshold?,invert?,impassable?,channel?,fit?,include_border?} Applies a mask image to cells."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp index 2701cb64385..fa97f4401ff 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp @@ -499,6 +499,20 @@ McpJson cmdSample(const McpJson &args) } // namespace +Int mcpFindTextureClass(const McpJson &args, const char *key) +{ + return findTextureClass(args, key); +} + +McpJson mcpCommitHeightMapEdit(WorldHeightMapEdit *copy, bool texturesChanged) +{ + EditRange range; + range.add(0, 0); + range.add(copy->getXExtent() - 1, copy->getYExtent() - 1); + range.fullUpdate = texturesChanged; + return commitHeightMapEdit(copy, range); +} + void mcpRegisterTerrainCommands() { mcpRegisterCommand("terrain.get_heights", cmdGetHeights, "{x0?,y0?,w?,h?,step?} Raw vertex heights (0..255) as rows; indices include the border."); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index d45c82a4d47..53e406a5e53 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -79,6 +79,7 @@ void registerAllCommands() mcpRegisterTerrainCommands(); mcpRegisterObjectCommands(); mcpRegisterWaypointCommands(); + mcpRegisterImageCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index 61377493266..a54004b2fb0 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -62,3 +62,7 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - **Heights.** Raw heights are 0..255; world height is raw × 0.625. - **Undo.** WorldBuilder keeps 15 undo steps. Waypoint links are not undoable (same as in the UI). - **Screenshots.** They render the 3D view, so the WorldBuilder window must not be minimized. +- **Terrain images.** Heightmap and mask PNGs have north at the top. + - 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. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index 4ce7f5eed07..d4fa56c3e92 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os from typing import Any, Literal from mcp.server.mcpserver import Image, MCPServer @@ -225,6 +226,73 @@ def terrain_sample(x: float, y: float) -> dict: return wb.call("terrain.sample", x=x, y=y) +# -------------------------------------------------------------------------------------------------- +# Terrain images +# -------------------------------------------------------------------------------------------------- + +def _abspath(path: str | None) -> str | None: + # WorldBuilder runs with the mod folder as its working directory, so resolve relative paths here. + return os.path.abspath(path) if path else None + + +ImageFit = Literal["stretch", "exact", "center"] +Channel = Literal["luma", "r", "g", "b", "a"] + + +@mcp.tool() +def terrain_export_heightmap(path: str | None = None, normalize: bool = False, include_border: bool = True, + view: bool = False) -> Any: + """Writes the heightmap as an 8-bit grayscale PNG: one pixel per heightmap vertex, pixel value = + raw height 0..255, top row = north edge. normalize stretches the used height range to 0..255 for + easier viewing (not for round trips). include_border=false exports only the playable area. + view=true also returns the image so you can look at the relief. Default path is a temp file.""" + result = wb.call("terrain.export_heightmap", path=_abspath(path), normalize=normalize, + include_border=include_border) + if view: + return [result, Image(path=result["path"])] + return result + + +@mcp.tool() +def terrain_import_heightmap(path: str, mode: Literal["set", "add"] = "set", range: list[float] | None = None, + channel: Channel = "luma", fit: ImageFit = "stretch", include_border: bool = True, + smooth: int = 0) -> dict: + """Loads terrain heights from a PNG/BMP/TGA image (8 or 16 bit; top row = north). Pixel 0..max is + mapped onto range=[low, high] raw heights (default [0, 255] for set, [-64, 64] for add). mode=add + adds the mapped value to the current heights. fit: stretch = resample to the map (bilinear), + exact = image must match the vertex grid size, center = place 1:1 in the middle. + include_border=false targets only the playable area. smooth = smoothing passes afterwards. + Tip: generate heightmaps locally (e.g. Python + numpy/PIL) and import them here. One undo step.""" + return wb.call("terrain.import_heightmap", path=_abspath(path), mode=mode, range=range, channel=channel, + fit=fit, include_border=include_border, smooth=smooth) + + +@mcp.tool() +def terrain_export_mask(kind: Literal["passability", "texture", "water"], texture: str | None = None, + path: str | None = None, include_border: bool = True, view: bool = False) -> Any: + """Writes a black/white PNG with one pixel per terrain cell (top row = north): impassable cells, + cells whose base texture is `texture`, or cells under water. view=true also returns the image.""" + result = wb.call("terrain.export_mask", kind=kind, texture=texture, path=_abspath(path), + include_border=include_border) + if view: + return [result, Image(path=result["path"])] + return result + + +@mcp.tool() +def terrain_import_mask(path: str, target: Literal["passability", "texture"], texture: str | None = None, + threshold: int = 128, invert: bool = False, impassable: bool = True, + channel: Channel = "luma", fit: ImageFit = "stretch", include_border: bool = True) -> dict: + """Applies a mask image to terrain cells: every cell whose pixel is >= threshold (0..255, or below it + with invert) is painted with `texture` (target=texture) or set impassable/passable (target=passability, + impassable flag). Cells outside the mask are left unchanged. Handy for stamping shapes such as text, + rivers or plateaus that you draw as an image locally. fit=exact expects one pixel per cell + (map_info heightmap extent minus one). One undo step.""" + return wb.call("terrain.import_mask", path=_abspath(path), target=target, texture=texture, + threshold=threshold, invert=invert, impassable=impassable, channel=channel, fit=fit, + include_border=include_border) + + # -------------------------------------------------------------------------------------------------- # Objects and sides # -------------------------------------------------------------------------------------------------- diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index fe10bb4d023..73776af2ace 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -61,6 +61,17 @@ def main() -> int: if roads: wb.call("roads.add", type=roads[0], points=[[50, 500], [300, 500], [400, 700]]) + # Heightmap image round trip must be lossless. + out_png = os.path.join(tempfile.mkdtemp(prefix="wb_mcp_"), "height.png") + wb.call("terrain.export_heightmap", path=out_png) + wb.call("terrain.import_heightmap", path=out_png, fit="exact") + again_png = out_png.replace("height.png", "height2.png") + wb.call("terrain.export_heightmap", path=again_png) + assert open(out_png, "rb").read() == open(again_png, "rb").read(), "heightmap round trip changed heights" + wb.call("edit.undo") + water = wb.call("terrain.export_mask", kind="water") + assert water["cells_set"] > 0, water + shot = wb.call("view.screenshot", x=400, y=400) print("screenshot:", shot["path"], shot["width"], "x", shot["height"]) From 1fb125de8769a84c3ed601fd13176a024beda828 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Wed, 30 Sep 2026 19:49:29 +0200 Subject: [PATCH 04/30] feat(worldbuilder): Adds MCP map mirroring, rotation and symmetrize map.transform mirrors or rotates the whole map (mirror_x/y, rotate_90/ 180/270, diagonal mirrors). map.symmetrize copies one half, triangle or quarter onto the rest for symmetric multiplayer maps (mirror_x/y, rotate_180, mirror_diag/antidiag, rotate_quarters). Both cover heights, passability, textures, objects, waypoints and areas in one undo step. New WorldHeightMapEdit::copyTransformedFrom re-derives tiles for their new position and re-orients blend layers, including the forced triangle flips of 3-way blends. Cliff UV mapping is reset on copied cells. Symmetrize keeps objects near the axis once, keeps road pairs intact, recreates waypoint links between copies (undoable), and renumbers players in names and owners per copy (Player_1_Start -> Player_2_Start, P1_, Perimeter1, teamplayer0001), with explicit rename pairs and an owner map as overrides. The feature set follows the Genesis map tools by The CWC Team. Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 1 + .../WorldBuilder/include/WHeightMapEdit.h | 17 + .../WorldBuilder/include/mcp/McpCommands.h | 1 + .../Tools/WorldBuilder/src/WHeightMapEdit.cpp | 125 +++ .../WorldBuilder/src/mcp/McpCmdSymmetry.cpp | 852 ++++++++++++++++++ .../WorldBuilder/src/mcp/McpCommands.cpp | 1 + scripts/worldbuilder-mcp/README.md | 5 + scripts/worldbuilder-mcp/server.py | 44 + scripts/worldbuilder-mcp/smoke_test.py | 9 + 9 files changed, 1055 insertions(+) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index 8e6eda61747..1eb30af77c1 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -202,6 +202,7 @@ set(WORLDBUILDER_SRC "src/mcp/McpCmdImage.cpp" "src/mcp/McpCmdMap.cpp" "src/mcp/McpCmdObjects.cpp" + "src/mcp/McpCmdSymmetry.cpp" "src/mcp/McpCmdTerrain.cpp" "src/mcp/McpCmdWaypoints.cpp" "src/mcp/McpCommands.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h index 31d1a2c72f7..61049749498 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h @@ -57,6 +57,20 @@ typedef struct } TGlobalTextureClass; +/// Describes a mirror or rotation for WorldHeightMapEdit::copyTransformedFrom by mapping destination +/// vertices and cells back to their source. +class HeightMapTransform +{ +public: + virtual ~HeightMapTransform() {} + /// Returns false to leave the destination vertex unchanged. + virtual Bool sourceVertex(Int x, Int y, Int *srcX, Int *srcY) const = 0; + /// Same for cells; a cell is addressed by its lower left vertex. + virtual Bool sourceCell(Int x, Int y, Int *srcX, Int *srcY) const = 0; + /// Maps a direction (components -1, 0 or 1) from source to destination orientation for a destination cell. + virtual void mapDirection(Int x, Int y, Int *dx, Int *dy) const = 0; +}; + class WorldHeightMapEdit : public WorldHeightMap { protected: @@ -120,6 +134,9 @@ class WorldHeightMapEdit : public WorldHeightMap void setCliff(Int xIndex, Int yIndex, Bool impassable) {setCliffState(xIndex, yIndex, impassable);} Bool setTileNdx(Int xIndex, Int yIndex, Int textureClass, Bool singleTile); Bool floodFill(Int xIndex, Int yIndex, Int textureClass, Bool doReplace, Bool askToConfirm = true); + /// Copies heights, textures (including blends) and passability from a same-sized map through a + /// mirror or rotation. Cliff texture mapping is reset on the copied cells. + void copyTransformedFrom(WorldHeightMapEdit *src, const HeightMapTransform &xf, Bool heights, Bool textures, Bool passability); static Int getNumTexClasses() {return m_numGlobalTextureClasses;}; static AsciiString getTexClassName(int ndx) {return m_globalTextureClasses[ndx].name;} static AsciiString getTexClassUiName(int ndx) ; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index 40fd41a3104..653a84489a1 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -49,6 +49,7 @@ void mcpRegisterTerrainCommands(); void mcpRegisterObjectCommands(); void mcpRegisterWaypointCommands(); void mcpRegisterImageCommands(); +void mcpRegisterSymmetryCommands(); //------------------------------------------------------------------------------------------------- // Helpers diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp index 28150d7a0e7..b2ca83c2b58 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp @@ -1656,6 +1656,131 @@ void WorldHeightMapEdit::setHeight(Int xIndex, Int yIndex, UnsignedByte height) This optimizes the tiles and blend tiles, recalculating them and removing any unused ones. */ +// Direction of the opaque side of a blend, following blendSpecificTiles: horizontal blends are +// opaque on the left when inverted, vertical ones at the bottom, and diagonals point at their +// corner. Long diagonals store the complementary corner, which transforms the same way. +static void getBlendDirection(const TBlendTileInfo &info, Int *dx, Int *dy) +{ + const Bool inverted = (info.inverted & INVERTED_MASK) != 0; + if (info.horiz) { + *dx = inverted ? -1 : 1; + *dy = 0; + } else if (info.vert) { + *dx = 0; + *dy = inverted ? -1 : 1; + } else { + *dx = info.rightDiagonal ? 1 : -1; + *dy = inverted ? -1 : 1; + } +} + +static void setBlendDirection(TBlendTileInfo *info, Int dx, Int dy) +{ + Bool inverted; + info->horiz = info->vert = info->rightDiagonal = info->leftDiagonal = false; + if (dy == 0) { + info->horiz = true; + inverted = dx < 0; + } else if (dx == 0) { + info->vert = true; + inverted = dy < 0; + } else { + info->rightDiagonal = dx > 0; + info->leftDiagonal = dx < 0; + inverted = dy < 0; + } + info->inverted = (info->inverted & FLIPPED_MASK) | (inverted ? INVERTED_MASK : 0); +} + +static Bool isDiagonalNeedingFlip(const TBlendTileInfo &info) +{ + const Bool inverted = (info.inverted & INVERTED_MASK) != 0; + return (info.rightDiagonal && !inverted) || (info.leftDiagonal && inverted); +} + +void WorldHeightMapEdit::copyTransformedFrom(WorldHeightMapEdit *src, const HeightMapTransform &xf, Bool heights, Bool textures, Bool passability) +{ + DEBUG_ASSERTCRASH(src->m_width == m_width && src->m_height == m_height, ("copyTransformedFrom needs maps of the same size")); + if (src->m_width != m_width || src->m_height != m_height) { + return; + } + Int x, y, sx, sy; + if (heights) { + for (y = 0; y < m_height; y++) { + for (x = 0; x < m_width; x++) { + if (xf.sourceVertex(x, y, &sx, &sy)) { + // Raw copy: passability is copied separately below. + m_data[y*m_width + x] = src->m_data[sy*m_width + sx]; + } + } + } + } + if (!textures && !passability) { + return; + } + for (y = 0; y < m_height-1; y++) { + for (x = 0; x < m_width-1; x++) { + if (!xf.sourceCell(x, y, &sx, &sy)) { + continue; + } + if (passability) { + setCliffState(x, y, src->getCliffState(sx, sy)); + } + if (!textures) { + continue; + } + const Int ndx = y*m_width + x; + const Int srcNdx = sy*m_width + sx; + const Int baseClass = src->getTextureClassFromNdx(src->m_tileNdxes[srcNdx]); + if (baseClass < 0) { + continue; + } + // Tiles depend on the cell position inside the texture, so recompute them instead of copying. + m_tileNdxes[ndx] = getTileNdxForClass(x, y, baseClass); + m_cliffInfoNdxes[ndx] = 0; + m_blendTileNdxes[ndx] = 0; + m_extraBlendTileNdxes[ndx] = 0; + + TBlendTileInfo blends[2]; + Bool hasBlend[2] = { src->m_blendTileNdxes[srcNdx] != 0, src->m_extraBlendTileNdxes[srcNdx] != 0 }; + if (!hasBlend[0]) { + continue; + } + for (Int layer = 0; layer < 2; layer++) { + if (!hasBlend[layer]) continue; + blends[layer] = src->m_blendedTiles[layer == 0 ? src->m_blendTileNdxes[srcNdx] : src->m_extraBlendTileNdxes[srcNdx]]; + const Int blendClass = src->getTextureClassFromNdx(blends[layer].blendNdx); + if (blendClass < 0) { + hasBlend[layer] = false; + continue; + } + blends[layer].blendNdx = getTileNdxForClass(x, y, blendClass); + Int dx, dy; + getBlendDirection(blends[layer], &dx, &dy); + xf.mapDirection(x, y, &dx, &dy); + setBlendDirection(&blends[layer], dx, dy); + blends[layer].inverted &= ~FLIPPED_MASK; + } + // A mirror changes which triangle split the diagonal layers need, so rederive the forced + // flips that keep a horizontal/vertical layer consistent with a diagonal one (see blendSpecificTiles). + for (Int layer = 0; layer < 2; layer++) { + const Int other = 1 - layer; + if (hasBlend[layer] && hasBlend[other] && (blends[layer].horiz || blends[layer].vert) && isDiagonalNeedingFlip(blends[other])) { + blends[layer].inverted |= FLIPPED_MASK; + } + } + if (hasBlend[0]) { + const Int blendNdx = findOrCreateBlendTile(&blends[0]); + m_blendTileNdxes[ndx] = blendNdx > 0 ? blendNdx : 0; + } + if (hasBlend[1] && m_blendTileNdxes[ndx] != 0) { + const Int blendNdx = findOrCreateBlendTile(&blends[1]); + m_extraBlendTileNdxes[ndx] = blendNdx > 0 ? blendNdx : 0; + } + } + } +} + Bool WorldHeightMapEdit::optimizeTiles() { // Run through all the tile indexes changing to tile classes. diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp new file mode 100644 index 00000000000..02a8a2db4ac --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp @@ -0,0 +1,852 @@ +/* +** 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 . +*/ + +// McpCmdSymmetry.cpp +// MCP bridge commands that mirror or rotate the map, or copy one part of it onto the others to make +// symmetric multiplayer maps. The feature set follows the map tools of Genesis (The CWC Team). +// +// All geometry works in a frame centered on the heightmap (including the border), measured in +// cells: vertex (x, y) is at (x - cx, y - cy), a cell at its center, and objects at their world +// position converted to cells. + +#include "StdAfx.h" +#include "mcp/McpCommands.h" + +#include "CUndoable.h" +#include "PointerTool.h" +#include "TerrainMaterial.h" +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/PolygonTrigger.h" +#include "GameLogic/SidesList.h" + +#include +#include +#include +#include +#include + +namespace +{ + +/// Integer 2x2 matrix [a b; c d] for mirrors and quarter rotations. All of them are orthogonal. +struct Mat2 +{ + Int a, b, c, d; + + void apply(double x, double y, double *ox, double *oy) const + { + *ox = a * x + b * y; + *oy = c * x + d * y; + } + void applyDir(Int *dx, Int *dy) const + { + const Int x = *dx, y = *dy; + *dx = a * x + b * y; + *dy = c * x + d * y; + } + Mat2 inverse() const + { + Mat2 m = { a, c, b, d }; + return m; + } + Mat2 operator*(const Mat2 &o) const + { + Mat2 m = { a * o.a + b * o.c, a * o.b + b * o.d, c * o.a + d * o.c, c * o.b + d * o.d }; + return m; + } + bool swapsAxes() const { return a == 0; } +}; + +const Mat2 MAT_IDENTITY = { 1, 0, 0, 1 }; +const Mat2 MAT_MIRROR_X = { -1, 0, 0, 1 }; +const Mat2 MAT_MIRROR_Y = { 1, 0, 0, -1 }; +const Mat2 MAT_ROTATE_90 = { 0, -1, 1, 0 }; // counterclockwise +const Mat2 MAT_ROTATE_180 = { -1, 0, 0, -1 }; +const Mat2 MAT_ROTATE_270 = { 0, 1, -1, 0 }; +const Mat2 MAT_MIRROR_DIAG = { 0, 1, 1, 0 }; // across the line y = x +const Mat2 MAT_MIRROR_ANTIDIAG = { 0, -1, -1, 0 }; // across the line y = -x + +/// Converts between heightmap indices, world coordinates and the centered frame. +struct Frame +{ + double cx, cy; + Int border; + Int width, height; ///< vertex counts + + void init(WorldHeightMapEdit *map) + { + width = map->getXExtent(); + height = map->getYExtent(); + border = map->getBorderSize(); + cx = (width - 1) / 2.0; + cy = (height - 1) / 2.0; + } + void worldToFrame(double wx, double wy, double *px, double *py) const + { + *px = wx / MAP_XY_FACTOR + border - cx; + *py = wy / MAP_XY_FACTOR + border - cy; + } + void frameToWorld(double px, double py, double *wx, double *wy) const + { + *wx = (px + cx - border) * MAP_XY_FACTOR; + *wy = (py + cy - border) * MAP_XY_FACTOR; + } +}; + +Int roundToInt(double v) +{ + return (Int)floor(v + 0.5); +} + +/// Which part of the map is kept and how it is copied onto the rest. +struct Symmetry +{ + std::string mode; + std::string source; + std::vector copies; ///< copies[0] is the identity; copies[k] maps the source onto copy k. + + void parse(const std::string &modeArg, const std::string &sourceArg, bool square) + { + mode = modeArg; + source = sourceArg; + copies.clear(); + copies.push_back(MAT_IDENTITY); + if (mode == "mirror_x") { + if (source != "west" && source != "east") mcpFail("mirror_x needs source west or east"); + copies.push_back(MAT_MIRROR_X); + } else if (mode == "mirror_y") { + if (source != "south" && source != "north") mcpFail("mirror_y needs source south or north"); + copies.push_back(MAT_MIRROR_Y); + } else if (mode == "rotate_180") { + if (source != "south" && source != "north" && source != "west" && source != "east") { + mcpFail("rotate_180 needs source south, north, west or east"); + } + copies.push_back(MAT_ROTATE_180); + } else if (mode == "mirror_diag") { + if (source != "se" && source != "nw") mcpFail("mirror_diag (across y = x) needs source se or nw"); + copies.push_back(MAT_MIRROR_DIAG); + } else if (mode == "mirror_antidiag") { + if (source != "sw" && source != "ne") mcpFail("mirror_antidiag (across y = -x) needs source sw or ne"); + copies.push_back(MAT_MIRROR_ANTIDIAG); + } else if (mode == "rotate_quarters") { + if (source != "sw" && source != "se" && source != "ne" && source != "nw") { + mcpFail("rotate_quarters needs source sw, se, ne or nw"); + } + copies.push_back(MAT_ROTATE_90); + copies.push_back(MAT_ROTATE_180); + copies.push_back(MAT_ROTATE_270); + } else { + mcpFail("mode must be mirror_x, mirror_y, rotate_180, mirror_diag, mirror_antidiag or rotate_quarters"); + } + for (size_t k = 1; k < copies.size(); k++) { + if (copies[k].swapsAxes() && !square) { + mcpFail("%s needs a square map", mode.c_str()); + } + } + } + + /// Source region with tie rules chosen so the source and its copies never overlap. + bool inSource(double px, double py) const + { + if (mode == "mirror_x") return source == "west" ? px < 0 : px > 0; + if (mode == "mirror_y") return source == "south" ? py < 0 : py > 0; + if (mode == "rotate_180") { + if (source == "south") return py < 0 || (py == 0 && px < 0); + if (source == "north") return py > 0 || (py == 0 && px > 0); + if (source == "west") return px < 0 || (px == 0 && py < 0); + return px > 0 || (px == 0 && py > 0); + } + if (mode == "mirror_diag") return source == "se" ? py < px : py > px; + if (mode == "mirror_antidiag") return source == "sw" ? px + py < 0 : px + py > 0; + // rotate_quarters: half-open quadrants that tile the plane under 90 degree rotations. + if (source == "sw") return px < 0 && py <= 0; + if (source == "se") return px >= 0 && py < 0; + if (source == "ne") return px > 0 && py >= 0; + return px <= 0 && py > 0; + } + + /// For a point outside the source, finds the copy that covers it and the source point it comes from. + bool findSource(double px, double py, double *sx, double *sy, Int *copy) const + { + if (inSource(px, py)) { + return false; + } + for (size_t k = 1; k < copies.size(); k++) { + copies[k].inverse().apply(px, py, sx, sy); + if (inSource(*sx, *sy)) { + *copy = (Int)k; + return true; + } + } + return false; + } + + /// True for points on (or within tolerance of) an axis or center that maps onto itself. + bool isOnAxis(double px, double py, double tolerance) const + { + for (size_t k = 1; k < copies.size(); k++) { + double ix, iy; + copies[k].apply(px, py, &ix, &iy); + if (sqrt((ix - px) * (ix - px) + (iy - py) * (iy - py)) < 2 * tolerance) { + return true; + } + } + return false; + } +}; + +/// Heightmap transform for map.symmetrize: fills everything outside the source from the source. +class SymmetryTransform : public HeightMapTransform +{ +public: + SymmetryTransform(const Frame &frame, const Symmetry &sym) : m_frame(frame), m_sym(sym) {} + + virtual Bool sourceVertex(Int x, Int y, Int *srcX, Int *srcY) const override + { + return map(x - m_frame.cx, y - m_frame.cy, 0, srcX, srcY, nullptr); + } + virtual Bool sourceCell(Int x, Int y, Int *srcX, Int *srcY) const override + { + return map(x + 0.5 - m_frame.cx, y + 0.5 - m_frame.cy, 0.5, srcX, srcY, nullptr); + } + virtual void mapDirection(Int x, Int y, Int *dx, Int *dy) const override + { + Int sx, sy, copy = 0; + if (map(x + 0.5 - m_frame.cx, y + 0.5 - m_frame.cy, 0.5, &sx, &sy, ©)) { + m_sym.copies[copy].applyDir(dx, dy); + } + } + +private: + bool map(double px, double py, double offset, Int *srcX, Int *srcY, Int *copyOut) const + { + double sx, sy; + Int copy; + if (!m_sym.findSource(px, py, &sx, &sy, ©)) { + return false; + } + *srcX = roundToInt(sx + m_frame.cx - offset); + *srcY = roundToInt(sy + m_frame.cy - offset); + if (copyOut) *copyOut = copy; + const Int maxX = m_frame.width - (offset > 0 ? 2 : 1); + const Int maxY = m_frame.height - (offset > 0 ? 2 : 1); + return *srcX >= 0 && *srcY >= 0 && *srcX <= maxX && *srcY <= maxY; + } + + const Frame &m_frame; + const Symmetry &m_sym; +}; + +/// Heightmap transform for map.transform: every destination comes from its preimage. +class WholeMapTransform : public HeightMapTransform +{ +public: + WholeMapTransform(const Frame &frame, const Mat2 &m) : m_frame(frame), m_m(m), m_inv(m.inverse()) {} + + virtual Bool sourceVertex(Int x, Int y, Int *srcX, Int *srcY) const override + { + double sx, sy; + m_inv.apply(x - m_frame.cx, y - m_frame.cy, &sx, &sy); + *srcX = roundToInt(sx + m_frame.cx); + *srcY = roundToInt(sy + m_frame.cy); + return *srcX >= 0 && *srcY >= 0 && *srcX < m_frame.width && *srcY < m_frame.height; + } + virtual Bool sourceCell(Int x, Int y, Int *srcX, Int *srcY) const override + { + double sx, sy; + m_inv.apply(x + 0.5 - m_frame.cx, y + 0.5 - m_frame.cy, &sx, &sy); + *srcX = roundToInt(sx + m_frame.cx - 0.5); + *srcY = roundToInt(sy + m_frame.cy - 0.5); + return *srcX >= 0 && *srcY >= 0 && *srcX < m_frame.width - 1 && *srcY < m_frame.height - 1; + } + virtual void mapDirection(Int, Int, Int *dx, Int *dy) const override + { + m_m.applyDir(dx, dy); + } + +private: + const Frame &m_frame; + Mat2 m_m; + Mat2 m_inv; +}; + +void transformObject(MapObject *obj, const Frame &frame, const Mat2 &m, Coord3D *outLoc, Real *outAngle) +{ + Coord3D loc = *obj->getLocation(); + double px, py, qx, qy, wx, wy; + frame.worldToFrame(loc.x, loc.y, &px, &py); + m.apply(px, py, &qx, &qy); + frame.frameToWorld(qx, qy, &wx, &wy); + loc.x = (Real)wx; + loc.y = (Real)wy; + double dx, dy; + m.apply(cos(obj->getAngle()), sin(obj->getAngle()), &dx, &dy); + *outLoc = loc; + *outAngle = (Real)atan2(dy, dx); +} + +/// Applies the transform to a polygon point (integer world coordinates, z unchanged). +ICoord3D transformPoint(const ICoord3D &pt, const Frame &frame, const Mat2 &m) +{ + double px, py, qx, qy, wx, wy; + frame.worldToFrame(pt.x, pt.y, &px, &py); + m.apply(px, py, &qx, &qy); + frame.frameToWorld(qx, qy, &wx, &wy); + ICoord3D out = pt; + out.x = roundToInt(wx); + out.y = roundToInt(wy); + return out; +} + +bool isDefaultWater(PolygonTrigger *trig) +{ + std::string name = trig->getTriggerName().str(); + std::string norm; + for (size_t i = 0; i < name.size(); i++) { + if (name[i] != ' ' && name[i] != '_') norm += (char)tolower((unsigned char)name[i]); + } + return norm == "defaultwater"; +} + +void polygonCentroid(PolygonTrigger *trig, const Frame &frame, double *px, double *py) +{ + double sx = 0, sy = 0; + const Int n = trig->getNumPoints(); + for (Int i = 0; i < n; i++) { + sx += trig->getPoint(i)->x; + sy += trig->getPoint(i)->y; + } + frame.worldToFrame(n ? sx / n : 0, n ? sy / n : 0, px, py); +} + +//------------------------------------------------------------------------------------------------- +// Undoables +//------------------------------------------------------------------------------------------------- + +/// Moves and rotates objects in place (map.transform). +class MoveObjectsUndoable : public Undoable +{ +public: + MoveObjectsUndoable(CWorldBuilderDoc *doc) : m_doc(doc), m_list(nullptr), m_tail(nullptr) {} + virtual ~MoveObjectsUndoable() override { delete m_list; } + + void add(MapObject *obj, const Coord3D &loc, Real angle) + { + MoveInfo *info = new MoveInfo(obj); + info->m_newLocation = loc; + info->m_newAngle = angle; + if (m_tail) m_tail->m_next = info; else m_list = info; + m_tail = info; + } + virtual void Do() override { for (MoveInfo *i = m_list; i; i = i->m_next) i->DoMove(m_doc); } + virtual void Undo() override { for (MoveInfo *i = m_list; i; i = i->m_next) i->UndoMove(m_doc); } + +private: + CWorldBuilderDoc *m_doc; + MoveInfo *m_list; + MoveInfo *m_tail; +}; + +/// Replaces all points of existing polygons (map.transform). +class PolygonPointsUndoable : public Undoable +{ +public: + void add(PolygonTrigger *trig, const std::vector &newPoints) + { + Entry e; + e.trig = trig; + e.newPoints = newPoints; + for (Int i = 0; i < trig->getNumPoints(); i++) { + e.oldPoints.push_back(*trig->getPoint(i)); + } + m_entries.push_back(e); + } + virtual void Do() override { apply(true); } + virtual void Undo() override { apply(false); } + +private: + struct Entry + { + PolygonTrigger *trig; + std::vector oldPoints, newPoints; + }; + void apply(bool forward) + { + for (size_t i = 0; i < m_entries.size(); i++) { + const std::vector &pts = forward ? m_entries[i].newPoints : m_entries[i].oldPoints; + for (size_t p = 0; p < pts.size(); p++) { + m_entries[i].trig->setPoint(pts[p], (Int)p); + } + } + } + std::vector m_entries; +}; + +/// Adds and removes waypoint links, which WorldBuilder itself does not record for undo. +class WaypointLinksUndoable : public Undoable +{ +public: + typedef std::pair Link; + WaypointLinksUndoable(CWorldBuilderDoc *doc) : m_doc(doc) {} + + std::vector added, removed; + + virtual void Do() override + { + for (size_t i = 0; i < removed.size(); i++) m_doc->removeWaypointLink(removed[i].first, removed[i].second); + for (size_t i = 0; i < added.size(); i++) m_doc->addWaypointLink(added[i].first, added[i].second); + } + virtual void Undo() override + { + for (size_t i = 0; i < added.size(); i++) m_doc->removeWaypointLink(added[i].first, added[i].second); + for (size_t i = 0; i < removed.size(); i++) m_doc->addWaypointLink(removed[i].first, removed[i].second); + } + +private: + CWorldBuilderDoc *m_doc; +}; + +//------------------------------------------------------------------------------------------------- +// Renaming +//------------------------------------------------------------------------------------------------- + +/// Renames copies: "auto" renumbers player numbers in known patterns, pairs replace substrings. +class Renamer +{ +public: + void parse(const McpJson &args, Int numCopies) + { + m_numCopies = numCopies; + m_auto = true; + const McpJson &rename = args.get("rename"); + if (rename.isString()) { + if (rename.asString() == "none") m_auto = false; + else if (rename.asString() != "auto") mcpFail("rename must be \"auto\", \"none\" or a list of [from, to] pairs"); + } else if (rename.isArray()) { + m_auto = false; + for (size_t i = 0; i < rename.size(); i++) { + const McpJson &p = rename.at(i); + if (!p.isArray() || p.size() != 2 || !p.at(0).isString() || !p.at(1).isString()) { + mcpFail("rename pairs must be [from, to] strings"); + } + m_pairs.push_back(std::make_pair(p.at(0).asString(), p.at(1).asString())); + } + } else if (!rename.isNull()) { + mcpFail("rename must be \"auto\", \"none\" or a list of [from, to] pairs"); + } + const McpJson &ownerMap = args.get("owner_map"); + if (ownerMap.isObject()) { + for (size_t i = 0; i < ownerMap.numKeys(); i++) { + if (!ownerMap.valueAt(i).isString()) mcpFail("owner_map values must be team names"); + m_ownerMap[ownerMap.keyAt(i)] = ownerMap.valueAt(i).asString(); + } + } + } + + std::string rename(const std::string &name, Int copy) const + { + if (name.empty()) return name; + if (!m_auto) { + std::string out = name; + for (size_t i = 0; i < m_pairs.size(); i++) { + size_t pos = 0; + while (!m_pairs[i].first.empty() && (pos = out.find(m_pairs[i].first, pos)) != std::string::npos) { + out.replace(pos, m_pairs[i].first.size(), m_pairs[i].second); + pos += m_pairs[i].second.size(); + } + } + return out; + } + static const std::regex patterns[] = { + std::regex("(Player_)(\\d+)", std::regex::icase), + std::regex("^(P)(\\d+)(?=_)", std::regex::icase), + std::regex("(Perimeter)(\\d+)", std::regex::icase), + std::regex("(player)(\\d+)", std::regex::icase), + }; + std::string out = name; + for (size_t p = 0; p < sizeof(patterns) / sizeof(patterns[0]); p++) { + std::string result; + std::string::const_iterator last = out.begin(); + for (std::sregex_iterator it(out.begin(), out.end(), patterns[p]), end; it != end; ++it) { + const std::smatch &m = *it; + result.append(last, m[0].first); + result += m[1].str(); + result += renumber(m[2].str(), copy); + last = m[0].second; + } + result.append(last, out.cend()); + out = result; + } + return out; + } + + /// Owner team for a copy; falls back to the original when the renamed team does not exist. + std::string owner(const std::string &original, Int copy, std::set *unresolved) const + { + std::map::const_iterator it = m_ownerMap.find(original); + std::string mapped = it != m_ownerMap.end() ? it->second : rename(original, copy); + if (mapped == original) return original; + Int ndx; + if (TheSidesList->findTeamInfo(AsciiString(mapped.c_str()), &ndx)) return mapped; + unresolved->insert(mapped); + return original; + } + +private: + std::string renumber(const std::string &digits, Int copy) const + { + const Int n = atoi(digits.c_str()); + if (n < 1 || n > m_numCopies) return digits; + const Int mapped = ((n - 1 + copy) % m_numCopies) + 1; + char buf[32]; + sprintf(buf, "%0*d", (int)digits.size(), mapped); + return buf; + } + + Int m_numCopies; + bool m_auto; + std::vector > m_pairs; + std::map m_ownerMap; +}; + +std::string makeUnique(const std::string &name, std::set &used) +{ + if (name.empty()) return name; + std::string candidate = name; + for (Int i = 2; used.count(candidate); i++) { + char buf[16]; + sprintf(buf, "_%d", i); + candidate = name + buf; + } + used.insert(candidate); + return candidate; +} + +struct Includes +{ + bool heights, textures, passability, objects, waypoints, areas; + + void parse(const McpJson &args) + { + const McpJson &inc = args.get("include"); + heights = textures = passability = objects = waypoints = areas = true; + if (inc.isNull()) return; + if (!inc.isObject()) mcpFail("include must be an object of booleans"); + heights = mcpArgBool(inc, "heights", true); + textures = mcpArgBool(inc, "textures", true); + passability = mcpArgBool(inc, "passability", true); + objects = mcpArgBool(inc, "objects", true); + waypoints = mcpArgBool(inc, "waypoints", true); + areas = mcpArgBool(inc, "areas", true); + } +}; + +/// Transforms the heightmap into a copy and adds it to the undo step. The copy is refreshed in the views. +void addHeightMapStep(CWorldBuilderDoc *doc, WorldHeightMapEdit *map, const HeightMapTransform &xf, + const Includes &inc, MultipleUndoable *undo, WorldHeightMapEdit **outCopy) +{ + *outCopy = nullptr; + if (!inc.heights && !inc.textures && !inc.passability) return; + WorldHeightMapEdit *copy = map->duplicate(); + copy->copyTransformedFrom(map, xf, inc.heights, inc.textures, inc.passability); + if (inc.textures) copy->optimizeTiles(); + IRegion2D all = { 0, 0, 0, 0 }; + doc->updateHeightMap(copy, false, all); + undo->addUndoable(new WBDocUndoable(doc, copy)); + *outCopy = copy; +} + +void finishHeightMapStep(CWorldBuilderDoc *doc, WorldHeightMapEdit *copy, const Includes &inc) +{ + if (copy == nullptr) return; + REF_PTR_RELEASE(copy); + if (inc.textures) TerrainMaterial::updateTextures(doc->GetHeightMap()); +} + +bool isRoadStart(MapObject *obj) +{ + return (obj->getFlag(FLAG_ROAD_POINT1) || obj->getFlag(FLAG_BRIDGE_POINT1)) && obj->getNext() != nullptr; +} + +//------------------------------------------------------------------------------------------------- +// map.symmetrize +//------------------------------------------------------------------------------------------------- + +McpJson cmdSymmetrize(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + WorldHeightMapEdit *map = mcpHeightMap(); + Frame frame; + frame.init(map); + Symmetry sym; + sym.parse(mcpArgString(args, "mode"), mcpArgString(args, "source"), frame.width == frame.height); + Includes inc; + inc.parse(args); + Renamer renamer; + renamer.parse(args, (Int)sym.copies.size()); + const double tolerance = mcpArgNumber(args, "axis_tolerance", 5.0) / MAP_XY_FACTOR; + const bool skipDefaultWater = mcpArgBool(args, "skip_default_water", true); + const Int numCopies = (Int)sym.copies.size(); + + // Classify objects: source objects are copied, target objects deleted, axis objects kept. + std::vector toDelete; + std::vector > sourceGroups; // single objects or road pairs + std::set usedWaypointNames, usedObjectNames; + std::set deletedWaypointIds, copiedWaypointIds; + Int keptOnAxis = 0; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + std::vector group(1, obj); + double wx = obj->getLocation()->x, wy = obj->getLocation()->y; + if (isRoadStart(obj)) { + MapObject *second = obj->getNext(); + group.push_back(second); + wx = (wx + second->getLocation()->x) / 2; + wy = (wy + second->getLocation()->y) / 2; + obj = second; + } + const bool isWaypoint = group[0]->isWaypoint(); + const bool enabled = isWaypoint ? inc.waypoints : inc.objects; + double px, py; + frame.worldToFrame(wx, wy, &px, &py); + bool deleted = false; + if (!enabled || sym.isOnAxis(px, py, tolerance)) { + keptOnAxis += enabled ? 1 : 0; + } else if (sym.inSource(px, py)) { + sourceGroups.push_back(group); + if (isWaypoint) copiedWaypointIds.insert(group[0]->getWaypointID()); + } else { + for (size_t i = 0; i < group.size(); i++) toDelete.push_back(group[i]); + if (isWaypoint) deletedWaypointIds.insert(group[0]->getWaypointID()); + deleted = true; + } + if (!deleted) { + for (size_t i = 0; i < group.size(); i++) { + if (group[i]->isWaypoint()) usedWaypointNames.insert(group[i]->getWaypointName().str()); + AsciiString name = group[i]->getProperties()->getAsciiString(TheKey_objectName); + if (!name.isEmpty()) usedObjectNames.insert(name.str()); + } + } + } + + // Build the copies as one linked list, keeping road pairs adjacent. + std::set unresolvedOwners; + McpJson renamed = McpJson::makeArray(); + std::vector > waypointIdMap(numCopies); + MapObject *head = nullptr, *tail = nullptr; + Int objectsCopied = 0; + for (Int k = 1; k < numCopies; k++) { + for (size_t g = 0; g < sourceGroups.size(); g++) { + for (size_t i = 0; i < sourceGroups[g].size(); i++) { + MapObject *src = sourceGroups[g][i]; + MapObject *dup = src->duplicate(); + dup->setNextMap(nullptr); + dup->setSelected(false); + Coord3D loc; + Real angle; + transformObject(src, frame, sym.copies[k], &loc, &angle); + dup->setLocation(&loc); + dup->setAngle(angle); + Dict *props = dup->getProperties(); + props->remove(TheKey_uniqueID); // validate() assigns a fresh one. + const std::string owner = props->getAsciiString(TheKey_originalOwner).str(); + props->setAsciiString(TheKey_originalOwner, AsciiString(renamer.owner(owner, k, &unresolvedOwners).c_str())); + AsciiString objName = props->getAsciiString(TheKey_objectName); + if (!objName.isEmpty()) { + const std::string newName = makeUnique(renamer.rename(objName.str(), k), usedObjectNames); + props->setAsciiString(TheKey_objectName, AsciiString(newName.c_str())); + } + if (src->isWaypoint()) { + const Int newId = doc->getNextWaypointID(); + waypointIdMap[k][src->getWaypointID()] = newId; + dup->setWaypointID(newId); + const std::string oldName = src->getWaypointName().str(); + const std::string newName = makeUnique(renamer.rename(oldName, k), usedWaypointNames); + dup->setWaypointName(AsciiString(newName.c_str())); + if (renamed.size() < 100) { + McpJson pair = McpJson::makeArray(); + pair.push(McpJson(oldName)); + pair.push(McpJson(newName)); + renamed.push(pair); + } + } + if (tail) tail->setNextMap(dup); else head = dup; + tail = dup; + objectsCopied++; + } + } + } + + // Waypoint links: drop links of deleted waypoints, recreate source links between the copies. + WaypointLinksUndoable *links = new WaypointLinksUndoable(doc); + for (Int i = 0; i < doc->getNumWaypointLinks(); i++) { + Int a, b; + doc->getWaypointLink(i, &a, &b); + if (deletedWaypointIds.count(a) || deletedWaypointIds.count(b)) { + links->removed.push_back(std::make_pair(a, b)); + continue; + } + if (!copiedWaypointIds.count(a) && !copiedWaypointIds.count(b)) continue; + for (Int k = 1; k < numCopies; k++) { + const Int ma = copiedWaypointIds.count(a) ? waypointIdMap[k][a] : a; + const Int mb = copiedWaypointIds.count(b) ? waypointIdMap[k][b] : b; + links->added.push_back(std::make_pair(ma, mb)); + } + } + + // Areas. + std::vector polysToDelete, polysToAdd; + std::set usedPolyNames; + if (inc.areas) { + std::vector sourcePolys; + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + if (trig->getNumPoints() == 0 || (skipDefaultWater && isDefaultWater(trig))) { + usedPolyNames.insert(trig->getTriggerName().str()); + continue; + } + double px, py; + polygonCentroid(trig, frame, &px, &py); + if (sym.isOnAxis(px, py, tolerance)) { + usedPolyNames.insert(trig->getTriggerName().str()); + } else if (sym.inSource(px, py)) { + sourcePolys.push_back(trig); + usedPolyNames.insert(trig->getTriggerName().str()); + } else { + polysToDelete.push_back(trig); + } + } + for (Int k = 1; k < numCopies; k++) { + for (size_t i = 0; i < sourcePolys.size(); i++) { + PolygonTrigger *src = sourcePolys[i]; + PolygonTrigger *copy = newInstance(PolygonTrigger)(src->getNumPoints()); + for (Int p = 0; p < src->getNumPoints(); p++) { + copy->addPoint(transformPoint(*src->getPoint(p), frame, sym.copies[k])); + } + copy->setTriggerName(AsciiString(makeUnique(renamer.rename(src->getTriggerName().str(), k), usedPolyNames).c_str())); + copy->setLayerName(src->getLayerName()); + copy->setWaterArea(src->isWaterArea()); + copy->setRiver(src->isRiver()); + copy->setRiverStart(src->getRiverStart()); + copy->setDoExportWithScripts(src->doExportWithScripts()); + polysToAdd.push_back(copy); + } + } + } + + // Assemble one undo step. MultipleUndoable runs the last added step first. + MultipleUndoable *undo = new MultipleUndoable; + undo->addUndoable(links); + for (size_t i = 0; i < polysToAdd.size(); i++) undo->addUndoable(new AddPolygonUndoable(polysToAdd[i])); + for (size_t i = 0; i < polysToDelete.size(); i++) undo->addUndoable(new DeletePolygonUndoable(polysToDelete[i])); + if (head) undo->addUndoable(new AddObjectUndoable(doc, head)); + if (!toDelete.empty()) { + PointerTool::clearSelection(); + for (size_t i = 0; i < toDelete.size(); i++) toDelete[i]->setSelected(true); + undo->addUndoable(new DeleteObjectUndoable(doc)); + PointerTool::clearSelection(); + } + SymmetryTransform xf(frame, sym); + WorldHeightMapEdit *copy = nullptr; + addHeightMapStep(doc, map, xf, inc, undo, ©); + mcpCommit(undo); + finishHeightMapStep(doc, copy, inc); + mcpResetObjectHandles(); + + McpJson result = McpJson::makeObject(); + result.set("mode", sym.mode).set("source", sym.source).set("copies", numCopies - 1); + result.set("objects_copied", objectsCopied).set("objects_deleted", (int)toDelete.size()).set("objects_kept_on_axis", keptOnAxis); + result.set("waypoint_links_added", (int)links->added.size()).set("waypoint_links_removed", (int)links->removed.size()); + result.set("areas_copied", (int)polysToAdd.size()).set("areas_deleted", (int)polysToDelete.size()); + result.set("waypoints_renamed", renamed); + McpJson unresolved = McpJson::makeArray(); + for (std::set::const_iterator it = unresolvedOwners.begin(); it != unresolvedOwners.end(); ++it) { + unresolved.push(McpJson(*it)); + } + result.set("unresolved_owners", unresolved); + return result; +} + +//------------------------------------------------------------------------------------------------- +// map.transform +//------------------------------------------------------------------------------------------------- + +McpJson cmdTransform(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + WorldHeightMapEdit *map = mcpHeightMap(); + Frame frame; + frame.init(map); + const std::string op = mcpArgString(args, "op"); + Mat2 m = MAT_IDENTITY; + if (op == "mirror_x") m = MAT_MIRROR_X; + else if (op == "mirror_y") m = MAT_MIRROR_Y; + else if (op == "rotate_90") m = MAT_ROTATE_90; + else if (op == "rotate_180") m = MAT_ROTATE_180; + else if (op == "rotate_270") m = MAT_ROTATE_270; + else if (op == "mirror_diag") m = MAT_MIRROR_DIAG; + else if (op == "mirror_antidiag") m = MAT_MIRROR_ANTIDIAG; + else mcpFail("op must be mirror_x, mirror_y, rotate_90, rotate_180, rotate_270, mirror_diag or mirror_antidiag"); + if (m.swapsAxes() && frame.width != frame.height) { + mcpFail("%s needs a square map", op.c_str()); + } + Includes inc; + inc.parse(args); + + MultipleUndoable *undo = new MultipleUndoable; + Int objectsMoved = 0, areasMoved = 0; + if (inc.areas) { + PolygonPointsUndoable *polys = new PolygonPointsUndoable; + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + std::vector pts; + for (Int i = 0; i < trig->getNumPoints(); i++) { + pts.push_back(transformPoint(*trig->getPoint(i), frame, m)); + } + polys->add(trig, pts); + areasMoved++; + } + undo->addUndoable(polys); + } + if (inc.objects || inc.waypoints) { + MoveObjectsUndoable *moves = new MoveObjectsUndoable(doc); + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (obj->isWaypoint() ? !inc.waypoints : !inc.objects) continue; + Coord3D loc; + Real angle; + transformObject(obj, frame, m, &loc, &angle); + moves->add(obj, loc, angle); + objectsMoved++; + } + undo->addUndoable(moves); + } + WholeMapTransform xf(frame, m); + WorldHeightMapEdit *copy = nullptr; + addHeightMapStep(doc, map, xf, inc, undo, ©); + mcpCommit(undo); + finishHeightMapStep(doc, copy, inc); + + return McpJson::makeObject().set("op", op).set("objects_moved", objectsMoved).set("areas_moved", areasMoved); +} + +} // namespace + +void mcpRegisterSymmetryCommands() +{ + mcpRegisterCommand("map.transform", cmdTransform, "{op:mirror_x|mirror_y|rotate_90|rotate_180|rotate_270|mirror_diag|mirror_antidiag, include?} Mirrors or rotates the whole map."); + mcpRegisterCommand("map.symmetrize", cmdSymmetrize, "{mode:mirror_x|mirror_y|rotate_180|mirror_diag|mirror_antidiag|rotate_quarters, source, rename?, owner_map?, include?, axis_tolerance?, skip_default_water?} Copies one part of the map onto the rest."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index 53e406a5e53..bbc9b300164 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -80,6 +80,7 @@ void registerAllCommands() mcpRegisterObjectCommands(); mcpRegisterWaypointCommands(); mcpRegisterImageCommands(); + mcpRegisterSymmetryCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index a54004b2fb0..ab6701740fb 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -62,6 +62,11 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - **Heights.** Raw heights are 0..255; world height is raw × 0.625. - **Undo.** WorldBuilder keeps 15 undo steps. Waypoint links are not undoable (same as in the UI). - **Screenshots.** They render the 3D view, so the WorldBuilder window must not be minimized. +- **Symmetry.** `map_transform` mirrors or rotates the whole map. `map_symmetrize` copies one half or quarter onto the rest. + - Both carry heights, textures with correctly oriented blends, passability, objects, waypoints with their links, and areas. + - Player numbers in names and owners are renumbered for each copy. + - Cliff texture mapping is reset on copied cells. + - The mirroring approach follows the Genesis map tools (The CWC Team, Apache-2.0). - **Terrain images.** Heightmap and mask PNGs have north at the top. - 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. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index d4fa56c3e92..80161264bb8 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -112,6 +112,50 @@ def edit_redo(count: int = 1) -> dict: return wb.call("edit.redo", count=count) +# -------------------------------------------------------------------------------------------------- +# Symmetry +# -------------------------------------------------------------------------------------------------- + +SymmetryParts = dict[str, bool] + + +@mcp.tool() +def map_transform(op: Literal["mirror_x", "mirror_y", "rotate_90", "rotate_180", "rotate_270", + "mirror_diag", "mirror_antidiag"], + include: SymmetryParts | None = None) -> dict: + """Mirrors or rotates the whole map in place: heights, textures (blends are re-oriented), + passability, objects, waypoints and areas. mirror_x flips east/west, mirror_y north/south, + rotations are counterclockwise; rotations by 90/270 and the diagonal mirrors need a square map. + include: {heights, textures, passability, objects, waypoints, areas} booleans, all true by default. + One undo step. Cliff texture mapping (UV adjustment) is reset on the moved cells.""" + return wb.call("map.transform", op=op, include=include) + + +@mcp.tool() +def map_symmetrize(mode: Literal["mirror_x", "mirror_y", "rotate_180", "mirror_diag", "mirror_antidiag", + "rotate_quarters"], + source: Literal["west", "east", "south", "north", "sw", "se", "ne", "nw"], + rename: str | list[list[str]] = "auto", owner_map: dict[str, str] | None = None, + include: SymmetryParts | None = None, axis_tolerance: float = 5, + skip_default_water: bool = True) -> dict: + """Builds a symmetric map: copies the `source` part onto the rest, replacing whatever was there. + - mirror_x (source west|east), mirror_y (south|north): mirror symmetry for 2 players. + - rotate_180 (source south|north|west|east): point symmetry for 2 players (e.g. bases in opposite + corners). + - mirror_diag (across y=x, source se|nw) / mirror_antidiag (across y=-x, source sw|ne). + - rotate_quarters (source sw|se|ne|nw, square map): 4-player rotational symmetry, copies go + counterclockwise. + Copies terrain, textures, passability, objects (road pairs stay intact), waypoints with their + links, and areas. Objects/areas within axis_tolerance (world units) of the mirror axis or center + are kept once instead of copied. rename="auto" renumbers players in names and owners for each copy + (Player_1_Start -> Player_2_Start, P1_ -> P2_, InnerPerimeter1 -> InnerPerimeter2, + teamplayer0001 -> teamplayer0002); "none" keeps names; or pass [["from","to"], ...] replacements. + owner_map {old_team: new_team} overrides owners. "Default Water" is left alone by default. + Object ids from objects_list are invalidated. One undo step.""" + return wb.call("map.symmetrize", mode=mode, source=source, rename=rename, owner_map=owner_map, + include=include, axis_tolerance=axis_tolerance, skip_default_water=skip_default_water) + + # -------------------------------------------------------------------------------------------------- # View # -------------------------------------------------------------------------------------------------- diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 73776af2ace..8a662689c7d 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -61,6 +61,15 @@ def main() -> int: if roads: wb.call("roads.add", type=roads[0], points=[[50, 500], [300, 500], [400, 700]]) + # Symmetry: mirror the west half onto the east half, then undo. + before_wp = len(wb.call("waypoints.list")["waypoints"]) + sym = wb.call("map.symmetrize", mode="mirror_x", source="west") + names = [w["waypoint_name"] for w in wb.call("waypoints.list")["waypoints"]] + assert "Player_2_Start" in names, (sym, names) + wb.call("map.transform", op="rotate_180") + wb.call("edit.undo", count=2) + assert len(wb.call("waypoints.list")["waypoints"]) == before_wp + # Heightmap image round trip must be lossless. out_png = os.path.join(tempfile.mkdtemp(prefix="wb_mcp_"), "height.png") wb.call("terrain.export_heightmap", path=out_png) From 5362fe13953894b106f1115dbe83f06066931cf7 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Wed, 30 Sep 2026 20:08:24 +0200 Subject: [PATCH 05/30] feat(worldbuilder): Adds MCP procedural terrain and auto texturing New bridge commands, modeled on the Genesis map generator processors: - terrain.generate: layered seeded Perlin noise (fractal, ridged or signed layers) with area limits, feathering and protected spots. - terrain.limit_slope: caps height steps between neighbours; cliff flags follow the new slopes. - terrain.auto_texture: classifies cells as ground, cliff (slope or impassable) or water (height or water polygons), paints base textures with noise overlays, checks texture space up front, then blends. - terrain.blend_all / remove_blends: bulk blending helpers. The noise is ported from Genesis PerlinNoise.cs (Apache-2.0, The CWC Team) with our own seeding; layout and scenery presets come from Genesis and are loaded by the Python server. See scripts/worldbuilder-mcp/NOTICE. Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 4 + .../WorldBuilder/include/WHeightMapEdit.h | 2 + .../WorldBuilder/include/mcp/McpCommands.h | 4 + .../Tools/WorldBuilder/include/mcp/McpNoise.h | 47 ++ .../Tools/WorldBuilder/include/mcp/McpShape.h | 100 ++++ .../Tools/WorldBuilder/src/WHeightMapEdit.cpp | 12 + .../WorldBuilder/src/mcp/McpCmdGenerate.cpp | 515 ++++++++++++++++++ .../WorldBuilder/src/mcp/McpCmdImage.cpp | 9 +- .../WorldBuilder/src/mcp/McpCmdTerrain.cpp | 80 +-- .../WorldBuilder/src/mcp/McpCommands.cpp | 1 + .../Tools/WorldBuilder/src/mcp/McpNoise.cpp | 122 +++++ scripts/worldbuilder-mcp/NOTICE | 14 + scripts/worldbuilder-mcp/presets/layouts.json | 32 ++ .../worldbuilder-mcp/presets/sceneries.json | 63 +++ scripts/worldbuilder-mcp/server.py | 115 ++++ 15 files changed, 1042 insertions(+), 78 deletions(-) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpNoise.h create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpShape.h create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpNoise.cpp create mode 100644 scripts/worldbuilder-mcp/NOTICE create mode 100644 scripts/worldbuilder-mcp/presets/layouts.json create mode 100644 scripts/worldbuilder-mcp/presets/sceneries.json diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index 1eb30af77c1..dd34d08c5cb 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -197,8 +197,11 @@ set(WORLDBUILDER_SRC "include/WorldBuilderView.h" "include/mcp/McpCommands.h" "include/mcp/McpJson.h" + "include/mcp/McpNoise.h" + "include/mcp/McpShape.h" "include/mcp/McpScreenshot.h" "include/mcp/McpServer.h" + "src/mcp/McpCmdGenerate.cpp" "src/mcp/McpCmdImage.cpp" "src/mcp/McpCmdMap.cpp" "src/mcp/McpCmdObjects.cpp" @@ -207,6 +210,7 @@ set(WORLDBUILDER_SRC "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/WHeightMapEdit.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h index 61049749498..4bb6622733b 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h @@ -137,6 +137,8 @@ class WorldHeightMapEdit : public WorldHeightMap /// Copies heights, textures (including blends) and passability from a same-sized map through a /// mirror or rotation. Cliff texture mapping is reset on the copied cells. void copyTransformedFrom(WorldHeightMapEdit *src, const HeightMapTransform &xf, Bool heights, Bool textures, Bool passability); + /// Removes both blend layers from the cells in the inclusive index range. + void clearBlends(Int x0, Int y0, Int x1, Int y1); static Int getNumTexClasses() {return m_numGlobalTextureClasses;}; static AsciiString getTexClassName(int ndx) {return m_globalTextureClasses[ndx].name;} static AsciiString getTexClassUiName(int ndx) ; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index 653a84489a1..1d77917b09c 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -50,6 +50,7 @@ void mcpRegisterObjectCommands(); void mcpRegisterWaypointCommands(); void mcpRegisterImageCommands(); void mcpRegisterSymmetryCommands(); +void mcpRegisterGenerateCommands(); //------------------------------------------------------------------------------------------------- // Helpers @@ -76,6 +77,9 @@ void mcpCommit(Undoable *undo); /// Pass texturesChanged when tiles were repainted, so the tile set is optimized and the texture list refreshed. McpJson mcpCommitHeightMapEdit(WorldHeightMapEdit *copy, bool texturesChanged); +/// True if the cell is covered by a water polygon whose surface is above the terrain. +bool mcpIsCellUnderWater(WorldHeightMapEdit *map, int cx, int cy); + /// Texture class from a name or index argument (see terrain.list_textures). int mcpFindTextureClass(const McpJson &args, const char *key); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpNoise.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpNoise.h new file mode 100644 index 00000000000..5408a67a285 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpNoise.h @@ -0,0 +1,47 @@ +/* +** 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 . +*/ + +// McpNoise.h +// Seeded Perlin noise for procedural terrain in the MCP bridge. +// +// Based on PerlinNoise.cs from Genesis, Copyright 2013 The CWC Team, licensed under the Apache +// License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0), itself following Ken +// Perlin's reference implementation. Changes: seeded with our own PRNG, 2D sampling helpers, +// ridged variant. + +#pragma once + +class McpNoise +{ +public: + explicit McpNoise(unsigned int seed); + + /// Raw 3D Perlin noise, roughly in -1..1. + double noise(double x, double y, double z) const; + + /// Fractal sum over octaves: frequency doubles and amplitude scales by persistence per octave. + /// Like Genesis the result is clamped, to 0..1 (or -1..1 when signedResult is set). + double fractal(double x, double y, double frequency, int octaves, double persistence, double amplitude, bool signedResult) const; + + /// Ridged variant (sharp crests), 0..1 before amplitude, clamped to 0..1. + double ridged(double x, double y, double frequency, int octaves, double persistence, double amplitude) const; + +private: + int m_p[512]; + double m_z; ///< Fixed slice through the 3D noise, derived from the seed. +}; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpShape.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpShape.h new file mode 100644 index 00000000000..79bfb29a2a4 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpShape.h @@ -0,0 +1,100 @@ +/* +** 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 . +*/ + +// McpShape.h +// Circle/rect area argument shared by the MCP terrain commands. + +#pragma once + +#include "mcp/McpCommands.h" +#include "WHeightMapEdit.h" + +#include + +/// A world-space shape for area operations. Circles fall off linearly over 'feather'. +struct McpShape +{ + bool circle; + double cx, cy, radius, feather; + double x0, y0, x1, y1; + + void parse(const McpJson &args, bool allowFeather) + { + std::string kind = mcpArgString(args, "shape", args.has("radius") ? "circle" : "rect"); + feather = allowFeather ? mcpArgNumber(args, "feather", 0) : 0; + if (feather < 0) feather = 0; + if (kind == "circle") { + circle = true; + cx = mcpArgNumber(args, "x"); + cy = mcpArgNumber(args, "y"); + radius = mcpArgNumber(args, "radius"); + if (radius < 0) mcpFail("radius must be >= 0"); + x0 = cx - radius - feather; + y0 = cy - radius - feather; + x1 = cx + radius + feather; + y1 = cy + radius + feather; + } else if (kind == "rect") { + circle = false; + x0 = mcpArgNumber(args, "x0"); + y0 = mcpArgNumber(args, "y0"); + x1 = mcpArgNumber(args, "x1"); + y1 = mcpArgNumber(args, "y1"); + if (x1 < x0) { double t = x0; x0 = x1; x1 = t; } + if (y1 < y0) { double t = y0; y0 = y1; y1 = t; } + cx = (x0 + x1) / 2; + cy = (y0 + y1) / 2; + radius = 0; + x0 -= feather; y0 -= feather; x1 += feather; y1 += feather; + } else { + mcpFail("shape must be 'circle' (x,y,radius) or 'rect' (x0,y0,x1,y1)"); + } + } + + /// 1 inside the shape, falling to 0 across the feather band, 0 outside. + double weight(double wx, double wy) const + { + double dist; + if (circle) { + double dx = wx - cx, dy = wy - cy; + dist = sqrt(dx * dx + dy * dy) - radius; + } else { + double dx = 0, dy = 0; + if (wx < x0 + feather) dx = x0 + feather - wx; + if (wx > x1 - feather) dx = wx - (x1 - feather); + if (wy < y0 + feather) dy = y0 + feather - wy; + if (wy > y1 - feather) dy = wy - (y1 - feather); + dist = sqrt(dx * dx + dy * dy); + } + const double CLOSE_ENOUGH = 0.01; + if (dist <= CLOSE_ENOUGH) return 1.0; + if (feather <= 0 || dist >= feather) return 0.0; + return (feather - dist) / feather; + } + + void indexRange(WorldHeightMapEdit *map, Int &ix0, Int &iy0, Int &ix1, Int &iy1) const + { + ix0 = (Int)floor(x0 / MAP_XY_FACTOR) + map->getBorderSize(); + iy0 = (Int)floor(y0 / MAP_XY_FACTOR) + map->getBorderSize(); + ix1 = (Int)ceil(x1 / MAP_XY_FACTOR) + map->getBorderSize(); + iy1 = (Int)ceil(y1 / MAP_XY_FACTOR) + map->getBorderSize(); + if (ix0 < 0) ix0 = 0; + if (iy0 < 0) iy0 = 0; + if (ix1 > map->getXExtent() - 1) ix1 = map->getXExtent() - 1; + if (iy1 > map->getYExtent() - 1) iy1 = map->getYExtent() - 1; + } +}; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp index b2ca83c2b58..b6cc1913d18 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp @@ -1781,6 +1781,18 @@ void WorldHeightMapEdit::copyTransformedFrom(WorldHeightMapEdit *src, const Heig } } +void WorldHeightMapEdit::clearBlends(Int x0, Int y0, Int x1, Int y1) +{ + for (Int y = y0; y <= y1; y++) { + for (Int x = x0; x <= x1; x++) { + if (x < 0 || y < 0 || x >= m_width || y >= m_height) continue; + const Int ndx = y*m_width + x; + m_blendTileNdxes[ndx] = 0; + m_extraBlendTileNdxes[ndx] = 0; + } + } +} + Bool WorldHeightMapEdit::optimizeTiles() { // Run through all the tile indexes changing to tile classes. diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp new file mode 100644 index 00000000000..5ab2d5590d1 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp @@ -0,0 +1,515 @@ +/* +** 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 . +*/ + +// McpCmdGenerate.cpp +// MCP bridge commands for procedural terrain: layered noise heights, slope limiting, automatic +// texturing by height/slope with noise overlays, and bulk blending. Modeled on the +// TerrainGenerator, TerrainSmoother and TextureGenerator processors of Genesis (The CWC Team). + +#include "StdAfx.h" +#include "mcp/McpCommands.h" +#include "mcp/McpNoise.h" +#include "mcp/McpShape.h" + +#include "WHeightMapEdit.h" + +#include +#include +#include + +namespace +{ + +/// Optional area limit: without shape arguments the whole map is affected at full weight. +struct Area +{ + bool limited; + McpShape shape; + + void parse(const McpJson &args, bool allowFeather) + { + limited = args.has("x") || args.has("x0") || args.has("shape"); + if (limited) shape.parse(args, allowFeather); + } + double weight(WorldHeightMapEdit *map, double vx, double vy) const + { + if (!limited) return 1.0; + const Int border = map->getBorderSize(); + return shape.weight((vx - border) * MAP_XY_FACTOR, (vy - border) * MAP_XY_FACTOR); + } + void range(WorldHeightMapEdit *map, Int *x0, Int *y0, Int *x1, Int *y1) const + { + if (limited) { + shape.indexRange(map, *x0, *y0, *x1, *y1); + } else { + *x0 = 0; + *y0 = 0; + *x1 = map->getXExtent() - 1; + *y1 = map->getYExtent() - 1; + } + } +}; + +UnsignedByte clampHeight(double h) +{ + if (h < 0) return 0; + if (h > 255) return 255; + return (UnsignedByte)floor(h + 0.5); +} + +//------------------------------------------------------------------------------------------------- +// terrain.generate +//------------------------------------------------------------------------------------------------- + +struct NoiseLayer +{ + double height, frequency, persistence, amplitude; + Int octaves; + bool ridged, signedResult; +}; + +McpJson cmdGenerate(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const unsigned int seed = (unsigned int)mcpArgInt(args, "seed", 1); + const std::string mode = mcpArgString(args, "mode", "set"); + if (mode != "set" && mode != "add") { + mcpFail("mode must be set or add"); + } + const double baseHeight = mcpArgNumber(args, "base_height", 40); + + std::vector layers; + const McpJson &layerArgs = mcpArgArray(args, "layers"); + for (size_t i = 0; i < layerArgs.size(); i++) { + const McpJson &l = layerArgs.at(i); + if (!l.isObject()) mcpFail("layers must be objects"); + NoiseLayer layer; + layer.height = mcpArgNumber(l, "height"); + layer.frequency = mcpArgNumber(l, "frequency"); + layer.octaves = mcpArgInt(l, "octaves", 3); + layer.persistence = mcpArgNumber(l, "persistence", 0.5); + layer.amplitude = mcpArgNumber(l, "amplitude", 1.0); + layer.ridged = mcpArgBool(l, "ridged", false); + layer.signedResult = mcpArgBool(l, "signed", false); + if (layer.frequency <= 0 || layer.octaves < 1 || layer.octaves > 10) { + mcpFail("layer %d: frequency must be > 0 and octaves 1..10", (int)i); + } + layers.push_back(layer); + } + if (layers.empty()) mcpFail("pass at least one layer"); + + Area area; + area.parse(args, true); + std::vector protect; + const McpJson &protectArgs = args.get("protect"); + if (protectArgs.isArray()) { + for (size_t i = 0; i < protectArgs.size(); i++) { + McpShape s; + s.parse(protectArgs.at(i), true); + protect.push_back(s); + } + } + + std::vector noises; + for (size_t i = 0; i < layers.size(); i++) { + noises.push_back(new McpNoise(seed * 31u + (unsigned int)i * 7919u)); + } + Int x0, y0, x1, y1; + area.range(map, &x0, &y0, &x1, &y1); + const Int border = map->getBorderSize(); + WorldHeightMapEdit *copy = map->duplicate(); + Int changed = 0; + for (Int y = y0; y <= y1; y++) { + for (Int x = x0; x <= x1; x++) { + double w = area.weight(map, x, y); + for (size_t p = 0; p < protect.size() && w > 0; p++) { + w *= 1.0 - protect[p].weight((x - border) * MAP_XY_FACTOR, (y - border) * MAP_XY_FACTOR); + } + if (w <= 0) continue; + const double cur = map->getHeight(x, y); + double target = mode == "set" ? baseHeight : cur; + for (size_t i = 0; i < layers.size(); i++) { + const NoiseLayer &l = layers[i]; + const double v = l.ridged + ? noises[i]->ridged(x, y, l.frequency, l.octaves, l.persistence, l.amplitude) + : noises[i]->fractal(x, y, l.frequency, l.octaves, l.persistence, l.amplitude, l.signedResult); + target += v * l.height; + } + const UnsignedByte h = clampHeight(cur + (target - cur) * (w > 1 ? 1 : w)); + if (h != map->getHeight(x, y)) { + copy->setHeight(x, y, h); + changed++; + } + } + } + for (size_t i = 0; i < noises.size(); i++) delete noises[i]; + if (changed == 0) { + REF_PTR_RELEASE(copy); + return McpJson::makeObject().set("changed", false); + } + McpJson j = mcpCommitHeightMapEdit(copy, false); + j.set("vertices_changed", changed); + return j; +} + +//------------------------------------------------------------------------------------------------- +// terrain.limit_slope +//------------------------------------------------------------------------------------------------- + +McpJson cmdLimitSlope(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const double maxStep = mcpArgNumber(args, "max_step", 15); + const Int iterations = mcpArgInt(args, "iterations", 20); + if (maxStep < 1 || iterations < 1 || iterations > 100) { + mcpFail("max_step must be >= 1 and iterations 1..100"); + } + Area area; + area.parse(args, false); + Int x0, y0, x1, y1; + area.range(map, &x0, &y0, &x1, &y1); + const Int w = x1 - x0 + 1, h = y1 - y0 + 1; + + std::vector heights(w * h); + for (Int y = 0; y < h; y++) { + for (Int x = 0; x < w; x++) { + heights[y * w + x] = map->getHeight(x0 + x, y0 + y); + } + } + // Like Genesis' TerrainSmoother, but symmetric: every too-steep neighbour pair is pulled toward + // each other until the height step is at most max_step. Diagonals allow sqrt(2) times more. + static const Int dirs[4][2] = { { 1, 0 }, { 0, 1 }, { 1, 1 }, { 1, -1 } }; + Int passes = 0; + for (; passes < iterations; passes++) { + bool any = false; + for (Int y = 0; y < h; y++) { + for (Int x = 0; x < w; x++) { + for (Int d = 0; d < 4; d++) { + const Int nx = x + dirs[d][0], ny = y + dirs[d][1]; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + const double limit = (dirs[d][0] && dirs[d][1]) ? maxStep * 1.4142 : maxStep; + double &a = heights[y * w + x]; + double &b = heights[ny * w + nx]; + const double diff = a - b; + if (fabs(diff) > limit + 0.01) { + const double excess = (fabs(diff) - limit) / 2; + if (diff > 0) { a -= excess; b += excess; } else { a += excess; b -= excess; } + any = true; + } + } + } + } + if (!any) break; + } + + WorldHeightMapEdit *copy = map->duplicate(); + Int changed = 0; + for (Int y = 0; y < h; y++) { + for (Int x = 0; x < w; x++) { + const UnsignedByte nh = clampHeight(heights[y * w + x]); + if (nh != map->getHeight(x0 + x, y0 + y)) { + // setHeight also updates the cliff (impassable) flags from the new slopes. + copy->setHeight(x0 + x, y0 + y, nh); + changed++; + } + } + } + if (changed == 0) { + REF_PTR_RELEASE(copy); + return McpJson::makeObject().set("changed", false).set("passes", passes); + } + McpJson j = mcpCommitHeightMapEdit(copy, false); + j.set("vertices_changed", changed).set("passes", passes); + return j; +} + +//------------------------------------------------------------------------------------------------- +// Blending +//------------------------------------------------------------------------------------------------- + +/// Runs autoBlendOut once per connected region of each listed texture class inside the range. +Int blendRegions(WorldHeightMapEdit *map, const std::set &classes, Int edgeClass, Int x0, Int y0, Int x1, Int y1) +{ + const Int w = x1 - x0 + 1, h = y1 - y0 + 1; + std::vector seen(w * h, 0); + std::vector stack; + Int regions = 0; + for (Int y = y0; y <= y1; y++) { + for (Int x = x0; x <= x1; x++) { + if (seen[(y - y0) * w + (x - x0)]) continue; + const Int cls = map->getTextureClass(x, y, true); + if (!classes.count(cls)) continue; + // Flood the region, remembering a cell that is not blended yet: autoBlendOut needs one. + Int seedX = -1, seedY = -1; + stack.clear(); + stack.push_back(y * 100000 + x); + seen[(y - y0) * w + (x - x0)] = 1; + while (!stack.empty()) { + const Int packed = stack.back(); + stack.pop_back(); + const Int cx = packed % 100000, cy = packed / 100000; + if (seedX < 0 && map->getTextureClass(cx, cy, false) == cls) { + seedX = cx; + seedY = cy; + } + static const Int n4[4][2] = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }; + for (Int d = 0; d < 4; d++) { + const Int nx = cx + n4[d][0], ny = cy + n4[d][1]; + if (nx < x0 || ny < y0 || nx > x1 || ny > y1) continue; + unsigned char &s = seen[(ny - y0) * w + (nx - x0)]; + if (s || map->getTextureClass(nx, ny, true) != cls) continue; + s = 1; + stack.push_back(ny * 100000 + nx); + } + } + if (seedX >= 0) { + map->autoBlendOut(seedX, seedY, edgeClass); + regions++; + } + } + } + return regions; +} + +Int optionalTexture(const McpJson &args, const char *key) +{ + return args.has(key) ? mcpFindTextureClass(args, key) : -1; +} + +McpJson cmdBlendAll(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + Area area; + area.parse(args, false); + Int x0, y0, x1, y1; + area.range(map, &x0, &y0, &x1, &y1); + if (x1 >= map->getXExtent() - 1) x1 = map->getXExtent() - 2; + if (y1 >= map->getYExtent() - 1) y1 = map->getYExtent() - 2; + + std::set classes; + if (args.has("textures")) { + const McpJson &list = mcpArgArray(args, "textures"); + for (size_t i = 0; i < list.size(); i++) { + McpJson one = McpJson::makeObject().set("texture", list.at(i)); + classes.insert(mcpFindTextureClass(one, "texture")); + } + } else { + // Blend everything except the most common texture, which is taken to be the ground. + std::vector counts(WorldHeightMapEdit::getNumTexClasses(), 0); + for (Int y = y0; y <= y1; y++) { + for (Int x = x0; x <= x1; x++) { + const Int cls = map->getTextureClass(x, y, true); + if (cls >= 0 && cls < (Int)counts.size()) counts[cls]++; + } + } + Int ground = -1; + for (Int i = 0; i < (Int)counts.size(); i++) { + if (counts[i] > 0) classes.insert(i); + if (counts[i] > 0 && (ground < 0 || counts[i] > counts[ground])) ground = i; + } + classes.erase(ground); + } + WorldHeightMapEdit *copy = map->duplicate(); + const Int regions = blendRegions(copy, classes, optionalTexture(args, "edge_texture"), x0, y0, x1, y1); + McpJson j = mcpCommitHeightMapEdit(copy, true); + j.set("regions_blended", regions); + return j; +} + +McpJson cmdRemoveBlends(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + Area area; + area.parse(args, false); + Int x0, y0, x1, y1; + area.range(map, &x0, &y0, &x1, &y1); + WorldHeightMapEdit *copy = map->duplicate(); + copy->clearBlends(x0, y0, x1, y1); + return mcpCommitHeightMapEdit(copy, true); +} + +//------------------------------------------------------------------------------------------------- +// terrain.auto_texture +//------------------------------------------------------------------------------------------------- + +struct Overlay +{ + Int texClass; + double frequency, threshold, persistence, amplitude; + Int octaves; + McpNoise *noise; +}; + +/// A base texture plus noise overlays (Genesis scenery layer). +struct TextureLayer +{ + bool present; + Int base; + std::vector overlays; + + void parse(const McpJson &arg, const char *what, unsigned int seed) + { + present = arg.isObject(); + if (!present) return; + base = mcpFindTextureClass(arg, "texture"); + const McpJson &list = arg.get("overlays"); + if (list.isNull()) return; + if (!list.isArray()) mcpFail("%s.overlays must be a list", what); + for (size_t i = 0; i < list.size(); i++) { + const McpJson &o = list.at(i); + Overlay ov; + ov.texClass = mcpFindTextureClass(o, "texture"); + // Defaults match the overlay noise of Genesis' TextureGenerator. + ov.frequency = mcpArgNumber(o, "frequency", 0.07); + ov.threshold = mcpArgNumber(o, "threshold", 0.5); + ov.octaves = mcpArgInt(o, "octaves", 1); + ov.persistence = mcpArgNumber(o, "persistence", 0.25); + ov.amplitude = mcpArgNumber(o, "amplitude", 1.45); + ov.noise = new McpNoise(seed * 131u + (unsigned int)(overlays.size() + 1) * 104729u + (unsigned int)strlen(what)); + overlays.push_back(ov); + } + } + void free() + { + for (size_t i = 0; i < overlays.size(); i++) delete overlays[i].noise; + overlays.clear(); + } + Int pick(Int x, Int y) const + { + Int cls = base; + for (size_t i = 0; i < overlays.size(); i++) { + const Overlay &o = overlays[i]; + if (o.noise->fractal(x, y, o.frequency, o.octaves, o.persistence, o.amplitude, false) > o.threshold) { + cls = o.texClass; + } + } + return cls; + } + void collect(std::vector &all, bool includeBase) const + { + if (!present) return; + if (includeBase) all.push_back(base); + for (size_t i = 0; i < overlays.size(); i++) all.push_back(overlays[i].texClass); + } +}; + +McpJson cmdAutoTexture(const McpJson &args) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + const unsigned int seed = (unsigned int)mcpArgInt(args, "seed", 1); + TextureLayer ground, cliff, water; + ground.parse(args.get("base"), "base", seed); + if (!ground.present) mcpFail("base must be an object with a texture (and optional overlays)"); + cliff.parse(args.get("cliff"), "cliff", seed); + water.parse(args.get("water"), "water", seed); + const McpJson &cliffArgs = args.get("cliff"); + const McpJson &waterArgs = args.get("water"); + const double cliffSlope = cliff.present ? mcpArgNumber(cliffArgs, "slope", 12) : 0; + const bool cliffImpassable = cliff.present && mcpArgBool(cliffArgs, "use_impassable", true); + const bool waterPolygons = water.present && !waterArgs.has("below"); + const double waterBelow = water.present && waterArgs.has("below") ? mcpArgNumber(waterArgs, "below") : 0; + + Area area; + area.parse(args, false); + Int x0, y0, x1, y1; + area.range(map, &x0, &y0, &x1, &y1); + if (x1 >= map->getXExtent() - 1) x1 = map->getXExtent() - 2; + if (y1 >= map->getYExtent() - 1) y1 = map->getYExtent() - 2; + + WorldHeightMapEdit *copy = map->duplicate(); + // Make sure every texture fits before painting anything, and allocate them in order. + std::vector textures; + ground.collect(textures, true); + cliff.collect(textures, true); + water.collect(textures, true); + std::vector missing; + // Painting a texture is the only public way to allocate it; use the area center, which is + // always repainted below. + const Int allocX = (x0 + x1) / 2, allocY = (y0 + y1) / 2; + for (size_t i = 0; i < textures.size(); i++) { + if (!copy->isTexClassUsed(textures[i])) { + if (!copy->canFitTexture(textures[i])) { + missing.push_back(WorldHeightMapEdit::getTexClassName(textures[i]).str()); + continue; + } + copy->setTileNdx(allocX, allocY, textures[i], false); + } + } + if (!missing.empty()) { + std::string list; + for (size_t i = 0; i < missing.size(); i++) list += (i ? ", " : "") + missing[i]; + ground.free(); cliff.free(); water.free(); + REF_PTR_RELEASE(copy); + mcpFail("the map has no room for these textures: %s (use fewer textures or replace existing ones)", list.c_str()); + } + + Int numCliff = 0, numWater = 0, numGround = 0; + for (Int y = y0; y <= y1; y++) { + for (Int x = x0; x <= x1; x++) { + if (area.limited && area.weight(map, x + 0.5, y + 0.5) <= 0) continue; + const Int h00 = map->getHeight(x, y), h10 = map->getHeight(x + 1, y); + const Int h01 = map->getHeight(x, y + 1), h11 = map->getHeight(x + 1, y + 1); + Int lo = h00, hi = h00; + const Int hs[3] = { h10, h01, h11 }; + for (Int i = 0; i < 3; i++) { if (hs[i] < lo) lo = hs[i]; if (hs[i] > hi) hi = hs[i]; } + const double avg = (h00 + h10 + h01 + h11) / 4.0; + Int cls; + if (cliff.present && (hi - lo >= cliffSlope || (cliffImpassable && map->getCliffState(x, y)))) { + cls = cliff.pick(x, y); + numCliff++; + } else if (water.present && (waterPolygons ? mcpIsCellUnderWater(map, x, y) : avg < waterBelow)) { + cls = water.pick(x, y); + numWater++; + } else { + cls = ground.pick(x, y); + numGround++; + } + copy->setTileNdx(x, y, cls, false); + } + } + + Int regions = 0; + if (mcpArgBool(args, "blend", true)) { + // Blend every texture except the ground base outward, so overlays, cliffs and water + // fade into their surroundings (Genesis GenerateBlendTiles). + std::vector toBlend; + ground.collect(toBlend, false); + cliff.collect(toBlend, true); + water.collect(toBlend, true); + std::set classes(toBlend.begin(), toBlend.end()); + classes.erase(ground.base); + regions = blendRegions(copy, classes, optionalTexture(args, "edge_texture"), x0, y0, x1, y1); + } + ground.free(); cliff.free(); water.free(); + + McpJson j = mcpCommitHeightMapEdit(copy, true); + j.set("cells_ground", numGround).set("cells_cliff", numCliff).set("cells_water", numWater); + j.set("regions_blended", regions); + return j; +} + +} // namespace + +void mcpRegisterGenerateCommands() +{ + mcpRegisterCommand("terrain.generate", cmdGenerate, "{layers:[{height,frequency,octaves?,persistence?,amplitude?,ridged?,signed?}], seed?, mode?:set|add, base_height?, shape?, feather?, protect?:[{x,y,radius,feather?}]} Noise terrain."); + mcpRegisterCommand("terrain.limit_slope", cmdLimitSlope, "{max_step?=15, iterations?=20, shape?} Reduces height steps between neighbours; cliff flags follow the slope."); + mcpRegisterCommand("terrain.auto_texture", cmdAutoTexture, "{base:{texture,overlays?}, cliff?:{texture,overlays?,slope?,use_impassable?}, water?:{texture,overlays?,below?}, seed?, blend?, edge_texture?, shape?} Textures cells by slope/water with noise overlays."); + mcpRegisterCommand("terrain.blend_all", cmdBlendAll, "{textures?, edge_texture?, shape?} Auto-blends every region of the given (default: all but the most common) textures."); + mcpRegisterCommand("terrain.remove_blends", cmdRemoveBlends, "{shape?} Removes texture blends."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp index 0a6b392c69a..31ad767b6f3 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp @@ -319,8 +319,10 @@ McpJson cmdImportHeightmap(const McpJson &args) // Cell masks //------------------------------------------------------------------------------------------------- +} // namespace + /// True if the cell is covered by a water polygon whose surface is above the terrain. -bool isCellUnderWater(WorldHeightMapEdit *map, Int cx, Int cy) +bool mcpIsCellUnderWater(WorldHeightMapEdit *map, Int cx, Int cy) { const Int border = map->getBorderSize(); ICoord3D pt; @@ -337,6 +339,9 @@ bool isCellUnderWater(WorldHeightMapEdit *map, Int cx, Int cy) return false; } +namespace +{ + McpJson cmdExportMask(const McpJson &args) { WorldHeightMapEdit *map = mcpHeightMap(); @@ -362,7 +367,7 @@ McpJson cmdExportMask(const McpJson &args) bool set; if (kind == "passability") set = map->getCliffState(cx, cy) != 0; else if (kind == "texture") set = map->getTextureClass(cx, cy, true) == texClass; - else set = isCellUnderWater(map, cx, cy); + else set = mcpIsCellUnderWater(map, cx, cy); pixels[(ch - 1 - y) * cw + x] = set ? 255 : 0; if (set) numSet++; } diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp index fa97f4401ff..f39da38b76b 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp @@ -24,6 +24,7 @@ #include "StdAfx.h" #include "mcp/McpCommands.h" +#include "mcp/McpShape.h" #include "CUndoable.h" #include "TerrainMaterial.h" @@ -120,79 +121,6 @@ Int findTextureClass(const McpJson &args, const char *key) return -1; } -/// A world-space shape for area operations. Circles fall off linearly over 'feather'. -struct Shape -{ - bool circle; - double cx, cy, radius, feather; - double x0, y0, x1, y1; - - void parse(const McpJson &args, bool allowFeather) - { - std::string kind = mcpArgString(args, "shape", args.has("radius") ? "circle" : "rect"); - feather = allowFeather ? mcpArgNumber(args, "feather", 0) : 0; - if (feather < 0) feather = 0; - if (kind == "circle") { - circle = true; - cx = mcpArgNumber(args, "x"); - cy = mcpArgNumber(args, "y"); - radius = mcpArgNumber(args, "radius"); - if (radius < 0) mcpFail("radius must be >= 0"); - x0 = cx - radius - feather; - y0 = cy - radius - feather; - x1 = cx + radius + feather; - y1 = cy + radius + feather; - } else if (kind == "rect") { - circle = false; - x0 = mcpArgNumber(args, "x0"); - y0 = mcpArgNumber(args, "y0"); - x1 = mcpArgNumber(args, "x1"); - y1 = mcpArgNumber(args, "y1"); - if (x1 < x0) { double t = x0; x0 = x1; x1 = t; } - if (y1 < y0) { double t = y0; y0 = y1; y1 = t; } - cx = (x0 + x1) / 2; - cy = (y0 + y1) / 2; - radius = 0; - x0 -= feather; y0 -= feather; x1 += feather; y1 += feather; - } else { - mcpFail("shape must be 'circle' (x,y,radius) or 'rect' (x0,y0,x1,y1)"); - } - } - - /// 1 inside the shape, falling to 0 across the feather band, 0 outside. - double weight(double wx, double wy) const - { - double dist; - if (circle) { - double dx = wx - cx, dy = wy - cy; - dist = sqrt(dx * dx + dy * dy) - radius; - } else { - double dx = 0, dy = 0; - if (wx < x0 + feather) dx = x0 + feather - wx; - if (wx > x1 - feather) dx = wx - (x1 - feather); - if (wy < y0 + feather) dy = y0 + feather - wy; - if (wy > y1 - feather) dy = wy - (y1 - feather); - dist = sqrt(dx * dx + dy * dy); - } - const double CLOSE_ENOUGH = 0.01; - if (dist <= CLOSE_ENOUGH) return 1.0; - if (feather <= 0 || dist >= feather) return 0.0; - return (feather - dist) / feather; - } - - void indexRange(WorldHeightMapEdit *map, Int &ix0, Int &iy0, Int &ix1, Int &iy1) const - { - ix0 = (Int)floor(x0 / MAP_XY_FACTOR) + map->getBorderSize(); - iy0 = (Int)floor(y0 / MAP_XY_FACTOR) + map->getBorderSize(); - ix1 = (Int)ceil(x1 / MAP_XY_FACTOR) + map->getBorderSize(); - iy1 = (Int)ceil(y1 / MAP_XY_FACTOR) + map->getBorderSize(); - if (ix0 < 0) ix0 = 0; - if (iy0 < 0) iy0 = 0; - if (ix1 > map->getXExtent() - 1) ix1 = map->getXExtent() - 1; - if (iy1 > map->getYExtent() - 1) iy1 = map->getYExtent() - 1; - } -}; - //------------------------------------------------------------------------------------------------- // Heights //------------------------------------------------------------------------------------------------- @@ -263,7 +191,7 @@ McpJson cmdBrush(const McpJson &args) { WorldHeightMapEdit *map = mcpHeightMap(); const std::string op = mcpArgString(args, "op"); - Shape shape; + McpShape shape; shape.parse(args, true); const double strength = mcpArgNumber(args, "strength", 1.0); const Int iterations = mcpArgInt(args, "iterations", 1); @@ -374,7 +302,7 @@ McpJson cmdPaintTexture(const McpJson &args) { WorldHeightMapEdit *map = mcpHeightMap(); const Int texClass = findTextureClass(args, "texture"); - Shape shape; + McpShape shape; shape.parse(args, false); // Tiles are either painted or not. if (!map->isTexClassUsed(texClass) && !map->canFitTexture(texClass)) { mcpFail("the map has no room for another texture; paint over or remove an existing one first"); @@ -457,7 +385,7 @@ McpJson cmdSetPassability(const McpJson &args) { WorldHeightMapEdit *map = mcpHeightMap(); const bool impassable = mcpArgBool(args, "impassable", true); - Shape shape; + McpShape shape; shape.parse(args, false); Int ix0, iy0, ix1, iy1; shape.indexRange(map, ix0, iy0, ix1, iy1); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index bbc9b300164..13e23f6cfab 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -81,6 +81,7 @@ void registerAllCommands() mcpRegisterWaypointCommands(); mcpRegisterImageCommands(); mcpRegisterSymmetryCommands(); + mcpRegisterGenerateCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpNoise.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpNoise.cpp new file mode 100644 index 00000000000..109459a15df --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpNoise.cpp @@ -0,0 +1,122 @@ +/* +** 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 . +*/ + +// McpNoise.cpp +// Based on PerlinNoise.cs from Genesis, Copyright 2013 The CWC Team, Apache License 2.0. + +#include "StdAfx.h" +#include "mcp/McpNoise.h" + +#include + +static double fade(double t) +{ + return t * t * t * (t * (t * 6 - 15) + 10); +} + +static double lerp(double t, double a, double b) +{ + return a + t * (b - a); +} + +static double grad(int hash, double x, double y, double z) +{ + // Convert the low 4 bits of the hash into 12 gradient directions. + const int h = hash & 15; + const double u = h < 8 ? x : y; + const double v = h < 4 ? y : (h == 12 || h == 14 ? x : z); + return ((h & 1) == 0 ? u : -u) + ((h & 2) == 0 ? v : -v); +} + +McpNoise::McpNoise(unsigned int seed) +{ + // xorshift32; a zero state would stay zero. + unsigned int state = seed * 2654435761u + 0x9E3779B9u; + if (state == 0) state = 1; + int perm[256]; + for (int i = 0; i < 256; i++) { + perm[i] = i; + } + for (int i = 255; i > 0; i--) { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + const int j = (int)(state % (unsigned int)(i + 1)); + const int t = perm[i]; + perm[i] = perm[j]; + perm[j] = t; + } + for (int i = 0; i < 256; i++) { + m_p[i] = m_p[i + 256] = perm[i]; + } + m_z = (seed % 997) * 0.618 + 0.5; +} + +double McpNoise::noise(double x, double y, double z) const +{ + const int X = (int)floor(x) & 255; + const int Y = (int)floor(y) & 255; + const int Z = (int)floor(z) & 255; + x -= floor(x); + y -= floor(y); + z -= floor(z); + const double u = fade(x); + const double v = fade(y); + const double w = fade(z); + const int A = m_p[X] + Y, AA = m_p[A] + Z, AB = m_p[A + 1] + Z; + const int B = m_p[X + 1] + Y, BA = m_p[B] + Z, BB = m_p[B + 1] + Z; + return lerp(w, lerp(v, lerp(u, grad(m_p[AA], x, y, z), grad(m_p[BA], x - 1, y, z)), + lerp(u, grad(m_p[AB], x, y - 1, z), grad(m_p[BB], x - 1, y - 1, z))), + lerp(v, lerp(u, grad(m_p[AA + 1], x, y, z - 1), grad(m_p[BA + 1], x - 1, y, z - 1)), + lerp(u, grad(m_p[AB + 1], x, y - 1, z - 1), grad(m_p[BB + 1], x - 1, y - 1, z - 1)))); +} + +double McpNoise::fractal(double x, double y, double frequency, int octaves, double persistence, double amplitude, bool signedResult) const +{ + double sum = 0; + double amp = amplitude; + double freq = frequency; + for (int i = 0; i < octaves; i++) { + sum += noise(x * freq, y * freq, m_z) * amp; + freq *= 2; + amp *= persistence; + } + const double lo = signedResult ? -1.0 : 0.0; + if (sum < lo) return lo; + if (sum > 1.0) return 1.0; + return sum; +} + +double McpNoise::ridged(double x, double y, double frequency, int octaves, double persistence, double amplitude) const +{ + double sum = 0; + double total = 0; + double amp = 1; + double freq = frequency; + for (int i = 0; i < octaves; i++) { + const double n = 1.0 - fabs(noise(x * freq, y * freq, m_z)); + sum += n * n * amp; + total += amp; + freq *= 2; + amp *= persistence; + } + double v = total > 0 ? sum / total * amplitude : 0; + if (v < 0) return 0; + if (v > 1) return 1; + return v; +} diff --git a/scripts/worldbuilder-mcp/NOTICE b/scripts/worldbuilder-mcp/NOTICE new file mode 100644 index 00000000000..cdf7f292f71 --- /dev/null +++ b/scripts/worldbuilder-mcp/NOTICE @@ -0,0 +1,14 @@ +WorldBuilder MCP server + +This component includes software and data derived from Genesis - CWC Map Generator, +Copyright 2013 The CWC Team (http://www.cold-war-crisis.de), licensed under the +Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0): + +- GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpNoise.cpp: Perlin noise, based on + Genesis/Math/PerlinNoise.cs. +- scripts/worldbuilder-mcp/presets/layouts.json and sceneries.json: terrain layout and + scenery presets from Genesis.UI/MapLayouts.xml, Genesis.UI/Sceneries/*.xml and + MapTools/MapTools.cs. + +The terrain generation, texturing and symmetry commands follow the design of the Genesis +processors and map tools. diff --git a/scripts/worldbuilder-mcp/presets/layouts.json b/scripts/worldbuilder-mcp/presets/layouts.json new file mode 100644 index 00000000000..6c05fd62cc2 --- /dev/null +++ b/scripts/worldbuilder-mcp/presets/layouts.json @@ -0,0 +1,32 @@ +{ + "_comment": "Terrain layouts for terrain_generate. 'default' is the Genesis MapLayouts.xml layout (Height=base_height; Elevation=amplitude, Jaggedness=frequency per vertex, Ruggedness=persistence, LevelOfDetail=octaves). Layer values are clamped noise (0..1, or -1..1 when signed) times height, in raw height units.", + "default": { + "base_height": 40, + "layers": [ + {"name": "Hills", "height": 200, "amplitude": 1.3, "frequency": 0.005, "persistence": 0.25, "octaves": 3}, + {"name": "Depressions", "height": -40, "amplitude": 1.22, "frequency": 0.014, "persistence": 0.7, "octaves": 2}, + {"name": "Bumps", "height": 13, "amplitude": 0.79, "frequency": 0.038, "persistence": 0.8, "octaves": 3} + ] + }, + "rolling": { + "base_height": 40, + "layers": [ + {"name": "Swells", "height": 30, "amplitude": 1.0, "frequency": 0.012, "persistence": 0.5, "octaves": 3, "signed": true}, + {"name": "Bumps", "height": 6, "amplitude": 0.8, "frequency": 0.05, "persistence": 0.6, "octaves": 2, "signed": true} + ] + }, + "mountains": { + "base_height": -40, + "layers": [ + {"name": "Ridges", "height": 140, "amplitude": 1.1, "frequency": 0.008, "persistence": 0.5, "octaves": 4, "ridged": true}, + {"name": "Valleys", "height": -30, "amplitude": 1.2, "frequency": 0.01, "persistence": 0.5, "octaves": 2}, + {"name": "Bumps", "height": 8, "amplitude": 0.8, "frequency": 0.045, "persistence": 0.7, "octaves": 2} + ] + }, + "flat_bumpy": { + "base_height": 40, + "layers": [ + {"name": "Bumps", "height": 8, "amplitude": 0.9, "frequency": 0.04, "persistence": 0.6, "octaves": 3, "signed": true} + ] + } +} diff --git a/scripts/worldbuilder-mcp/presets/sceneries.json b/scripts/worldbuilder-mcp/presets/sceneries.json new file mode 100644 index 00000000000..983dcf417fa --- /dev/null +++ b/scripts/worldbuilder-mcp/presets/sceneries.json @@ -0,0 +1,63 @@ +{ + "_comment": "Sceneries for terrain_auto_texture, from Genesis (Genesis.UI/Sceneries/*.xml and the MapTools defaults). Overlay 'frequency' is the Genesis Granularity; an overlay replaces the texture where its noise exceeds 'threshold' (0.5).", + "highlands": { + "base": {"texture": "GrassLargeType1", "overlays": [ + {"texture": "GrassMediumType22", "frequency": 0.05}, + {"texture": "GrassMediumType6", "frequency": 0.07}, + {"texture": "GrassMediumType5", "frequency": 0.1}]}, + "cliff": {"texture": "CliffMediumType15b", "overlays": [{"texture": "CliffMediumType17", "frequency": 0.23}]} + }, + "fall": { + "base": {"texture": "GrassMediumType30", "overlays": [ + {"texture": "GrassMediumType11b", "frequency": 0.1}, + {"texture": "GrassMediumType37d", "frequency": 0.15}, + {"texture": "GrassMediumType10", "frequency": 0.07}]}, + "cliff": {"texture": "CliffMediumType15", "overlays": [ + {"texture": "CliffMediumType13", "frequency": 0.2}, + {"texture": "GrassMediumType23", "frequency": 0.3}]} + }, + "snow": { + "base": {"texture": "SnowLargeType1", "overlays": [ + {"texture": "SnowType5", "frequency": 0.05}, + {"texture": "SnowType6", "frequency": 0.1}]}, + "cliff": {"texture": "CliffMediumType17bsnowy", "overlays": [{"texture": "CliffMediumType17b", "frequency": 0.23}]} + }, + "woodland": { + "base": {"texture": "GrassMediumType13Light", "overlays": [ + {"texture": "GrassMediumType13", "frequency": 0.05}, + {"texture": "GrassMediumType24", "frequency": 0.1}]}, + "cliff": {"texture": "CliffMediumType16", "overlays": [ + {"texture": "CliffMediumType1", "frequency": 0.3}, + {"texture": "CliffMediumType15", "frequency": 0.23}]} + }, + "rocky_island": { + "base": {"texture": "GrassMediumType7", "overlays": [ + {"texture": "Grass02", "frequency": 0.05}, + {"texture": "GrassMediumType30", "frequency": 0.07}, + {"texture": "GrassMediumType10", "frequency": 0.1}]}, + "cliff": {"texture": "CliffMediumType3", "overlays": [{"texture": "RocksMediumType1Snow2", "frequency": 0.15}]} + }, + "sand_cliffs": { + "base": {"texture": "DirtMediumType11", "overlays": [ + {"texture": "GrassMediumType23brown", "frequency": 0.05}, + {"texture": "RocksMediumType1", "frequency": 0.1}, + {"texture": "RocksMediumType1Snow2", "frequency": 0.07}]}, + "cliff": {"texture": "CliffMediumType3"} + }, + "desert": { + "base": {"texture": "SandMediumType13", "overlays": [ + {"texture": "SandMediumType13b", "frequency": 0.1}, + {"texture": "SandMediumType13c", "frequency": 0.05}, + {"texture": "SandMediumType13d", "frequency": 0.05}, + {"texture": "SandMediumType13grassy4", "frequency": 0.05}, + {"texture": "SandLargeType3Light", "frequency": 0.05}, + {"texture": "SandMediumType6Light", "frequency": 0.05}]}, + "cliff": {"texture": "CliffLargeType1", "overlays": [ + {"texture": "CliffMediumType1", "frequency": 0.07}, + {"texture": "CliffMediumType16", "frequency": 0.05}, + {"texture": "CliffMediumType15", "frequency": 0.05}]}, + "water": {"texture": "SandMediumType9blue", "overlays": [ + {"texture": "SandMediumType9blue2", "frequency": 0.07}, + {"texture": "SandMediumType9blue3", "frequency": 0.04}]} + } +} diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index 80161264bb8..4dc34ef230b 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json import os from typing import Any, Literal @@ -270,6 +271,120 @@ def terrain_sample(x: float, y: float) -> dict: return wb.call("terrain.sample", x=x, y=y) +# -------------------------------------------------------------------------------------------------- +# Procedural terrain +# -------------------------------------------------------------------------------------------------- + +_PRESET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "presets") + + +def _load_presets(name: str) -> dict[str, Any]: + with open(os.path.join(_PRESET_DIR, name), encoding="utf-8") as f: + data = json.load(f) + return {k: v for k, v in data.items() if not k.startswith("_")} + + +def _preset(kind: str, name: str) -> dict[str, Any]: + presets = _load_presets(kind) + if name not in presets: + raise ToolError(f"unknown preset '{name}'; available: {', '.join(sorted(presets))}") + return presets[name] + + +@mcp.tool() +def terrain_list_presets() -> dict: + """Terrain layouts (for terrain_generate) and sceneries (for terrain_auto_texture), with their settings.""" + return {"layouts": _load_presets("layouts.json"), "sceneries": _load_presets("sceneries.json")} + + +@mcp.tool() +def terrain_generate(preset: str | None = "default", layers: list[dict[str, Any]] | None = None, seed: int = 1, + mode: Literal["set", "add"] = "set", base_height: float | None = None, + shape: Shape | 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, feather: float = 0, + protect: list[dict[str, Any]] | None = None) -> dict: + """Generates terrain heights from layered Perlin noise (Genesis TerrainGenerator). + Each layer adds clamp(noise, 0..1) * height raw units; noise has frequency (per heightmap vertex), + octaves, persistence (roughness), amplitude, and optional ridged (sharp crests) or signed (-1..1). + preset loads a layout from terrain_list_presets (default, rolling, mountains, flat_bumpy); passing + layers overrides it. mode=set starts from base_height, mode=add adds onto the current terrain. + Limit to a circle/rect with feather, and keep spots unchanged with + protect=[{"x":..,"y":..,"radius":..,"feather":..}] (e.g. base locations; flatten them afterwards). + Different seeds give different terrain. One undo step.""" + if layers is None: + if preset is None: + raise ToolError("pass a preset or layers") + layout = _preset("layouts.json", preset) + layers = layout["layers"] + if base_height is None: + base_height = layout.get("base_height") + return wb.call("terrain.generate", layers=layers, seed=seed, mode=mode, base_height=base_height, + feather=feather, protect=protect, **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + +@mcp.tool() +def terrain_limit_slope(max_step: float = 15, iterations: int = 20, shape: Shape | 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) -> dict: + """Pulls too-steep neighbouring vertices together until no height step exceeds max_step raw units + (Genesis TerrainSmoother). Impassable cliff flags follow the resulting slopes automatically. + Run it after terrain_generate to turn noise spikes into walkable slopes. One undo step.""" + return wb.call("terrain.limit_slope", max_step=max_step, iterations=iterations, + **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + +@mcp.tool() +def terrain_auto_texture(scenery: str | None = "highlands", base: dict[str, Any] | None = None, + cliff: dict[str, Any] | None = None, water: dict[str, Any] | None = None, + cliff_slope: float | None = None, water_below: float | None = None, + seed: int = 1, blend: bool = True, edge_texture: str | None = None, + shape: Shape | 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) -> dict: + """Textures the terrain automatically (Genesis TextureGenerator). Each cell is classified as + cliff (height difference across the cell >= cliff_slope raw, default 12, or impassable), + water (below water_below raw height, or under a water polygon when water_below is omitted), + or ground. Each class has a base texture plus overlays scattered by noise: + {"texture": .., "overlays": [{"texture": .., "frequency": 0.07, "threshold": 0.5}]}. + scenery loads a preset (highlands, fall, snow, woodland, rocky_island, sand_cliffs, desert); + base/cliff/water override its parts. Overlays, cliffs and water are then blended into their + surroundings (blend=false skips that). Fails up front if the map has no room for a texture. + One undo step.""" + preset = _preset("sceneries.json", scenery) if scenery else {} + base = base or preset.get("base") + cliff = cliff or preset.get("cliff") + water = water or preset.get("water") + if base is None: + raise ToolError("pass a scenery or a base texture") + if cliff is not None and cliff_slope is not None: + cliff = {**cliff, "slope": cliff_slope} + if water is not None and water_below is not None: + water = {**water, "below": water_below} + return wb.call("terrain.auto_texture", base=base, cliff=cliff, water=water, seed=seed, blend=blend, + edge_texture=edge_texture, **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + +@mcp.tool() +def terrain_blend_all(textures: list[str] | None = None, edge_texture: str | None = None, + shape: Shape | 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) -> dict: + """Auto-blends the edges of every region of the given textures (default: all textures except the + most common one, taken as the ground) into their surroundings. One undo step.""" + return wb.call("terrain.blend_all", textures=textures, edge_texture=edge_texture, + **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + +@mcp.tool() +def terrain_remove_blends(shape: Shape | 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) -> dict: + """Removes texture blends (in an area or the whole map), leaving hard texture edges. One undo step.""" + return wb.call("terrain.remove_blends", **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + # -------------------------------------------------------------------------------------------------- # Terrain images # -------------------------------------------------------------------------------------------------- From 14fc57adfdcbc22c8f2746ae31c6729d84c88b4a Mon Sep 17 00:00:00 2001 From: pWn3d Date: Thu, 1 Oct 2026 06:52:25 +0200 Subject: [PATCH 06/30] test(worldbuilder): Covers MCP terrain generation in the smoke test Adds generate, limit_slope, auto_texture and the blend helpers to the smoke test, undoing exactly the steps that changed the map, and documents the procedural terrain tools and the single-client bridge in the README. Co-Authored-By: Claude Opus 5.5 --- scripts/worldbuilder-mcp/README.md | 9 +++++++++ scripts/worldbuilder-mcp/smoke_test.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index ab6701740fb..67f427b76de 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -44,6 +44,9 @@ on its UI thread and records each edit as a normal undo step. uv run --directory scripts/worldbuilder-mcp smoke_test.py ``` +The bridge serves one client at a time, so run this before an MCP client has connected to +WorldBuilder (or with the MCP server stopped). Otherwise it waits for the connection. + This creates a new map and discards unsaved changes in the open one. It then exercises every command area, saves the map to a temp folder, reopens it and verifies it. @@ -67,6 +70,12 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - Player numbers in names and owners are renumbered for each copy. - Cliff texture mapping is reset on copied cells. - The mirroring approach follows the Genesis map tools (The CWC Team, Apache-2.0). +- **Procedural terrain.** A typical sequence is `terrain_generate`, then `terrain_limit_slope`, then `terrain_auto_texture`. + - `terrain_generate` builds heights from layered, seeded Perlin noise. It can protect spots such as base locations. + - `terrain_limit_slope` turns spikes into walkable slopes. + - `terrain_auto_texture` textures cells as ground, cliff or water, scatters noise overlays and blends the edges. + - `terrain_blend_all` and `terrain_remove_blends` are bulk blending helpers. + - Layouts and sceneries are JSON presets in `presets/` (`terrain_list_presets`), taken from Genesis. See `NOTICE`. - **Terrain images.** Heightmap and mask PNGs have north at the top. - 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. diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 8a662689c7d..7d159e1233e 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -70,6 +70,23 @@ def main() -> int: wb.call("edit.undo", count=2) assert len(wb.call("waypoints.list")["waypoints"]) == before_wp + # Procedural terrain: generate, limit slopes, auto texture, then undo exactly what changed. + steps = 0 + gen = wb.call("terrain.generate", seed=5, base_height=30, layers=[{"height": 60, "frequency": 0.03, "octaves": 3}]) + assert gen["changed"], gen + steps += 1 + if wb.call("terrain.limit_slope", max_step=6)["changed"]: + steps += 1 + tex = wb.call("terrain.auto_texture", seed=5, base={"texture": textures[0]["name"]}, + cliff={"texture": textures[1]["name"], "slope": 5}) + assert tex["cells_ground"] > 0, tex + steps += 1 + wb.call("terrain.blend_all") + wb.call("terrain.remove_blends") + steps += 2 + wb.call("edit.undo", count=steps) + assert wb.call("terrain.sample", x=300, y=300)["height_raw"] == 60, "undo did not restore the terrain" + # Heightmap image round trip must be lossless. out_png = os.path.join(tempfile.mkdtemp(prefix="wb_mcp_"), "height.png") wb.call("terrain.export_heightmap", path=out_png) From a16b6c8d803d6e39ccd1a58d39e60ec309032c11 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Thu, 1 Oct 2026 06:59:13 +0200 Subject: [PATCH 07/30] feat(worldbuilder): Adds MCP skirmish AI setup and check commands ai.skirmish_setup creates, for every Player__Start waypoint, the data the skirmish AI looks up by name: InnerPerimeter/OuterPerimeter areas and three approach paths into the base labeled Center, Flank and Backdoor (the number is the defending player, see doTeamFollowSkirmishApproachPath and buildAIBaseDefenseStructure), plus a CombatZone area. Waypoints stay inside the map and are moved off cliffs and water. Existing data, including unnumbered labels the AI ignores, is replaced, all in one undo step. ai.skirmish_check reports per start which areas and paths exist and lists what is missing. The waypoint links undoable moves to a shared header. The idea follows the SkirmishAIGenerator of Genesis (The CWC Team). Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 2 + .../WorldBuilder/include/mcp/McpCommands.h | 1 + .../WorldBuilder/include/mcp/McpUndoables.h | 52 ++ .../WorldBuilder/src/mcp/McpCmdSkirmish.cpp | 456 ++++++++++++++++++ .../WorldBuilder/src/mcp/McpCmdSymmetry.cpp | 27 +- .../WorldBuilder/src/mcp/McpCommands.cpp | 1 + scripts/worldbuilder-mcp/README.md | 3 + scripts/worldbuilder-mcp/server.py | 34 ++ scripts/worldbuilder-mcp/smoke_test.py | 9 + 9 files changed, 560 insertions(+), 25 deletions(-) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index dd34d08c5cb..e793ead6998 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -199,12 +199,14 @@ set(WORLDBUILDER_SRC "include/mcp/McpJson.h" "include/mcp/McpNoise.h" "include/mcp/McpShape.h" + "include/mcp/McpUndoables.h" "include/mcp/McpScreenshot.h" "include/mcp/McpServer.h" "src/mcp/McpCmdGenerate.cpp" "src/mcp/McpCmdImage.cpp" "src/mcp/McpCmdMap.cpp" "src/mcp/McpCmdObjects.cpp" + "src/mcp/McpCmdSkirmish.cpp" "src/mcp/McpCmdSymmetry.cpp" "src/mcp/McpCmdTerrain.cpp" "src/mcp/McpCmdWaypoints.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index 1d77917b09c..cb313ef21dd 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -51,6 +51,7 @@ void mcpRegisterWaypointCommands(); void mcpRegisterImageCommands(); void mcpRegisterSymmetryCommands(); void mcpRegisterGenerateCommands(); +void mcpRegisterSkirmishCommands(); //------------------------------------------------------------------------------------------------- // Helpers diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h new file mode 100644 index 00000000000..8893cbe8f1a --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h @@ -0,0 +1,52 @@ +/* +** 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 . +*/ + +// McpUndoables.h +// Undo steps shared by several MCP bridge commands. + +#pragma once + +#include "CUndoable.h" +#include "WorldBuilderDoc.h" + +#include +#include + +/// Adds and removes waypoint links, which WorldBuilder itself does not record for undo. +class McpWaypointLinksUndoable : public Undoable +{ +public: + typedef std::pair Link; + McpWaypointLinksUndoable(CWorldBuilderDoc *doc) : m_doc(doc) {} + + std::vector added, removed; + + virtual void Do() override + { + for (size_t i = 0; i < removed.size(); i++) m_doc->removeWaypointLink(removed[i].first, removed[i].second); + for (size_t i = 0; i < added.size(); i++) m_doc->addWaypointLink(added[i].first, added[i].second); + } + virtual void Undo() override + { + for (size_t i = 0; i < added.size(); i++) m_doc->removeWaypointLink(added[i].first, added[i].second); + for (size_t i = 0; i < removed.size(); i++) m_doc->addWaypointLink(removed[i].first, removed[i].second); + } + +private: + CWorldBuilderDoc *m_doc; +}; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp new file mode 100644 index 00000000000..9843be924e1 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp @@ -0,0 +1,456 @@ +/* +** 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 . +*/ + +// McpCmdSkirmish.cpp +// MCP bridge commands that create and check the map data the skirmish AI looks up by name +// (idea from the SkirmishAIGenerator of Genesis, The CWC Team). For the player starting at +// Player__Start the engine uses: +// - areas InnerPerimeter and OuterPerimeter (ScriptEngine::getQualifiedTriggerAreaByName), +// - waypoint paths labeled Center, Flank and Backdoor: approach paths that lead INTO +// base N. Attackers join at the closest waypoint and follow the links +// (ScriptActions::doTeamFollowSkirmishApproachPath); the defender places base defenses toward +// its own paths (AISkirmishPlayer::buildAIBaseDefenseStructure). + +#include "StdAfx.h" +#include "mcp/McpCommands.h" +#include "mcp/McpUndoables.h" + +#include "PointerTool.h" +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/PolygonTrigger.h" +#include "GameLogic/Scripts.h" + +#include +#include +#include +#include + +namespace +{ + +const char *const COMBAT_ZONE_NAME = "CombatZone"; +const char *const PATH_LABELS[3] = { SKIRMISH_CENTER, SKIRMISH_FLANK, SKIRMISH_BACKDOOR }; + +struct Start +{ + Int number; + double x, y; +}; + +struct Vec2 +{ + double x, y; +}; + +bool startBefore(const Start &a, const Start &b) +{ + return a.number < b.number; +} + +std::vector findStarts() +{ + std::vector starts; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (!obj->isWaypoint()) continue; + int number = 0; + int consumed = 0; + const AsciiString wayName = obj->getWaypointName(); + // %n is only reached when the whole pattern matched. + if (sscanf(wayName.str(), "Player_%d_Start%n", &number, &consumed) == 1 && consumed == wayName.getLength() + && number >= 1 && number <= MAX_PLAYER_COUNT) { + Start s; + s.number = number; + s.x = obj->getLocation()->x; + s.y = obj->getLocation()->y; + starts.push_back(s); + } + } + std::sort(starts.begin(), starts.end(), startBefore); + return starts; +} + +/// Splits "Center2" into the skirmish label and its number. Returns false for other labels. +bool parsePathLabel(const AsciiString &label, Int *which, Int *number) +{ + for (Int i = 0; i < 3; i++) { + const size_t len = strlen(PATH_LABELS[i]); + if (_strnicmp(label.str(), PATH_LABELS[i], len) == 0) { + const char *rest = label.str() + len; + *which = i; + *number = *rest ? atoi(rest) : 0; + for (const char *p = rest; *p; p++) { + if (*p < '0' || *p > '9') return false; + } + return true; + } + } + return false; +} + +/// True if any of the waypoint's three path labels is a skirmish approach label. +bool getSkirmishLabel(MapObject *waypoint, Int *which, Int *number) +{ + const NameKeyType keys[3] = { TheKey_waypointPathLabel1, TheKey_waypointPathLabel2, TheKey_waypointPathLabel3 }; + for (Int i = 0; i < 3; i++) { + Bool exists = false; + AsciiString label = waypoint->getProperties()->getAsciiString(keys[i], &exists); + if (exists && !label.isEmpty() && parsePathLabel(label, which, number)) { + return true; + } + } + return false; +} + +struct MapBounds +{ + WorldHeightMapEdit *map; + double maxX, maxY; + + void init(WorldHeightMapEdit *m) + { + map = m; + maxX = (m->getXExtent() - 1 - 2 * m->getBorderSize()) * MAP_XY_FACTOR; + maxY = (m->getYExtent() - 1 - 2 * m->getBorderSize()) * MAP_XY_FACTOR; + } + void clamp(Vec2 *p, double margin) const + { + if (p->x < margin) p->x = margin; + if (p->y < margin) p->y = margin; + if (p->x > maxX - margin) p->x = maxX - margin; + if (p->y > maxY - margin) p->y = maxY - margin; + } + bool isPassable(const Vec2 &p) const + { + const Int cx = (Int)floor(p.x / MAP_XY_FACTOR) + map->getBorderSize(); + const Int cy = (Int)floor(p.y / MAP_XY_FACTOR) + map->getBorderSize(); + if (cx < 0 || cy < 0 || cx >= map->getXExtent() - 1 || cy >= map->getYExtent() - 1) return false; + return !map->getCliffState(cx, cy) && !mcpIsCellUnderWater(map, cx, cy); + } + /// Moves a point off cliffs and water to the nearest passable spot within reach. + bool nudgeToPassable(Vec2 *p) const + { + if (isPassable(*p)) return false; + for (double r = 20; r <= 300; r += 20) { + for (Int a = 0; a < 16; a++) { + Vec2 q = { p->x + r * cos(a * PI / 8), p->y + r * sin(a * PI / 8) }; + if (q.x < 10 || q.y < 10 || q.x > maxX - 10 || q.y > maxY - 10) continue; + if (isPassable(q)) { + *p = q; + return true; + } + } + } + return false; + } + Int groundZ(const Vec2 &p) const + { + const Int ix = (Int)floor(p.x / MAP_XY_FACTOR + 0.5) + map->getBorderSize(); + const Int iy = (Int)floor(p.y / MAP_XY_FACTOR + 0.5) + map->getBorderSize(); + return (Int)floor(map->getHeight(ix, iy) * MAP_HEIGHT_SCALE + 0.5); + } +}; + +/// A roughly circular trigger area, clipped to the playable area. +PolygonTrigger *makeCircleArea(const AsciiString &name, double cx, double cy, double radius, const MapBounds &bounds) +{ + const Int numPoints = 12; + PolygonTrigger *trig = newInstance(PolygonTrigger)(numPoints); + for (Int i = 0; i < numPoints; i++) { + Vec2 p = { cx + radius * cos(i * 2 * PI / numPoints), cy + radius * sin(i * 2 * PI / numPoints) }; + bounds.clamp(&p, 0); + ICoord3D pt; + pt.x = (Int)floor(p.x + 0.5); + pt.y = (Int)floor(p.y + 0.5); + pt.z = bounds.groundZ(p); + trig->addPoint(pt); + } + trig->setTriggerName(name); + return trig; +} + +PolygonTrigger *findArea(const AsciiString &name) +{ + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + if (trig->getTriggerName().compareNoCase(name) == 0) return trig; + } + return nullptr; +} + +Vec2 rotate(const Vec2 &v, double degrees) +{ + const double a = degrees * PI / 180.0; + Vec2 r = { v.x * cos(a) - v.y * sin(a), v.x * sin(a) + v.y * cos(a) }; + return r; +} + +//------------------------------------------------------------------------------------------------- +// ai.skirmish_check +//------------------------------------------------------------------------------------------------- + +McpJson cmdSkirmishCheck(const McpJson &) +{ + mcpDoc(); + const std::vector starts = findStarts(); + McpJson players = McpJson::makeArray(); + McpJson problems = McpJson::makeArray(); + if (starts.size() < 2) { + problems.push(McpJson("fewer than two Player__Start waypoints")); + } + + // Count labeled waypoints per label and player; collect labels without a valid number. + Int counts[3][MAX_PLAYER_COUNT + 1]; + memset(counts, 0, sizeof(counts)); + std::set badLabels; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (!obj->isWaypoint()) continue; + Int which, number; + if (!getSkirmishLabel(obj, &which, &number)) continue; + if (number >= 1 && number <= MAX_PLAYER_COUNT) { + counts[which][number]++; + } else { + badLabels.insert(std::string(PATH_LABELS[which]) + (number ? " (bad number)" : " (no player number)")); + } + } + for (std::set::const_iterator it = badLabels.begin(); it != badLabels.end(); ++it) { + problems.push(McpJson("path label " + *it + ": the AI only uses labels like Center1, Flank2, Backdoor1")); + } + + for (size_t i = 0; i < starts.size(); i++) { + const Int n = starts[i].number; + AsciiString inner, outer; + inner.format("%s%d", INNER_PERIMETER, n); + outer.format("%s%d", OUTER_PERIMETER, n); + McpJson p = McpJson::makeObject(); + p.set("player", n).set("start_x", starts[i].x).set("start_y", starts[i].y); + p.set("inner_perimeter", findArea(inner) != nullptr).set("outer_perimeter", findArea(outer) != nullptr); + for (Int l = 0; l < 3; l++) { + static const char *const countKeys[3] = { "center_waypoints", "flank_waypoints", "backdoor_waypoints" }; + p.set(countKeys[l], counts[l][n]); + if (counts[l][n] == 0) { + AsciiString msg; + msg.format("player %d has no %s%d approach path", n, PATH_LABELS[l], n); + problems.push(McpJson(msg.str())); + } + } + if (!findArea(inner)) { AsciiString m; m.format("missing area %s", inner.str()); problems.push(McpJson(m.str())); } + if (!findArea(outer)) { AsciiString m; m.format("missing area %s", outer.str()); problems.push(McpJson(m.str())); } + players.push(p); + } + McpJson j = McpJson::makeObject(); + j.set("players", players).set("combat_zone", findArea(COMBAT_ZONE_NAME) != nullptr); + j.set("problems", problems).set("ready", problems.size() == 0); + return j; +} + +//------------------------------------------------------------------------------------------------- +// ai.skirmish_setup +//------------------------------------------------------------------------------------------------- + +McpJson cmdSkirmishSetup(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + MapBounds bounds; + bounds.init(mcpHeightMap()); + const std::vector starts = findStarts(); + if (starts.size() < 2) { + mcpFail("need at least two start waypoints named Player__Start (found %d)", (int)starts.size()); + } + const double innerRadius = mcpArgNumber(args, "inner_radius", 350); + const double outerRadius = mcpArgNumber(args, "outer_radius", 600); + const double flankAngle = mcpArgNumber(args, "flank_angle", 70); + const double backdoorAngle = mcpArgNumber(args, "backdoor_angle", 70); + const Int pathPoints = mcpArgInt(args, "path_points", 5); + const bool replace = mcpArgBool(args, "replace", true); + const bool makeCombatZone = mcpArgBool(args, "combat_zone", true); + if (innerRadius < 50 || outerRadius <= innerRadius) mcpFail("need 50 <= inner_radius < outer_radius"); + if (pathPoints < 2 || pathPoints > 20) mcpFail("path_points must be 2..20"); + + std::set numbers; + for (size_t i = 0; i < starts.size(); i++) { + if (!numbers.insert(starts[i].number).second) mcpFail("duplicate start waypoint Player_%d_Start", starts[i].number); + } + + // Existing skirmish data for these players is replaced so the command can be re-run. + std::vector oldAreas; + std::vector oldWaypoints; + std::set oldWaypointIds; + std::set usedNames; + for (size_t i = 0; i < starts.size(); i++) { + AsciiString name; + name.format("%s%d", INNER_PERIMETER, starts[i].number); + if (PolygonTrigger *t = findArea(name)) oldAreas.push_back(t); + name.format("%s%d", OUTER_PERIMETER, starts[i].number); + if (PolygonTrigger *t = findArea(name)) oldAreas.push_back(t); + } + if (makeCombatZone) { + if (PolygonTrigger *t = findArea(COMBAT_ZONE_NAME)) oldAreas.push_back(t); + } + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (!obj->isWaypoint()) continue; + Int which, number; + // Unnumbered labels are leftovers the AI never uses; replace them too. + if (getSkirmishLabel(obj, &which, &number) && (number == 0 || numbers.count(number))) { + oldWaypoints.push_back(obj); + oldWaypointIds.insert(obj->getWaypointID()); + } else { + usedNames.insert(obj->getWaypointName().str()); + } + } + if (!replace && (!oldAreas.empty() || !oldWaypoints.empty())) { + mcpFail("the map already has %d skirmish areas and %d approach path waypoints; pass replace=true to rebuild them", + (int)oldAreas.size(), (int)oldWaypoints.size()); + } + + // Build the new areas and paths. + std::vector newAreas; + McpWaypointLinksUndoable *links = new McpWaypointLinksUndoable(doc); + MapObject *head = nullptr, *tail = nullptr; + McpJson players = McpJson::makeArray(); + Int nudged = 0; + Vec2 centroid = { 0, 0 }; + for (size_t i = 0; i < starts.size(); i++) { + centroid.x += starts[i].x / starts.size(); + centroid.y += starts[i].y / starts.size(); + } + + for (size_t i = 0; i < starts.size(); i++) { + const Start &s = starts[i]; + AsciiString name; + name.format("%s%d", INNER_PERIMETER, s.number); + newAreas.push_back(makeCircleArea(name, s.x, s.y, innerRadius, bounds)); + name.format("%s%d", OUTER_PERIMETER, s.number); + newAreas.push_back(makeCircleArea(name, s.x, s.y, outerRadius, bounds)); + + // Approach paths start near the other players and lead into this base. + Vec2 others = { 0, 0 }; + for (size_t k = 0; k < starts.size(); k++) { + if (k == i) continue; + others.x += starts[k].x / (starts.size() - 1); + others.y += starts[k].y / (starts.size() - 1); + } + Vec2 dir = { others.x - s.x, others.y - s.y }; + double dist = sqrt(dir.x * dir.x + dir.y * dir.y); + if (dist < 1) { + dir.x = bounds.maxX / 2 - s.x; + dir.y = bounds.maxY / 2 - s.y; + dist = sqrt(dir.x * dir.x + dir.y * dir.y); + } + if (dist < 1) { dir.x = 1; dir.y = 0; dist = 1; } + dir.x /= dist; + dir.y /= dist; + const double startDist = dist - outerRadius > dist * 0.5 ? dist - outerRadius : dist * 0.5; + const Vec2 origin = { s.x + dir.x * startDist, s.y + dir.y * startDist }; + + McpJson paths = McpJson::makeObject(); + const double angles[3] = { 0, flankAngle, -backdoorAngle }; + for (Int l = 0; l < 3; l++) { + AsciiString label; + label.format("%s%d", PATH_LABELS[l], s.number); + const Vec2 side = rotate(dir, angles[l]); + const Vec2 end = { s.x + side.x * innerRadius * 0.8, s.y + side.y * innerRadius * 0.8 }; + // Quadratic curve: flank and backdoor swing out and come in from their own side. + const Vec2 control = { s.x + side.x * dist * 0.7, s.y + side.y * dist * 0.7 }; + McpJson names = McpJson::makeArray(); + Int prevId = 0; + for (Int p = 0; p < pathPoints; p++) { + const double t = (double)p / (pathPoints - 1); + Vec2 pt = { + (1 - t) * (1 - t) * origin.x + 2 * (1 - t) * t * control.x + t * t * end.x, + (1 - t) * (1 - t) * origin.y + 2 * (1 - t) * t * control.y + t * t * end.y }; + bounds.clamp(&pt, 40); + if (bounds.nudgeToPassable(&pt)) nudged++; + + Coord3D loc; + loc.x = (Real)pt.x; + loc.y = (Real)pt.y; + loc.z = 0; + MapObject *way = newInstance(MapObject)(loc, "*Waypoints/Waypoint", 0, 0, nullptr, nullptr); + const Int id = doc->getNextWaypointID(); + AsciiString wayName; + wayName.format("%s_%d", label.str(), p + 1); + for (Int suffix = 2; usedNames.count(wayName.str()); suffix++) { + wayName.format("%s_%d_%d", label.str(), p + 1, suffix); + } + usedNames.insert(wayName.str()); + way->setIsWaypoint(); + way->setWaypointID(id); + way->setWaypointName(wayName); + way->getProperties()->setAsciiString(TheKey_originalOwner, "team"); + way->getProperties()->setAsciiString(TheKey_waypointPathLabel1, label); + if (tail) tail->setNextMap(way); else head = way; + tail = way; + if (prevId) links->added.push_back(std::make_pair(prevId, id)); + prevId = id; + names.push(McpJson(wayName.str())); + } + paths.set(label.str(), names); + } + players.push(McpJson::makeObject().set("player", s.number).set("paths", paths)); + } + + if (makeCombatZone) { + double nearest = 1e30; + for (size_t i = 0; i < starts.size(); i++) { + const double d = sqrt((starts[i].x - centroid.x) * (starts[i].x - centroid.x) + (starts[i].y - centroid.y) * (starts[i].y - centroid.y)); + if (d < nearest) nearest = d; + } + double radius = nearest - outerRadius; + if (radius < 200) radius = nearest * 0.4; + if (radius < 100) radius = 100; + newAreas.push_back(makeCircleArea(COMBAT_ZONE_NAME, centroid.x, centroid.y, radius, bounds)); + } + + // Links of replaced waypoints go away with them. + for (Int i = 0; i < doc->getNumWaypointLinks(); i++) { + Int a, b; + doc->getWaypointLink(i, &a, &b); + if (oldWaypointIds.count(a) || oldWaypointIds.count(b)) links->removed.push_back(std::make_pair(a, b)); + } + + // One undo step; MultipleUndoable runs the last added step first. + MultipleUndoable *undo = new MultipleUndoable; + undo->addUndoable(links); + if (head) undo->addUndoable(new AddObjectUndoable(doc, head)); + for (size_t i = 0; i < newAreas.size(); i++) undo->addUndoable(new AddPolygonUndoable(newAreas[i])); + for (size_t i = 0; i < oldAreas.size(); i++) undo->addUndoable(new DeletePolygonUndoable(oldAreas[i])); + if (!oldWaypoints.empty()) { + PointerTool::clearSelection(); + for (size_t i = 0; i < oldWaypoints.size(); i++) oldWaypoints[i]->setSelected(true); + undo->addUndoable(new DeleteObjectUndoable(doc)); + PointerTool::clearSelection(); + } + mcpCommit(undo); + mcpResetObjectHandles(); + + McpJson j = McpJson::makeObject(); + j.set("players", players); + j.set("areas_created", (int)newAreas.size()).set("areas_replaced", (int)oldAreas.size()); + j.set("waypoints_created", (int)(starts.size() * 3 * pathPoints)).set("waypoints_replaced", (int)oldWaypoints.size()); + j.set("waypoints_moved_off_obstacles", nudged); + return j; +} + +} // namespace + +void mcpRegisterSkirmishCommands() +{ + mcpRegisterCommand("ai.skirmish_setup", cmdSkirmishSetup, "{inner_radius?=350, outer_radius?=600, flank_angle?=70, backdoor_angle?=70, path_points?=5, combat_zone?=true, replace?=true} Creates perimeters and Center/Flank/Backdoor approach paths for every Player__Start."); + mcpRegisterCommand("ai.skirmish_check", cmdSkirmishCheck, "Reports which skirmish AI areas and approach paths each start position has, and what is missing."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp index 02a8a2db4ac..a6c8fd3c939 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp @@ -26,6 +26,7 @@ #include "StdAfx.h" #include "mcp/McpCommands.h" +#include "mcp/McpUndoables.h" #include "CUndoable.h" #include "PointerTool.h" @@ -399,30 +400,6 @@ class PolygonPointsUndoable : public Undoable std::vector m_entries; }; -/// Adds and removes waypoint links, which WorldBuilder itself does not record for undo. -class WaypointLinksUndoable : public Undoable -{ -public: - typedef std::pair Link; - WaypointLinksUndoable(CWorldBuilderDoc *doc) : m_doc(doc) {} - - std::vector added, removed; - - virtual void Do() override - { - for (size_t i = 0; i < removed.size(); i++) m_doc->removeWaypointLink(removed[i].first, removed[i].second); - for (size_t i = 0; i < added.size(); i++) m_doc->addWaypointLink(added[i].first, added[i].second); - } - virtual void Undo() override - { - for (size_t i = 0; i < added.size(); i++) m_doc->removeWaypointLink(added[i].first, added[i].second); - for (size_t i = 0; i < removed.size(); i++) m_doc->addWaypointLink(removed[i].first, removed[i].second); - } - -private: - CWorldBuilderDoc *m_doc; -}; - //------------------------------------------------------------------------------------------------- // Renaming //------------------------------------------------------------------------------------------------- @@ -694,7 +671,7 @@ McpJson cmdSymmetrize(const McpJson &args) } // Waypoint links: drop links of deleted waypoints, recreate source links between the copies. - WaypointLinksUndoable *links = new WaypointLinksUndoable(doc); + McpWaypointLinksUndoable *links = new McpWaypointLinksUndoable(doc); for (Int i = 0; i < doc->getNumWaypointLinks(); i++) { Int a, b; doc->getWaypointLink(i, &a, &b); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index 13e23f6cfab..a08c4f986e8 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -82,6 +82,7 @@ void registerAllCommands() mcpRegisterImageCommands(); mcpRegisterSymmetryCommands(); mcpRegisterGenerateCommands(); + mcpRegisterSkirmishCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index 67f427b76de..7fcaee536f7 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -70,6 +70,9 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - Player numbers in names and owners are renumbered for each copy. - Cliff texture mapping is reset on copied cells. - The mirroring approach follows the Genesis map tools (The CWC Team, Apache-2.0). +- **Skirmish AI.** `ai_skirmish_setup` creates what the AI looks up by name for every `Player__Start`. `ai_skirmish_check` reports what is missing. + - Areas: `InnerPerimeter` and `OuterPerimeter`, plus a `CombatZone`. + - Paths: approach paths into each base, labelled `Center`, `Flank` and `Backdoor`. - **Procedural terrain.** A typical sequence is `terrain_generate`, then `terrain_limit_slope`, then `terrain_auto_texture`. - `terrain_generate` builds heights from layered, seeded Perlin noise. It can protect spots such as base locations. - `terrain_limit_slope` turns spikes into walkable slopes. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index 4dc34ef230b..6123279424c 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -31,6 +31,9 @@ valid names instead of guessing. - Object owners are team names from sides_list ("team" is neutral, "teamplayer0001" belongs to player0001). Add players with sides_add_player. +- Skirmish maps: add Player__Start waypoints, then run ai_skirmish_setup (and ai_skirmish_check). + The AI needs numbered names: InnerPerimeter1, OuterPerimeter1 and paths labeled Center1, Flank1, + Backdoor1 leading into base 1, and so on per player. Plain "Center" labels are ignored. - Every edit is one WorldBuilder undo step (edit_undo reverts it). WorldBuilder keeps only 15. - Use view_screenshot to look at the result. """ @@ -618,6 +621,37 @@ def roads_add(type: str, points: list[list[float]], return wb.call("roads.add", type=type, points=points, corners=corners) +# -------------------------------------------------------------------------------------------------- +# Skirmish AI +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def ai_skirmish_setup(inner_radius: float = 350, outer_radius: float = 600, flank_angle: float = 70, + backdoor_angle: float = 70, path_points: int = 5, combat_zone: bool = True, + replace: bool = True) -> dict: + """Creates everything the skirmish AI looks up by name, for every Player__Start waypoint: + - areas InnerPerimeter / OuterPerimeter (circles of the given radii around the start), + - three approach paths INTO base N, labeled Center, Flank and Backdoor (the number is + the defending player; attackers join at the nearest waypoint and follow the links into the + base). Flank/backdoor swing out by flank_angle/backdoor_angle degrees to either side. + Waypoints are kept inside the map and moved off cliffs and water. + - a CombatZone area between the bases (used by the mod's scripts). + replace=true (default) first removes existing perimeters, CombatZone and approach-path waypoints + (including unnumbered Center/Flank/Backdoor labels, which the AI ignores), so it can be re-run + after moving starts. Place the start waypoints first. One undo step; object ids are invalidated.""" + return wb.call("ai.skirmish_setup", inner_radius=inner_radius, outer_radius=outer_radius, + flank_angle=flank_angle, backdoor_angle=backdoor_angle, path_points=path_points, + combat_zone=combat_zone, replace=replace) + + +@mcp.tool() +def ai_skirmish_check() -> dict: + """Checks whether the map has what the skirmish AI needs: per start position the perimeter + areas and the number of waypoints on its Center/Flank/Backdoor approach paths, plus a list of + problems (missing areas or paths, path labels without a player number).""" + return wb.call("ai.skirmish_check") + + @mcp.tool() def worldbuilder_command(cmd: str, args: dict[str, Any] | None = None) -> Any: """Runs a raw bridge command (see list_commands) for anything not covered by the other tools.""" diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 7d159e1233e..389895a80cb 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -61,6 +61,15 @@ def main() -> int: if roads: wb.call("roads.add", type=roads[0], points=[[50, 500], [300, 500], [400, 700]]) + # Skirmish AI data: needs two starts, so add a second one first. + wb.call("waypoints.add", x=800, y=800, name="Player_2_Start") + assert not wb.call("ai.skirmish_check")["ready"] + setup = wb.call("ai.skirmish_setup", inner_radius=120, outer_radius=220) + assert setup["waypoints_created"] == 30, setup + check = wb.call("ai.skirmish_check") + assert check["ready"], check + wb.call("edit.undo", count=2) + # Symmetry: mirror the west half onto the east half, then undo. before_wp = len(wb.call("waypoints.list")["waypoints"]) sym = wb.call("map.symmetrize", mode="mirror_x", source="west") From e12f5bb22704f58c108484f400209b64442c9cb9 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Thu, 1 Oct 2026 07:03:50 +0200 Subject: [PATCH 08/30] feat(worldbuilder): Adds MCP objects.scatter for decoration Scatters weighted templates over the playable map or a circle/rect by count or density, with random rotation and a deterministic seed. It keeps a minimum spacing, stays clear of roads, of existing objects' footprints, of Player__Start positions, cliffs and water, and can be limited by slope, texture and named areas. An optional noise mask groups objects into groves. All objects are added in one undo step. The idea follows the TreeGenerator and RockGenerator of Genesis (The CWC Team). Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 1 + .../WorldBuilder/include/mcp/McpCommands.h | 1 + .../WorldBuilder/src/mcp/McpCmdScatter.cpp | 342 ++++++++++++++++++ .../WorldBuilder/src/mcp/McpCommands.cpp | 1 + scripts/worldbuilder-mcp/README.md | 4 + scripts/worldbuilder-mcp/server.py | 29 ++ scripts/worldbuilder-mcp/smoke_test.py | 9 + 7 files changed, 387 insertions(+) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index e793ead6998..99e3ec3b3a6 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -206,6 +206,7 @@ set(WORLDBUILDER_SRC "src/mcp/McpCmdImage.cpp" "src/mcp/McpCmdMap.cpp" "src/mcp/McpCmdObjects.cpp" + "src/mcp/McpCmdScatter.cpp" "src/mcp/McpCmdSkirmish.cpp" "src/mcp/McpCmdSymmetry.cpp" "src/mcp/McpCmdTerrain.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index cb313ef21dd..26c50590d5d 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -52,6 +52,7 @@ void mcpRegisterImageCommands(); void mcpRegisterSymmetryCommands(); void mcpRegisterGenerateCommands(); void mcpRegisterSkirmishCommands(); +void mcpRegisterScatterCommands(); //------------------------------------------------------------------------------------------------- // Helpers diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp new file mode 100644 index 00000000000..7c67a4014cd --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp @@ -0,0 +1,342 @@ +/* +** 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 . +*/ + +// McpCmdScatter.cpp +// MCP bridge command that scatters decoration objects (trees, rocks, props) over an area while +// keeping clear of cliffs, water, roads, existing objects and start positions. The idea follows +// the TreeGenerator and RockGenerator of Genesis (The CWC Team). + +#include "StdAfx.h" +#include "mcp/McpCommands.h" +#include "mcp/McpNoise.h" +#include "mcp/McpShape.h" + +#include "CUndoable.h" +#include "PointerTool.h" +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" +#include "Common/ThingFactory.h" +#include "Common/ThingSort.h" +#include "Common/ThingTemplate.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/PolygonTrigger.h" + +#include +#include +#include + +namespace +{ + +struct Blocker +{ + double x, y, radius; +}; + +struct RoadSegment +{ + double x0, y0, x1, y1; +}; + +double distanceToSegment(double px, double py, const RoadSegment &s) +{ + const double dx = s.x1 - s.x0, dy = s.y1 - s.y0; + const double len2 = dx * dx + dy * dy; + double t = len2 > 0 ? ((px - s.x0) * dx + (py - s.y0) * dy) / len2 : 0; + if (t < 0) t = 0; + if (t > 1) t = 1; + const double cx = s.x0 + t * dx - px, cy = s.y0 + t * dy - py; + return sqrt(cx * cx + cy * cy); +} + +/// Small deterministic generator so the same seed gives the same scatter. +class Random +{ +public: + explicit Random(unsigned int seed) : m_state(seed * 2654435761u + 0x9E3779B9u) { if (m_state == 0) m_state = 1; } + double next() ///< 0..1 + { + m_state ^= m_state << 13; + m_state ^= m_state >> 17; + m_state ^= m_state << 5; + return (m_state & 0xFFFFFF) / (double)0x1000000; + } +private: + unsigned int m_state; +}; + +struct WeightedTemplate +{ + const ThingTemplate *tt; + double weight; +}; + +std::vector parseAreas(const McpJson &args, const char *key) +{ + std::vector areas; + const McpJson &list = args.get(key); + if (list.isNull()) return areas; + if (!list.isArray()) mcpFail("%s must be a list of area names", key); + for (size_t i = 0; i < list.size(); i++) { + if (!list.at(i).isString()) mcpFail("%s must be a list of area names", key); + PolygonTrigger *found = nullptr; + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + if (trig->getTriggerName().compareNoCase(list.at(i).asString().c_str()) == 0) found = trig; + } + if (!found) mcpFail("unknown area '%s' (see polygons.list)", list.at(i).asString().c_str()); + areas.push_back(found); + } + return areas; +} + +bool insideAny(const std::vector &areas, double x, double y) +{ + ICoord3D pt; + pt.x = (Int)floor(x + 0.5); + pt.y = (Int)floor(y + 0.5); + pt.z = 0; + for (size_t i = 0; i < areas.size(); i++) { + if (areas[i]->pointInTrigger(pt)) return true; + } + return false; +} + +McpJson cmdScatter(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + WorldHeightMapEdit *map = mcpHeightMap(); + const Int border = map->getBorderSize(); + const double maxX = (map->getXExtent() - 1 - 2 * border) * MAP_XY_FACTOR; + const double maxY = (map->getYExtent() - 1 - 2 * border) * MAP_XY_FACTOR; + + // Templates, optionally weighted. + std::vector templates; + double totalWeight = 0; + const McpJson &list = mcpArgArray(args, "templates"); + for (size_t i = 0; i < list.size(); i++) { + const McpJson &entry = list.at(i); + WeightedTemplate wt; + std::string name; + if (entry.isString()) { + name = entry.asString(); + wt.weight = 1; + } else if (entry.isObject()) { + name = mcpArgString(entry, "template"); + wt.weight = mcpArgNumber(entry, "weight", 1); + } else { + mcpFail("templates must be names or {template, weight} objects"); + } + wt.tt = TheThingFactory->findTemplate(AsciiString(name.c_str()), FALSE); + if (wt.tt == nullptr) mcpFail("unknown object template '%s' (see objects.list_templates)", name.c_str()); + if (wt.weight <= 0) mcpFail("weights must be > 0"); + totalWeight += wt.weight; + templates.push_back(wt); + } + if (templates.empty()) mcpFail("pass at least one template"); + + // Area: a shape, or the whole playable map. + const bool limited = args.has("x") || args.has("x0") || args.has("shape"); + McpShape shape; + double bx0 = 0, by0 = 0, bx1 = maxX, by1 = maxY, areaSize = maxX * maxY; + if (limited) { + shape.parse(args, true); + bx0 = shape.x0 < 0 ? 0 : shape.x0; + by0 = shape.y0 < 0 ? 0 : shape.y0; + bx1 = shape.x1 > maxX ? maxX : shape.x1; + by1 = shape.y1 > maxY ? maxY : shape.y1; + if (bx1 <= bx0 || by1 <= by0) mcpFail("the area is outside the playable map"); + areaSize = shape.circle ? PI * shape.radius * shape.radius : (bx1 - bx0) * (by1 - by0); + } + + const unsigned int seed = (unsigned int)mcpArgInt(args, "seed", 1); + const double density = mcpArgNumber(args, "density", 40); // objects per 1000 x 1000 world units + Int count = args.has("count") ? mcpArgInt(args, "count") : (Int)floor(areaSize / 1000000.0 * density + 0.5); + if (count < 1) count = 1; + if (count > 5000) mcpFail("at most 5000 objects per call (requested %d)", count); + const double minSpacing = mcpArgNumber(args, "min_spacing", 30); + const double roadClearance = mcpArgNumber(args, "road_clearance", 40); + const double objectClearance = mcpArgNumber(args, "object_clearance", 40); + const double startClearance = mcpArgNumber(args, "start_clearance", 300); + const double edgeMargin = mcpArgNumber(args, "edge_margin", 10); + const bool avoidCliffs = mcpArgBool(args, "avoid_cliffs", true); + const bool avoidWater = mcpArgBool(args, "avoid_water", true); + const double maxSlope = mcpArgNumber(args, "max_slope", -1); + const std::vector avoidAreas = parseAreas(args, "avoid_areas"); + const std::vector insideAreas = parseAreas(args, "inside_areas"); + + // Optional texture filter. + std::set onlyTextures; + if (args.has("only_textures")) { + const McpJson &tex = mcpArgArray(args, "only_textures"); + for (size_t i = 0; i < tex.size(); i++) { + McpJson one = McpJson::makeObject().set("texture", tex.at(i)); + onlyTextures.insert(mcpFindTextureClass(one, "texture")); + } + } + + // Optional clustering: only place where a noise field exceeds the threshold (groves, rock fields). + const McpJson &cluster = args.get("cluster"); + McpNoise noise(seed * 977u + 13u); + double clusterFrequency = 0, clusterThreshold = 0; + if (cluster.isObject()) { + clusterFrequency = mcpArgNumber(cluster, "frequency", 0.03); + clusterThreshold = mcpArgNumber(cluster, "threshold", 0.5); + } else if (!cluster.isNull()) { + mcpFail("cluster must be an object {frequency, threshold}"); + } + + // Things to keep away from. + std::vector blockers; // existing objects with their own radius + std::vector starts; + std::vector roads; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + const Coord3D *loc = obj->getLocation(); + if (obj->getFlag(FLAG_ROAD_POINT1) || obj->getFlag(FLAG_BRIDGE_POINT1)) { + MapObject *second = obj->getNext(); + if (second) { + RoadSegment seg = { loc->x, loc->y, second->getLocation()->x, second->getLocation()->y }; + roads.push_back(seg); + obj = second; + } + continue; + } + if (obj->getFlags() & (FLAG_ROAD_FLAGS | FLAG_BRIDGE_FLAGS)) continue; + if (obj->isWaypoint()) { + int number = 0, consumed = 0; + const AsciiString name = obj->getWaypointName(); + if (sscanf(name.str(), "Player_%d_Start%n", &number, &consumed) == 1 && consumed == name.getLength()) { + Blocker b = { loc->x, loc->y, startClearance }; + starts.push_back(b); + } + continue; + } + const ThingTemplate *tt = obj->getThingTemplate(); + Blocker b = { loc->x, loc->y, minSpacing }; + // Decoration only needs the normal spacing; everything else keeps its footprint plus clearance free. + if (tt && tt->getEditorSorting() != ES_SHRUBBERY && tt->getEditorSorting() != ES_MISC_NATURAL) { + b.radius = tt->getTemplateGeometryInfo().getBoundingCircleRadius() + objectClearance; + } + blockers.push_back(b); + } + + Random rng(seed); + std::vector placed; + MapObject *head = nullptr, *tail = nullptr; + Int attempts = 0; + Int rejectedTerrain = 0, rejectedSpacing = 0, rejectedRoad = 0, rejectedArea = 0; + const Int maxAttempts = count * 40 + 200; + McpJson perTemplate = McpJson::makeObject(); + std::vector templateCounts(templates.size(), 0); + + while ((Int)placed.size() < count && attempts < maxAttempts) { + attempts++; + const double x = bx0 + rng.next() * (bx1 - bx0); + const double y = by0 + rng.next() * (by1 - by0); + const double roll = rng.next(); + const double pick = rng.next() * totalWeight; + const double angle = (rng.next() * 2 - 1) * PI; + + if (x < edgeMargin || y < edgeMargin || x > maxX - edgeMargin || y > maxY - edgeMargin) { rejectedArea++; continue; } + if (limited && shape.weight(x, y) < roll) { rejectedArea++; continue; } + if (!insideAreas.empty() && !insideAny(insideAreas, x, y)) { rejectedArea++; continue; } + if (insideAny(avoidAreas, x, y)) { rejectedArea++; continue; } + if (clusterFrequency > 0 && noise.fractal(x / MAP_XY_FACTOR, y / MAP_XY_FACTOR, clusterFrequency, 2, 0.5, 1.3, false) <= clusterThreshold) { + rejectedArea++; + continue; + } + + const Int cx = (Int)floor(x / MAP_XY_FACTOR) + border; + const Int cy = (Int)floor(y / MAP_XY_FACTOR) + border; + bool terrainOk = true; + if (avoidCliffs && map->getCliffState(cx, cy)) terrainOk = false; + if (terrainOk && avoidWater && mcpIsCellUnderWater(map, cx, cy)) terrainOk = false; + if (terrainOk && maxSlope >= 0) { + Int lo = 255, hi = 0; + for (Int dy = 0; dy <= 1; dy++) { + for (Int dx = 0; dx <= 1; dx++) { + const Int h = map->getHeight(cx + dx, cy + dy); + if (h < lo) lo = h; + if (h > hi) hi = h; + } + } + if (hi - lo > maxSlope) terrainOk = false; + } + if (terrainOk && !onlyTextures.empty() && !onlyTextures.count(map->getTextureClass(cx, cy, true))) terrainOk = false; + if (!terrainOk) { rejectedTerrain++; continue; } + + bool roadOk = true; + for (size_t i = 0; i < roads.size() && roadOk; i++) { + if (distanceToSegment(x, y, roads[i]) < roadClearance) roadOk = false; + } + if (!roadOk) { rejectedRoad++; continue; } + + bool spacingOk = true; + for (size_t i = 0; i < starts.size() && spacingOk; i++) { + if (hypot(starts[i].x - x, starts[i].y - y) < starts[i].radius) spacingOk = false; + } + for (size_t i = 0; i < blockers.size() && spacingOk; i++) { + if (hypot(blockers[i].x - x, blockers[i].y - y) < blockers[i].radius) spacingOk = false; + } + for (size_t i = 0; i < placed.size() && spacingOk; i++) { + if (hypot(placed[i].x - x, placed[i].y - y) < minSpacing) spacingOk = false; + } + if (!spacingOk) { rejectedSpacing++; continue; } + + size_t which = 0; + double acc = 0; + for (size_t i = 0; i < templates.size(); i++) { + acc += templates[i].weight; + which = i; + if (pick < acc) break; + } + Coord3D loc; + loc.x = (Real)x; + loc.y = (Real)y; + loc.z = 0; + MapObject *obj = newInstance(MapObject)(loc, templates[which].tt->getName(), (Real)angle, 0, nullptr, templates[which].tt); + obj->getProperties()->setAsciiString(TheKey_originalOwner, "team"); + if (tail) tail->setNextMap(obj); else head = obj; + tail = obj; + Blocker b = { x, y, minSpacing }; + placed.push_back(b); + templateCounts[which]++; + } + + if (head) { + PointerTool::clearSelection(); + mcpCommit(new AddObjectUndoable(doc, head)); + PointerTool::clearSelection(); + } + for (size_t i = 0; i < templates.size(); i++) { + perTemplate.set(templates[i].tt->getName().str(), templateCounts[i]); + } + McpJson j = McpJson::makeObject(); + j.set("placed", (int)placed.size()).set("requested", count).set("attempts", attempts); + j.set("per_template", perTemplate); + j.set("rejected", McpJson::makeObject() + .set("outside_area_or_cluster", rejectedArea).set("terrain", rejectedTerrain) + .set("road", rejectedRoad).set("spacing", rejectedSpacing)); + return j; +} + +} // namespace + +void mcpRegisterScatterCommands() +{ + mcpRegisterCommand("objects.scatter", cmdScatter, "{templates:[name|{template,weight}], count?|density?, seed?, shape?, feather?, min_spacing?, road_clearance?, object_clearance?, start_clearance?, avoid_cliffs?, avoid_water?, max_slope?, only_textures?, avoid_areas?, inside_areas?, cluster?:{frequency,threshold}} Scatters decoration objects."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index a08c4f986e8..48684a11dee 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -83,6 +83,7 @@ void registerAllCommands() mcpRegisterSymmetryCommands(); mcpRegisterGenerateCommands(); mcpRegisterSkirmishCommands(); + mcpRegisterScatterCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index 7fcaee536f7..4667ec0ce60 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -70,6 +70,10 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - Player numbers in names and owners are renumbered for each copy. - Cliff texture mapping is reset on copied cells. - The mirroring approach follows the Genesis map tools (The CWC Team, Apache-2.0). +- **Scattering.** `objects_scatter` places decoration such as trees and rocks at random. + - It takes weighted templates and a count or density, and can group objects into noise-driven groves. + - It keeps clear of roads, building footprints, start positions, cliffs and water. + - Optional filters limit it by texture, slope or named area. - **Skirmish AI.** `ai_skirmish_setup` creates what the AI looks up by name for every `Player__Start`. `ai_skirmish_check` reports what is missing. - Areas: `InnerPerimeter` and `OuterPerimeter`, plus a `CombatZone`. - Paths: approach paths into each base, labelled `Center`, `Flank` and `Backdoor`. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index 6123279424c..26050fae76e 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -535,6 +535,35 @@ def objects_delete(ids: list[int]) -> dict: return wb.call("objects.delete", ids=ids) +@mcp.tool() +def objects_scatter(templates: list[str | dict[str, Any]], count: int | None = None, density: float = 40, + seed: int = 1, shape: Shape | 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, feather: float = 0, + min_spacing: float = 30, road_clearance: float = 40, object_clearance: float = 40, + start_clearance: float = 300, avoid_cliffs: bool = True, avoid_water: bool = True, + max_slope: float | None = None, only_textures: list[str] | None = None, + avoid_areas: list[str] | None = None, inside_areas: list[str] | None = None, + cluster: dict[str, float] | None = None) -> dict: + """Scatters decoration (trees, bushes, rocks, props) at random positions with random rotation. + templates: names or {"template": .., "weight": ..} (see objects_list_templates, e.g. + editor_sorting SHRUBBERY or MISC_NATURAL). Number: count, or density = objects per 1000x1000 + world units over the area. Area: the whole playable map, or a circle/rect (feather thins the edge). + Placement keeps min_spacing between scattered objects, road_clearance from roads, + object_clearance beyond the footprint of existing structures/units, start_clearance from + Player__Start waypoints, and skips cliffs and water. Optional filters: max_slope (raw height + difference across the cell), only_textures (e.g. grass only), avoid_areas / inside_areas (area + names). cluster={"frequency": 0.03, "threshold": 0.5} groups objects into groves using noise + (lower frequency = larger groves, higher threshold = fewer). Same seed gives the same result. + All objects are neutral. One undo step; follow with map_symmetrize if the map must stay symmetric.""" + return wb.call("objects.scatter", templates=templates, count=count, density=density, seed=seed, + feather=feather, min_spacing=min_spacing, road_clearance=road_clearance, + object_clearance=object_clearance, start_clearance=start_clearance, + avoid_cliffs=avoid_cliffs, avoid_water=avoid_water, max_slope=max_slope, + only_textures=only_textures, avoid_areas=avoid_areas, inside_areas=inside_areas, + cluster=cluster, **_shape_args(shape, x, y, radius, x0, y0, x1, y1)) + + @mcp.tool() def sides_list() -> dict: """Players (with their property dicts) and teams. Object owners are team names.""" diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 389895a80cb..793298d7411 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -70,6 +70,15 @@ def main() -> int: assert check["ready"], check wb.call("edit.undo", count=2) + # Scatter decoration: must respect the road and stay deterministic for a seed. + if trees: + objects_before = wb.call("map.info")["counts"]["objects"] + sc = wb.call("objects.scatter", templates=[trees[0]["name"]], count=25, seed=3, start_clearance=60) + assert 0 < sc["placed"] <= 25, sc + assert wb.call("map.info")["counts"]["objects"] == objects_before + sc["placed"] + wb.call("edit.undo") + assert wb.call("map.info")["counts"]["objects"] == objects_before + # Symmetry: mirror the west half onto the east half, then undo. before_wp = len(wb.call("waypoints.list")["waypoints"]) sym = wb.call("map.symmetrize", mode="mirror_x", source="west") From 6178d41ec7986aa87e630796aa507c7a94917878 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Thu, 1 Oct 2026 07:15:00 +0200 Subject: [PATCH 09/30] feat(worldbuilder): Adds MCP roads.route with terrain-aware routing roads.route finds a road between two points (and optional via points) with A* over the heightmap vertices. Cliffs, water, structure footprints and named areas are blocked, slopes add cost and steps above max_grade are forbidden. Stops on blocked ground are reached from the nearest free vertex. The grid path is straightened and nearly collinear points are dropped; a shortcut must be neither clearly costlier nor clearly steeper than the path it replaces, so the road does not cut across features the pathfinder went around. Sharp bends get tight corners, ends snap to existing road points, and the ground under the road can be flattened to a smoothed profile. Road and terrain are one undo step. roads.add now shares its segment builder (mcpBuildRoadChain). The idea follows the RoadGenerator and RoadFlattener of Genesis (The CWC Team). Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 1 + .../WorldBuilder/include/mcp/McpCommands.h | 9 + .../WorldBuilder/src/mcp/McpCmdRoute.cpp | 540 ++++++++++++++++++ .../WorldBuilder/src/mcp/McpCmdWaypoints.cpp | 72 ++- .../WorldBuilder/src/mcp/McpCommands.cpp | 1 + scripts/worldbuilder-mcp/README.md | 5 + scripts/worldbuilder-mcp/server.py | 23 + scripts/worldbuilder-mcp/smoke_test.py | 9 + 8 files changed, 630 insertions(+), 30 deletions(-) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index 99e3ec3b3a6..1362ee578b1 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -206,6 +206,7 @@ set(WORLDBUILDER_SRC "src/mcp/McpCmdImage.cpp" "src/mcp/McpCmdMap.cpp" "src/mcp/McpCmdObjects.cpp" + "src/mcp/McpCmdRoute.cpp" "src/mcp/McpCmdScatter.cpp" "src/mcp/McpCmdSkirmish.cpp" "src/mcp/McpCmdSymmetry.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index 26c50590d5d..595fa4168f5 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -24,6 +24,9 @@ #include "mcp/McpJson.h" +#include "Common/AsciiString.h" +#include + class CWorldBuilderDoc; class WorldHeightMapEdit; class MapObject; @@ -53,6 +56,7 @@ void mcpRegisterSymmetryCommands(); void mcpRegisterGenerateCommands(); void mcpRegisterSkirmishCommands(); void mcpRegisterScatterCommands(); +void mcpRegisterRouteCommands(); //------------------------------------------------------------------------------------------------- // Helpers @@ -105,6 +109,11 @@ PolygonTrigger *mcpRequirePolygon(int id); /// Common JSON description of a map object. McpJson mcpDescribeObject(MapObject *obj); +/// Builds the linked map objects for a road polyline (or a 2-point bridge): one object pair per +/// segment. cornerFlags holds a FLAG_ROAD_CORNER_* value (or 0) per polyline point. +MapObject *mcpBuildRoadChain(const AsciiString &roadName, bool isBridge, const std::vector &points, + const std::vector &cornerFlags, MapObject **outTail); + /// Converts between engine property dictionaries and JSON objects. When writing, the type of a key /// that already exists in the dict is kept; new keys get their type from the JSON value. class Dict; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp new file mode 100644 index 00000000000..1a1d4982d3d --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp @@ -0,0 +1,540 @@ +/* +** 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 . +*/ + +// McpCmdRoute.cpp +// MCP bridge command that routes a road across the terrain: A* over the heightmap vertices that +// avoids cliffs, water and structures and prefers gentle slopes, straightened afterwards, with +// optional flattening of the ground under the road. The idea follows the RoadGenerator and +// RoadFlattener of Genesis (The CWC Team). + +#include "StdAfx.h" +#include "mcp/McpCommands.h" + +#include "CUndoable.h" +#include "PointerTool.h" +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" +#include "Common/ThingSort.h" +#include "Common/ThingTemplate.h" +#include "GameClient/TerrainRoads.h" +#include "GameLogic/PolygonTrigger.h" + +#include +#include +#include + +namespace +{ + +struct Pt +{ + double x, y; ///< heightmap vertex coordinates (fractional allowed) +}; + +/// Terrain grid with the cells a road may not cross. +class RouteGrid +{ +public: + RouteGrid(WorldHeightMapEdit *map) : m_map(map), m_w(map->getXExtent()), m_h(map->getYExtent()), + m_border(map->getBorderSize()), m_blocked(map->getXExtent() * map->getYExtent(), 0), + m_slopeWeight(0.5), m_maxGrade(0) {} + + Int width() const { return m_w; } + Int height() const { return m_h; } + Int border() const { return m_border; } + void setCosts(double slopeWeight, double maxGrade) { m_slopeWeight = slopeWeight; m_maxGrade = maxGrade; } + + bool inPlayable(Int x, Int y) const + { + return x >= m_border && y >= m_border && x <= m_w - 1 - m_border && y <= m_h - 1 - m_border; + } + bool isBlocked(Int x, Int y) const { return !inPlayable(x, y) || m_blocked[y * m_w + x] != 0; } + void block(Int x, Int y) { if (x >= 0 && y >= 0 && x < m_w && y < m_h) m_blocked[y * m_w + x] = 1; } + double heightAt(Int x, Int y) const { return m_map->getHeight(x, y); } + + void blockCliffsAndWater(bool cliffs, bool water) + { + for (Int y = 0; y < m_h - 1; y++) { + for (Int x = 0; x < m_w - 1; x++) { + if ((cliffs && m_map->getCliffState(x, y)) || (water && mcpIsCellUnderWater(m_map, x, y))) { + // A blocked cell blocks its four corner vertices. + block(x, y); block(x + 1, y); block(x, y + 1); block(x + 1, y + 1); + } + } + } + } + void blockCircle(double wx, double wy, double radius) + { + const double cx = wx / MAP_XY_FACTOR + m_border, cy = wy / MAP_XY_FACTOR + m_border; + const double r = radius / MAP_XY_FACTOR; + for (Int y = (Int)floor(cy - r); y <= (Int)ceil(cy + r); y++) { + for (Int x = (Int)floor(cx - r); x <= (Int)ceil(cx + r); x++) { + if ((x - cx) * (x - cx) + (y - cy) * (y - cy) <= r * r) block(x, y); + } + } + } + void blockArea(PolygonTrigger *trig) + { + for (Int y = m_border; y < m_h - m_border; y++) { + for (Int x = m_border; x < m_w - m_border; x++) { + ICoord3D pt; + pt.x = (Int)((x - m_border) * MAP_XY_FACTOR); + pt.y = (Int)((y - m_border) * MAP_XY_FACTOR); + pt.z = 0; + if (trig->pointInTrigger(pt)) block(x, y); + } + } + } + + /// Finds the closest unblocked vertex within maxRadius cells (the vertex itself if it is free). + bool nearestFree(Int x, Int y, Int maxRadius, Int *fx, Int *fy) const + { + double best = 1e30; + bool found = false; + for (Int r = 0; r <= maxRadius && !found; r++) { + for (Int dy = -r; dy <= r; dy++) { + for (Int dx = -r; dx <= r; dx++) { + if (abs(dx) != r && abs(dy) != r) continue; // ring only + if (isBlocked(x + dx, y + dy)) continue; + const double d = dx * dx + dy * dy; + if (d < best) { + best = d; + *fx = x + dx; + *fy = y + dy; + found = true; + } + } + } + } + return found; + } + + /// Height change per cell of distance for one step between neighbouring vertices. + double stepGrade(Int x0, Int y0, Int x1, Int y1) const + { + const double d = (x0 != x1 && y0 != y1) ? 1.41421356 : 1.0; + return fabs(heightAt(x1, y1) - heightAt(x0, y0)) / d; + } + + /// Cost of one step between neighbouring vertices, or a negative value if it is too steep. + double stepCost(Int x0, Int y0, Int x1, Int y1) const + { + const double d = (x0 != x1 && y0 != y1) ? 1.41421356 : 1.0; + const double grade = stepGrade(x0, y0, x1, y1); + if (m_maxGrade > 0 && grade > m_maxGrade) return -1; + return d * (1.0 + m_slopeWeight * grade); + } + + /// Cost of the straight line between two vertices, or negative if it crosses something blocked. + /// maxGrade, if given, receives the steepest step along the line. + double lineCost(const Pt &a, const Pt &b, double *maxGrade = nullptr) const + { + if (maxGrade) *maxGrade = 0; + const double len = hypot(b.x - a.x, b.y - a.y); + const Int steps = (Int)ceil(len * 2); + if (steps == 0) return 0; + double cost = 0; + Int px = (Int)floor(a.x + 0.5), py = (Int)floor(a.y + 0.5); + for (Int i = 1; i <= steps; i++) { + const double t = (double)i / steps; + const Int x = (Int)floor(a.x + (b.x - a.x) * t + 0.5); + const Int y = (Int)floor(a.y + (b.y - a.y) * t + 0.5); + if (x == px && y == py) continue; + if (isBlocked(x, y)) return -1; + const double c = stepCost(px, py, x, y); + if (c < 0) return -1; + cost += c; + if (maxGrade) { + const double grade = stepGrade(px, py, x, y); + if (grade > *maxGrade) *maxGrade = grade; + } + px = x; + py = y; + } + return cost; + } + + /// A* between two vertices. Returns the vertex path and its accumulated costs. + bool findPath(Int sx, Int sy, Int gx, Int gy, std::vector *path, std::vector *costs) const + { + const Int n = m_w * m_h; + std::vector g(n, 1e30); + std::vector parent(n, -1); + std::vector closed(n, 0); + typedef std::pair Entry; + std::priority_queue, std::greater > open; + const Int start = sy * m_w + sx, goal = gy * m_w + gx; + g[start] = 0; + open.push(Entry(0, start)); + static const Int dirs[8][2] = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } }; + while (!open.empty()) { + const Int cur = open.top().second; + open.pop(); + if (closed[cur]) continue; + closed[cur] = 1; + if (cur == goal) break; + const Int cx = cur % m_w, cy = cur / m_w; + for (Int d = 0; d < 8; d++) { + const Int nx = cx + dirs[d][0], ny = cy + dirs[d][1]; + if (isBlocked(nx, ny)) continue; + // Don't cut corners diagonally past a blocked vertex. + if (dirs[d][0] && dirs[d][1] && (isBlocked(cx + dirs[d][0], cy) || isBlocked(cx, cy + dirs[d][1]))) continue; + const double step = stepCost(cx, cy, nx, ny); + if (step < 0) continue; + const Int next = ny * m_w + nx; + if (closed[next] || g[cur] + step >= g[next]) continue; + g[next] = g[cur] + step; + parent[next] = cur; + const Int dx = abs(nx - gx), dy = abs(ny - gy); + const double heuristic = (dx > dy ? dx : dy) + 0.41421356 * (dx < dy ? dx : dy); + open.push(Entry(g[next] + heuristic, next)); + } + } + if (!closed[goal]) return false; + std::vector reversed; + for (Int cur = goal; cur != -1; cur = parent[cur]) reversed.push_back(cur); + for (size_t i = reversed.size(); i-- > 0;) { + Pt p = { (double)(reversed[i] % m_w), (double)(reversed[i] / m_w) }; + path->push_back(p); + costs->push_back(g[reversed[i]]); + } + return true; + } + +private: + WorldHeightMapEdit *m_map; + Int m_w, m_h, m_border; + std::vector m_blocked; + double m_slopeWeight, m_maxGrade; +}; + +/// A shortcut may be at most this much steeper than the piece of route it replaces. Without it a +/// long straight line could cut across a short steep feature the pathfinder had gone around. +bool gradeAcceptable(double shortcutGrade, double replacedGrade) +{ + const double allowed = replacedGrade * 1.5 > 3.0 ? replacedGrade * 1.5 : 3.0; + return shortcutGrade <= allowed; +} + +/// Removes bends the road does not need: keeps a vertex only when the straight line past it would +/// be blocked, clearly more expensive or clearly steeper than the routed path. +std::vector straighten(const RouteGrid &grid, const std::vector &path, const std::vector &costs, double tolerance) +{ + std::vector grades(path.size(), 0); // grade of the step leading to each vertex + for (size_t i = 1; i < path.size(); i++) { + grades[i] = grid.stepGrade((Int)path[i - 1].x, (Int)path[i - 1].y, (Int)path[i].x, (Int)path[i].y); + } + std::vector out; + size_t i = 0; + out.push_back(path[0]); + while (i + 1 < path.size()) { + size_t best = i + 1; + for (size_t j = path.size() - 1; j > i + 1; j--) { + double lineGrade = 0; + const double line = grid.lineCost(path[i], path[j], &lineGrade); + if (line < 0 || line > (costs[j] - costs[i]) * tolerance + 0.5) continue; + double pathGrade = 0; + for (size_t k = i + 1; k <= j; k++) { + if (grades[k] > pathGrade) pathGrade = grades[k]; + } + if (gradeAcceptable(lineGrade, pathGrade)) { + best = j; + break; + } + } + out.push_back(path[best]); + i = best; + } + return out; +} + +/// Drops points that lie within maxOffset cells of the line between their neighbours, as long as +/// that line is neither blocked nor clearly costlier (steeper) than going through the point. This +/// removes the small wiggles a grid path leaves behind. +std::vector dropNearlyStraightPoints(const RouteGrid &grid, std::vector pts, double maxOffset, double tolerance) +{ + bool changed = true; + while (changed && pts.size() > 2) { + changed = false; + for (size_t i = 1; i + 1 < pts.size(); i++) { + const Pt &a = pts[i - 1], &b = pts[i + 1], &p = pts[i]; + const double len = hypot(b.x - a.x, b.y - a.y); + if (len < 0.001) continue; + const double offset = fabs((b.x - a.x) * (a.y - p.y) - (a.x - p.x) * (b.y - a.y)) / len; + if (offset > maxOffset) continue; + double directGrade = 0, grade1 = 0, grade2 = 0; + const double direct = grid.lineCost(a, b, &directGrade); + const double around = grid.lineCost(a, p, &grade1) + grid.lineCost(p, b, &grade2); + if (direct >= 0 && direct <= around * tolerance + 0.5 + && gradeAcceptable(directGrade, grade1 > grade2 ? grade1 : grade2)) { + pts.erase(pts.begin() + i); + changed = true; + break; + } + } + } + return pts; +} + +bool parsePoint(const McpJson &v, double *x, double *y) +{ + if (!v.isArray() || v.size() < 2 || !v.at(0).isNumber() || !v.at(1).isNumber()) return false; + *x = v.at(0).asNumber(); + *y = v.at(1).asNumber(); + return true; +} + +McpJson cmdRoute(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + WorldHeightMapEdit *map = mcpHeightMap(); + const std::string type = mcpArgString(args, "type"); + AsciiString roadName(type.c_str()); + if (TheTerrainRoads->findRoad(roadName) == nullptr) { + mcpFail("unknown road type '%s' (see roads.list_types; bridges are placed with roads.add)", type.c_str()); + } + const std::string corners = mcpArgString(args, "corners", "auto"); + if (corners != "auto" && corners != "curved" && corners != "angled" && corners != "tight") { + mcpFail("corners must be auto, curved, angled or tight"); + } + + // Stops: from, optional via points, to. + std::vector stops; + double wx, wy; + if (!parsePoint(args.get("from"), &wx, &wy)) mcpFail("from must be [x, y] in world units"); + Pt p = { wx, wy }; + stops.push_back(p); + const McpJson &via = args.get("via"); + if (via.isArray()) { + for (size_t i = 0; i < via.size(); i++) { + if (!parsePoint(via.at(i), &wx, &wy)) mcpFail("via must be a list of [x, y] points"); + Pt v = { wx, wy }; + stops.push_back(v); + } + } + if (!parsePoint(args.get("to"), &wx, &wy)) mcpFail("to must be [x, y] in world units"); + Pt t = { wx, wy }; + stops.push_back(t); + + // Snap the ends to existing road points so the new road joins them. + const double snap = mcpArgNumber(args, "snap", 25); + for (size_t s = 0; s < stops.size(); s += stops.size() - 1) { + double bestDist = snap; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (!(obj->getFlags() & FLAG_ROAD_FLAGS)) continue; + const double d = hypot(obj->getLocation()->x - stops[s].x, obj->getLocation()->y - stops[s].y); + if (d <= bestDist) { + bestDist = d; + stops[s].x = obj->getLocation()->x; + stops[s].y = obj->getLocation()->y; + } + } + } + + RouteGrid grid(map); + grid.setCosts(mcpArgNumber(args, "slope_weight", 0.5), mcpArgNumber(args, "max_grade", 10)); + grid.blockCliffsAndWater(mcpArgBool(args, "avoid_cliffs", true), mcpArgBool(args, "avoid_water", true)); + const double clearance = mcpArgNumber(args, "object_clearance", 20); + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + const ThingTemplate *tt = obj->getThingTemplate(); + if (tt && tt->getEditorSorting() == ES_STRUCTURE) { + grid.blockCircle(obj->getLocation()->x, obj->getLocation()->y, tt->getTemplateGeometryInfo().getBoundingCircleRadius() + clearance); + } + } + const McpJson &avoidAreas = args.get("avoid_areas"); + if (avoidAreas.isArray()) { + for (size_t i = 0; i < avoidAreas.size(); i++) { + PolygonTrigger *found = nullptr; + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + if (avoidAreas.at(i).isString() && trig->getTriggerName().compareNoCase(avoidAreas.at(i).asString().c_str()) == 0) found = trig; + } + if (!found) mcpFail("unknown area in avoid_areas (see polygons.list)"); + grid.blockArea(found); + } + } + + // Route each leg and straighten it. + const double tolerance = mcpArgNumber(args, "straighten", 1.25); + const double mergeDistance = mcpArgNumber(args, "merge_distance", 20) / MAP_XY_FACTOR; + std::vector route; + for (size_t s = 0; s + 1 < stops.size(); s++) { + // A stop may lie on blocked ground (e.g. right at a building). Route between the nearest + // free vertices and connect the stop itself with a straight piece. + Int ends[2][2], freeEnds[2][2]; + for (Int e = 0; e < 2; e++) { + ends[e][0] = (Int)floor(stops[s + e].x / MAP_XY_FACTOR + 0.5) + grid.border(); + ends[e][1] = (Int)floor(stops[s + e].y / MAP_XY_FACTOR + 0.5) + grid.border(); + if (!grid.inPlayable(ends[e][0], ends[e][1])) { + mcpFail("point (%.0f, %.0f) is outside the playable map", stops[s + e].x, stops[s + e].y); + } + if (!grid.nearestFree(ends[e][0], ends[e][1], 40, &freeEnds[e][0], &freeEnds[e][1])) { + mcpFail("point (%.0f, %.0f) is deep inside blocked ground (cliffs, water or structures)", stops[s + e].x, stops[s + e].y); + } + } + std::vector path; + std::vector costs; + if (!grid.findPath(freeEnds[0][0], freeEnds[0][1], freeEnds[1][0], freeEnds[1][1], &path, &costs)) { + mcpFail("no route from (%.0f, %.0f) to (%.0f, %.0f): blocked by cliffs, water, structures or max_grade. " + "Relax avoid_cliffs/avoid_water/max_grade, add via points, or bridge the gap with roads.add.", + stops[s].x, stops[s].y, stops[s + 1].x, stops[s + 1].y); + } + std::vector leg = tolerance > 0 ? straighten(grid, path, costs, tolerance) : path; + if (tolerance > 0) { + leg = dropNearlyStraightPoints(grid, leg, mergeDistance, tolerance); + } + // Only the real start and end get the connecting piece; a blocked via point would + // otherwise become a dead-end spur. + if (s == 0 && (freeEnds[0][0] != ends[0][0] || freeEnds[0][1] != ends[0][1])) { + Pt stop = { (double)ends[0][0], (double)ends[0][1] }; + leg.insert(leg.begin(), stop); + } + if (s + 2 == stops.size() && (freeEnds[1][0] != ends[1][0] || freeEnds[1][1] != ends[1][1])) { + Pt stop = { (double)ends[1][0], (double)ends[1][1] }; + leg.push_back(stop); + } + for (size_t i = (s == 0 ? 0 : 1); i < leg.size(); i++) route.push_back(leg[i]); + } + + // World-space polyline; the exact end points replace the snapped grid vertices. + std::vector points; + for (size_t i = 0; i < route.size(); i++) { + Coord2D c; + c.x = (Real)((route[i].x - grid.border()) * MAP_XY_FACTOR); + c.y = (Real)((route[i].y - grid.border()) * MAP_XY_FACTOR); + points.push_back(c); + } + points.front().x = (Real)stops.front().x; + points.front().y = (Real)stops.front().y; + points.back().x = (Real)stops.back().x; + points.back().y = (Real)stops.back().y; + + // Corner style per point; "auto" tightens only the sharp bends. + std::vector flags(points.size(), 0); + for (size_t i = 0; i < points.size(); i++) { + if (corners == "angled") flags[i] = FLAG_ROAD_CORNER_ANGLED; + else if (corners == "tight") flags[i] = FLAG_ROAD_CORNER_TIGHT; + else if (corners == "auto" && i > 0 && i + 1 < points.size()) { + const double a1 = atan2(points[i].y - points[i - 1].y, points[i].x - points[i - 1].x); + const double a2 = atan2(points[i + 1].y - points[i].y, points[i + 1].x - points[i].x); + double turn = fabs(a2 - a1); + if (turn > PI) turn = 2 * PI - turn; + if (turn > PI / 3) flags[i] = FLAG_ROAD_CORNER_TIGHT; + } + } + + double length = 0; + for (size_t i = 0; i + 1 < points.size(); i++) { + length += hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y); + } + + // Optional flattening: level the ground across the road to a smoothed height profile along it. + MultipleUndoable *undo = new MultipleUndoable; + MapObject *tail = nullptr; + MapObject *head = mcpBuildRoadChain(roadName, false, points, flags, &tail); + undo->addUndoable(new AddObjectUndoable(doc, head)); + WorldHeightMapEdit *copy = nullptr; + Int flattened = 0; + if (mcpArgBool(args, "flatten", true)) { + const double halfWidth = mcpArgNumber(args, "flatten_width", 50) / 2 / MAP_XY_FACTOR; + const double feather = mcpArgNumber(args, "flatten_feather", 40) / MAP_XY_FACTOR; + std::vector samples; + std::vector profile; + for (size_t i = 0; i + 1 < route.size(); i++) { + const double len = hypot(route[i + 1].x - route[i].x, route[i + 1].y - route[i].y); + const Int steps = (Int)ceil(len * 2); + for (Int k = (i == 0 ? 0 : 1); k <= steps; k++) { + const double f = steps ? (double)k / steps : 0; + Pt sp = { route[i].x + (route[i + 1].x - route[i].x) * f, route[i].y + (route[i + 1].y - route[i].y) * f }; + samples.push_back(sp); + profile.push_back(grid.heightAt((Int)floor(sp.x + 0.5), (Int)floor(sp.y + 0.5))); + } + } + for (Int pass = 0; pass < 6; pass++) { + std::vector prev = profile; + for (size_t i = 1; i + 1 < profile.size(); i++) { + profile[i] = (prev[i - 1] + prev[i] * 2 + prev[i + 1]) / 4; + } + } + copy = map->duplicate(); + const double reach = halfWidth + feather; + Int minX = grid.width(), minY = grid.height(), maxX = 0, maxY = 0; + for (size_t i = 0; i < samples.size(); i++) { + if (samples[i].x - reach < minX) minX = (Int)floor(samples[i].x - reach); + if (samples[i].y - reach < minY) minY = (Int)floor(samples[i].y - reach); + if (samples[i].x + reach > maxX) maxX = (Int)ceil(samples[i].x + reach); + if (samples[i].y + reach > maxY) maxY = (Int)ceil(samples[i].y + reach); + } + if (minX < 0) minX = 0; + if (minY < 0) minY = 0; + if (maxX > grid.width() - 1) maxX = grid.width() - 1; + if (maxY > grid.height() - 1) maxY = grid.height() - 1; + for (Int y = minY; y <= maxY; y++) { + for (Int x = minX; x <= maxX; x++) { + double best = 1e30; + size_t bestNdx = 0; + for (size_t i = 0; i < samples.size(); i++) { + const double d = (samples[i].x - x) * (samples[i].x - x) + (samples[i].y - y) * (samples[i].y - y); + if (d < best) { best = d; bestNdx = i; } + } + const double dist = sqrt(best); + if (dist > reach) continue; + const double w = dist <= halfWidth ? 1.0 : (feather > 0 ? 1.0 - (dist - halfWidth) / feather : 0); + const double cur = map->getHeight(x, y); + double h = floor(cur + (profile[bestNdx] - cur) * w + 0.5); + if (h < 0) h = 0; + if (h > 255) h = 255; + if ((Int)h != map->getHeight(x, y)) { + copy->setHeight(x, y, (UnsignedByte)h); + flattened++; + } + } + } + if (flattened > 0) { + IRegion2D all = { 0, 0, 0, 0 }; + doc->updateHeightMap(copy, false, all); + undo->addUndoable(new WBDocUndoable(doc, copy)); + } + } + PointerTool::clearSelection(); + mcpCommit(undo); + if (copy) REF_PTR_RELEASE(copy); + + McpJson pts = McpJson::makeArray(); + for (size_t i = 0; i < points.size(); i++) { + McpJson pair = McpJson::makeArray(); + pair.push(McpJson((double)points[i].x)); + pair.push(McpJson((double)points[i].y)); + pts.push(pair); + } + McpJson ids = McpJson::makeArray(); + for (MapObject *obj = head; obj; obj = obj->getNext()) { + ids.push(mcpObjectHandle(obj)); + if (obj == tail) break; + } + McpJson j = McpJson::makeObject(); + j.set("type", type).set("points", pts).set("segments", (int)points.size() - 1).set("length", length); + j.set("flattened_vertices", flattened).set("point_ids", ids); + return j; +} + +} // namespace + +void mcpRegisterRouteCommands() +{ + mcpRegisterCommand("roads.route", cmdRoute, "{type, from:[x,y], to:[x,y], via?:[[x,y]...], corners?:auto|curved|angled|tight, flatten?, flatten_width?, flatten_feather?, slope_weight?, max_grade?, avoid_cliffs?, avoid_water?, avoid_areas?, object_clearance?, straighten?, snap?} Routes a road over the terrain."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp index 1a4159d2d98..04db575c563 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp @@ -341,38 +341,19 @@ McpJson cmdAddRoad(const McpJson &args) else if (corner == "tight") cornerFlag = FLAG_ROAD_CORNER_TIGHT; else if (corner != "curved") mcpFail("corners must be curved, angled or tight"); - // Each segment is a pair of map objects; consecutive segments share end locations so the - // renderer joins them. - MapObject *head = nullptr; - MapObject *tail = nullptr; - for (size_t i = 0; i + 1 < points.size(); i++) { - Coord3D loc[2]; - for (Int k = 0; k < 2; k++) { - const McpJson &p = points.at(i + k); - if (!p.isArray() || p.size() < 2 || !p.at(0).isNumber() || !p.at(1).isNumber()) { - if (head) deleteInstance(head); - mcpFail("points must be [x, y] pairs in world units"); - } - loc[k].x = (Real)p.at(0).asNumber(); - loc[k].y = (Real)p.at(1).asNumber(); - loc[k].z = 0; // roads stick to the terrain. - } - MapObject *p1 = newInstance(MapObject)(loc[0], roadName, 0.0f, 0, nullptr, nullptr); - MapObject *p2 = newInstance(MapObject)(loc[1], roadName, 0.0f, 0, nullptr, nullptr); - p1->setColor(RGB(255, 255, 0)); - p2->setColor(RGB(255, 255, 0)); - p1->setFlag(isBridge ? FLAG_BRIDGE_POINT1 : FLAG_ROAD_POINT1); - p2->setFlag(isBridge ? FLAG_BRIDGE_POINT2 : FLAG_ROAD_POINT2); - if (!isBridge && cornerFlag) { - p1->setFlag(cornerFlag); - p2->setFlag(cornerFlag); + std::vector pts; + for (size_t i = 0; i < points.size(); i++) { + const McpJson &p = points.at(i); + if (!p.isArray() || p.size() < 2 || !p.at(0).isNumber() || !p.at(1).isNumber()) { + mcpFail("points must be [x, y] pairs in world units"); } - p1->getProperties()->setAsciiString(TheKey_originalOwner, NEUTRAL_TEAM_INTERNAL_STR); - p2->getProperties()->setAsciiString(TheKey_originalOwner, NEUTRAL_TEAM_INTERNAL_STR); - p1->setNextMap(p2); - if (tail) tail->setNextMap(p1); else head = p1; - tail = p2; + Coord2D c; + c.x = (Real)p.at(0).asNumber(); + c.y = (Real)p.at(1).asNumber(); + pts.push_back(c); } + MapObject *tail = nullptr; + MapObject *head = mcpBuildRoadChain(roadName, isBridge, pts, std::vector(pts.size(), cornerFlag), &tail); PointerTool::clearSelection(); mcpCommit(new AddObjectUndoable(doc, head)); @@ -386,6 +367,37 @@ McpJson cmdAddRoad(const McpJson &args) } // namespace +MapObject *mcpBuildRoadChain(const AsciiString &roadName, bool isBridge, const std::vector &points, + const std::vector &cornerFlags, MapObject **outTail) +{ + // Each segment is a pair of map objects; consecutive segments share end locations so the + // renderer joins them. + MapObject *head = nullptr; + MapObject *tail = nullptr; + for (size_t i = 0; i + 1 < points.size(); i++) { + MapObject *pair[2]; + for (Int k = 0; k < 2; k++) { + Coord3D loc; + loc.x = points[i + k].x; + loc.y = points[i + k].y; + loc.z = 0; // roads stick to the terrain. + pair[k] = newInstance(MapObject)(loc, roadName, 0.0f, 0, nullptr, nullptr); + pair[k]->setColor(RGB(255, 255, 0)); + if (!isBridge && cornerFlags[i + k]) { + pair[k]->setFlag(cornerFlags[i + k]); + } + pair[k]->getProperties()->setAsciiString(TheKey_originalOwner, NEUTRAL_TEAM_INTERNAL_STR); + } + pair[0]->setFlag(isBridge ? FLAG_BRIDGE_POINT1 : FLAG_ROAD_POINT1); + pair[1]->setFlag(isBridge ? FLAG_BRIDGE_POINT2 : FLAG_ROAD_POINT2); + pair[0]->setNextMap(pair[1]); + if (tail) tail->setNextMap(pair[0]); else head = pair[0]; + tail = pair[1]; + } + if (outTail) *outTail = tail; + return head; +} + void mcpRegisterWaypointCommands() { mcpRegisterCommand("waypoints.list", cmdListWaypoints, "All waypoints and the links between them."); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index 48684a11dee..0058fd5405c 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -84,6 +84,7 @@ void registerAllCommands() mcpRegisterGenerateCommands(); mcpRegisterSkirmishCommands(); mcpRegisterScatterCommands(); + mcpRegisterRouteCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index 4667ec0ce60..d7a98ab82ed 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -70,6 +70,11 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - Player numbers in names and owners are renumbered for each copy. - Cliff texture mapping is reset on copied cells. - The mirroring approach follows the Genesis map tools (The CWC Team, Apache-2.0). +- **Road routing.** `roads_route` finds its own way from A to B, optionally through via points. + - It goes around cliffs, water, structures and named areas, prefers gentle slopes and refuses steep steps. + - Unneeded bends are removed, but a shortcut is never allowed to be noticeably steeper than the routed path. + - By default it levels the ground under the road to a smoothed profile. + - Road and terrain change are one undo step. - **Scattering.** `objects_scatter` places decoration such as trees and rocks at random. - It takes weighted templates and a count or density, and can group objects into noise-driven groves. - It keeps clear of roads, building footprints, start positions, cliffs and water. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index 26050fae76e..6282e2ab8ba 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -650,6 +650,29 @@ def roads_add(type: str, points: list[list[float]], return wb.call("roads.add", type=type, points=points, corners=corners) +@mcp.tool() +def roads_route(type: str, from_point: list[float], to_point: list[float], via: list[list[float]] | None = None, + corners: Literal["auto", "curved", "angled", "tight"] = "auto", flatten: bool = True, + flatten_width: float = 50, flatten_feather: float = 40, slope_weight: float = 0.5, + max_grade: float = 10, avoid_cliffs: bool = True, avoid_water: bool = True, + avoid_areas: list[str] | None = None, object_clearance: float = 20, straighten: float = 1.25, + snap: float = 25) -> dict: + """Routes a road from from_point to to_point ([x, y] world units), optionally through via points, + finding its own way over the terrain: it goes around cliffs, water, structures (plus + object_clearance) and avoid_areas, prefers gentle slopes (slope_weight) and never climbs steps + steeper than max_grade raw height units per cell (0 = no limit). The path is then straightened (straighten + = how much costlier a straight shortcut may be, 0 keeps every grid step) and built like roads_add. + corners="auto" uses tight corners only at sharp bends. flatten levels the ground under the road + to a smoothed profile (flatten_width plus a flatten_feather falloff on each side). Ends within + `snap` of an existing road point join that road. Fails with a hint when no route exists; water + needs a bridge (roads_add with a bridge type). Road, terrain and all are one undo step.""" + return wb.call("roads.route", type=type, to=to_point, via=via, corners=corners, flatten=flatten, + flatten_width=flatten_width, flatten_feather=flatten_feather, slope_weight=slope_weight, + max_grade=max_grade, avoid_cliffs=avoid_cliffs, avoid_water=avoid_water, + avoid_areas=avoid_areas, object_clearance=object_clearance, straighten=straighten, + snap=snap, **{"from": from_point}) + + # -------------------------------------------------------------------------------------------------- # Skirmish AI # -------------------------------------------------------------------------------------------------- diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 793298d7411..0eeb741ba5c 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -79,6 +79,15 @@ def main() -> int: wb.call("edit.undo") assert wb.call("map.info")["counts"]["objects"] == objects_before + # Routed road: must build segments, optionally flatten, and undo as one step. + if roads: + road_points = wb.call("map.info")["counts"]["road_points"] + rt = wb.call("roads.route", type=roads[0], to=[880, 620], **{"from": [60, 600]}) + assert rt["segments"] >= 1 and rt["length"] > 800, rt + assert wb.call("map.info")["counts"]["road_points"] == road_points + 2 * rt["segments"] + wb.call("edit.undo") + assert wb.call("map.info")["counts"]["road_points"] == road_points + # Symmetry: mirror the west half onto the east half, then undo. before_wp = len(wb.call("waypoints.list")["waypoints"]) sym = wb.call("map.symmetrize", mode="mirror_x", source="west") From 727dade85be554a045892dea1d057138e0dbe786 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Thu, 1 Oct 2026 07:22:17 +0200 Subject: [PATCH 10/30] feat(worldbuilder): Adds MCP map.usage and map.replace map.usage lists the textures (with cell counts), object templates and road types a map uses and flags templates the game does not know, which is the starting point for converting a map made for another mod. map.replace swaps textures, object templates and road or bridge types everywhere from {from: to} mappings, in one undo step, with a dry run. Textures keep their blends, objects keep position, rotation, owner and properties, roads keep their flags. All targets are validated first and chained mappings are rejected because their result would depend on the order. The Python tool also reads Genesis-style "from to" list files. The modify-objects undoable moves to the shared header and is reused by objects.modify, map.transform and map.replace. The idea follows the Replace command of the Genesis map tools (The CWC Team). Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 1 + .../WorldBuilder/include/mcp/McpCommands.h | 1 + .../WorldBuilder/include/mcp/McpUndoables.h | 42 +++ .../WorldBuilder/src/mcp/McpCmdObjects.cpp | 41 +-- .../WorldBuilder/src/mcp/McpCmdReplace.cpp | 305 ++++++++++++++++++ .../WorldBuilder/src/mcp/McpCmdSymmetry.cpp | 30 +- .../WorldBuilder/src/mcp/McpCommands.cpp | 1 + scripts/worldbuilder-mcp/README.md | 4 + scripts/worldbuilder-mcp/server.py | 42 +++ scripts/worldbuilder-mcp/smoke_test.py | 19 ++ 10 files changed, 420 insertions(+), 66 deletions(-) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdReplace.cpp diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index 1362ee578b1..7fd96c6e872 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -206,6 +206,7 @@ set(WORLDBUILDER_SRC "src/mcp/McpCmdImage.cpp" "src/mcp/McpCmdMap.cpp" "src/mcp/McpCmdObjects.cpp" + "src/mcp/McpCmdReplace.cpp" "src/mcp/McpCmdRoute.cpp" "src/mcp/McpCmdScatter.cpp" "src/mcp/McpCmdSkirmish.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index 595fa4168f5..1dd33938f0e 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -57,6 +57,7 @@ void mcpRegisterGenerateCommands(); void mcpRegisterSkirmishCommands(); void mcpRegisterScatterCommands(); void mcpRegisterRouteCommands(); +void mcpRegisterReplaceCommands(); //------------------------------------------------------------------------------------------------- // Helpers diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h index 8893cbe8f1a..6f806750dd5 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h @@ -23,6 +23,7 @@ #include "CUndoable.h" #include "WorldBuilderDoc.h" +#include "wbview3d.h" #include #include @@ -50,3 +51,44 @@ class McpWaypointLinksUndoable : public Undoable private: CWorldBuilderDoc *m_doc; }; + +/// Moves, rotates, renames or re-templates objects. Set the new values on the MoveInfo returned by +/// add(); they are applied in Do(). +class McpModifyObjectsUndoable : public Undoable +{ +public: + McpModifyObjectsUndoable(CWorldBuilderDoc *doc) : m_doc(doc), m_list(nullptr), m_tail(nullptr), m_templateChanged(false) {} + virtual ~McpModifyObjectsUndoable() override { delete m_list; } + + MoveInfo *add(MapObject *obj) + { + MoveInfo *info = new MoveInfo(obj); + if (m_tail) m_tail->m_next = info; else m_list = info; + m_tail = info; + return info; + } + void setTemplateChanged() { m_templateChanged = true; } + + virtual void Do() override { apply(true); } + virtual void Undo() override { apply(false); } + +private: + void apply(bool forward) + { + for (MoveInfo *info = m_list; info; info = info->m_next) { + if (forward) info->DoMove(m_doc); else info->UndoMove(m_doc); + } + if (m_templateChanged) { + WbView3d *view = m_doc->GetActive3DView(); + if (view) { + view->resetRenderObjects(); + view->invalObjectInView(nullptr); + } + } + } + + CWorldBuilderDoc *m_doc; + MoveInfo *m_list; + MoveInfo *m_tail; + bool m_templateChanged; +}; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp index 32b5b99f49f..187977be882 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp @@ -22,6 +22,7 @@ #include "StdAfx.h" #define DEFINE_EDITOR_SORTING_NAMES #include "mcp/McpCommands.h" +#include "mcp/McpUndoables.h" #include "CUndoable.h" #include "PointerTool.h" @@ -41,46 +42,6 @@ namespace { -/// Moves, rotates or re-templates objects. The edits are applied when constructed, like ModifyObjectUndoable. -class McpModifyObjectsUndoable : public Undoable -{ -public: - McpModifyObjectsUndoable(CWorldBuilderDoc *doc) : m_doc(doc), m_list(nullptr), m_tail(nullptr), m_templateChanged(false) {} - virtual ~McpModifyObjectsUndoable() override { delete m_list; } - - MoveInfo *add(MapObject *obj) - { - MoveInfo *info = new MoveInfo(obj); - if (m_tail) m_tail->m_next = info; else m_list = info; - m_tail = info; - return info; - } - void setTemplateChanged() { m_templateChanged = true; } - - virtual void Do() override { apply(true); } - virtual void Undo() override { apply(false); } - -private: - void apply(bool forward) - { - for (MoveInfo *info = m_list; info; info = info->m_next) { - if (forward) info->DoMove(m_doc); else info->UndoMove(m_doc); - } - if (m_templateChanged) { - WbView3d *view = m_doc->GetActive3DView(); - if (view) { - view->resetRenderObjects(); - view->invalObjectInView(nullptr); - } - } - } - - CWorldBuilderDoc *m_doc; - MoveInfo *m_list; - MoveInfo *m_tail; - bool m_templateChanged; -}; - std::vector requireObjects(const McpJson &args) { std::vector objects; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdReplace.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdReplace.cpp new file mode 100644 index 00000000000..6bf1c6ae5ee --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdReplace.cpp @@ -0,0 +1,305 @@ +/* +** 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 . +*/ + +// McpCmdReplace.cpp +// MCP bridge commands to list what a map uses and to replace textures, object templates and road +// types in bulk, e.g. when converting a map made for another mod. The idea follows the Replace +// command of the Genesis map tools (The CWC Team). + +#include "StdAfx.h" +#include "mcp/McpCommands.h" +#include "mcp/McpUndoables.h" + +#include "TerrainMaterial.h" +#include "WHeightMapEdit.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "GameClient/TerrainRoads.h" + +#include +#include +#include + +namespace +{ + +struct Mapping +{ + std::string from, to; +}; + +std::string lower(const std::string &s) +{ + std::string out = s; + for (size_t i = 0; i < out.size(); i++) out[i] = (char)tolower((unsigned char)out[i]); + return out; +} + +/// Reads a {from: to} object and rejects chains (a target that is also a source), whose result +/// would depend on the order the replacements run in. +std::vector parseMapping(const McpJson &args, const char *key) +{ + std::vector out; + const McpJson &obj = args.get(key); + if (obj.isNull()) return out; + if (!obj.isObject()) mcpFail("%s must be an object {from: to, ...}", key); + for (size_t i = 0; i < obj.numKeys(); i++) { + if (!obj.valueAt(i).isString()) mcpFail("%s values must be names", key); + Mapping m; + m.from = obj.keyAt(i); + m.to = obj.valueAt(i).asString(); + if (lower(m.from) == lower(m.to)) continue; + out.push_back(m); + } + for (size_t i = 0; i < out.size(); i++) { + for (size_t k = 0; k < out.size(); k++) { + if (lower(out[i].to) == lower(out[k].from)) { + mcpFail("%s: '%s' is both a replacement and a source; split chained or swapped replacements into separate calls", + key, out[i].to.c_str()); + } + } + } + return out; +} + +Int textureClassByName(const std::string &name) +{ + McpJson arg = McpJson::makeObject().set("texture", McpJson(name)); + return mcpFindTextureClass(arg, "texture"); +} + +bool isRoadPoint(MapObject *obj) +{ + return (obj->getFlags() & (FLAG_ROAD_FLAGS | FLAG_BRIDGE_FLAGS)) != 0; +} + +//------------------------------------------------------------------------------------------------- +// map.usage +//------------------------------------------------------------------------------------------------- + +McpJson cmdUsage(const McpJson &) +{ + WorldHeightMapEdit *map = mcpHeightMap(); + + std::vector cells(WorldHeightMapEdit::getNumTexClasses(), 0); + for (Int y = 0; y < map->getYExtent() - 1; y++) { + for (Int x = 0; x < map->getXExtent() - 1; x++) { + const Int cls = map->getTextureClass(x, y, true); + if (cls >= 0 && cls < (Int)cells.size()) cells[cls]++; + } + } + McpJson textures = McpJson::makeArray(); + for (Int i = 0; i < (Int)cells.size(); i++) { + if (cells[i] > 0) { + textures.push(McpJson::makeObject().set("name", WorldHeightMapEdit::getTexClassName(i).str()).set("cells", cells[i])); + } + } + + std::map objectCounts, roadCounts; + std::map objectKnown; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (obj->isWaypoint() || obj->isLight() || obj->isScorch()) continue; + const std::string name = obj->getName().str(); + if (isRoadPoint(obj)) { + roadCounts[name]++; + } else { + objectCounts[name]++; + objectKnown[name] = obj->getThingTemplate() != nullptr; + } + } + McpJson objects = McpJson::makeArray(); + Int unknown = 0; + for (std::map::const_iterator it = objectCounts.begin(); it != objectCounts.end(); ++it) { + const bool known = objectKnown[it->first]; + if (!known) unknown += it->second; + objects.push(McpJson::makeObject().set("template", it->first).set("count", it->second).set("known", known)); + } + McpJson roads = McpJson::makeArray(); + for (std::map::const_iterator it = roadCounts.begin(); it != roadCounts.end(); ++it) { + const AsciiString name(it->first.c_str()); + const bool known = TheTerrainRoads->findRoad(name) != nullptr || TheTerrainRoads->findBridge(name) != nullptr; + roads.push(McpJson::makeObject().set("type", it->first).set("points", it->second).set("known", known)); + } + McpJson j = McpJson::makeObject(); + j.set("textures", textures).set("objects", objects).set("roads", roads); + j.set("objects_with_unknown_template", unknown); + return j; +} + +//------------------------------------------------------------------------------------------------- +// map.replace +//------------------------------------------------------------------------------------------------- + +/// Replaces one texture everywhere in the (copied) map. Returns the number of cells changed. +Int replaceTexture(WorldHeightMapEdit *copy, Int from, Int to, bool dryRun) +{ + Int count = 0, seedX = -1, seedY = -1, seed2X = -1, seed2Y = -1; + for (Int y = 0; y < copy->getYExtent() - 1; y++) { + for (Int x = 0; x < copy->getXExtent() - 1; x++) { + if (copy->getTextureClass(x, y, true) != from) continue; + if (count == 0) { seedX = x; seedY = y; } + else if (count == 1) { seed2X = x; seed2Y = y; } + count++; + } + } + if (count == 0 || dryRun) return count; + + // floodFill in replace mode swaps the texture in place when the new one is not in the map yet, + // which only works if it needs no more tiles. Allocating it first (when it fits) avoids that limit. + if (!copy->isTexClassUsed(to) && copy->canFitTexture(to)) { + copy->setTileNdx(seedX, seedY, to, false); + if (count == 1) return count; + seedX = seed2X; + seedY = seed2Y; + } + if (!copy->floodFill(seedX, seedY, to, true, false)) { + return -1; + } + return count; +} + +McpJson cmdReplace(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + WorldHeightMapEdit *map = mcpHeightMap(); + const bool dryRun = mcpArgBool(args, "dry_run", false); + const std::vector textures = parseMapping(args, "textures"); + const std::vector objects = parseMapping(args, "objects"); + const std::vector roads = parseMapping(args, "roads"); + if (textures.empty() && objects.empty() && roads.empty()) { + mcpFail("pass textures, objects and/or roads as {from: to} mappings"); + } + + // Validate every target before changing anything. + std::vector > textureClasses; + for (size_t i = 0; i < textures.size(); i++) { + textureClasses.push_back(std::make_pair(textureClassByName(textures[i].from), textureClassByName(textures[i].to))); + } + std::vector objectTargets; + for (size_t i = 0; i < objects.size(); i++) { + const ThingTemplate *tt = TheThingFactory->findTemplate(AsciiString(objects[i].to.c_str()), FALSE); + if (tt == nullptr) mcpFail("unknown object template '%s' (see objects.list_templates)", objects[i].to.c_str()); + objectTargets.push_back(tt); + } + for (size_t i = 0; i < roads.size(); i++) { + const AsciiString name(roads[i].to.c_str()); + if (TheTerrainRoads->findRoad(name) == nullptr && TheTerrainRoads->findBridge(name) == nullptr) { + mcpFail("unknown road or bridge type '%s' (see roads.list_types)", roads[i].to.c_str()); + } + } + + McpJson result = McpJson::makeObject(); + MultipleUndoable *undo = new MultipleUndoable; + bool changed = false; + + // Objects and roads. + McpModifyObjectsUndoable *modify = new McpModifyObjectsUndoable(doc); + std::vector objectCounts(objects.size(), 0), roadCounts(roads.size(), 0); + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (obj->isWaypoint() || obj->isLight() || obj->isScorch()) continue; + const std::string name = lower(obj->getName().str()); + if (isRoadPoint(obj)) { + for (size_t i = 0; i < roads.size(); i++) { + if (name != lower(roads[i].from)) continue; + const AsciiString target(roads[i].to.c_str()); + // A road point must stay a road and a bridge point a bridge. + const bool targetIsBridge = TheTerrainRoads->findRoad(target) == nullptr; + if (targetIsBridge != ((obj->getFlags() & FLAG_BRIDGE_FLAGS) != 0)) continue; + roadCounts[i]++; + if (!dryRun) { + MoveInfo *info = modify->add(obj); + info->m_oldName = obj->getName(); + info->m_newName = target; + } + } + } else { + for (size_t i = 0; i < objects.size(); i++) { + if (name != lower(objects[i].from)) continue; + objectCounts[i]++; + if (!dryRun) { + MoveInfo *info = modify->add(obj); + info->m_oldName = obj->getName(); + info->m_newName = objectTargets[i]->getName(); + info->m_newThing = objectTargets[i]; + modify->setTemplateChanged(); + } + } + } + } + McpJson objectResult = McpJson::makeArray(), roadResult = McpJson::makeArray(); + Int totalObjects = 0; + for (size_t i = 0; i < objects.size(); i++) { + objectResult.push(McpJson::makeObject().set("from", objects[i].from).set("to", objects[i].to).set("count", objectCounts[i])); + totalObjects += objectCounts[i]; + } + for (size_t i = 0; i < roads.size(); i++) { + roadResult.push(McpJson::makeObject().set("from", roads[i].from).set("to", roads[i].to).set("points", roadCounts[i])); + totalObjects += roadCounts[i]; + } + if (totalObjects > 0 && !dryRun) { + undo->addUndoable(modify); + changed = true; + } else { + REF_PTR_RELEASE(modify); + } + + // Textures. + McpJson textureResult = McpJson::makeArray(); + WorldHeightMapEdit *copy = textures.empty() ? nullptr : map->duplicate(); + Int totalCells = 0; + for (size_t i = 0; i < textures.size(); i++) { + const Int cells = replaceTexture(copy, textureClasses[i].first, textureClasses[i].second, dryRun); + if (cells < 0) { + REF_PTR_RELEASE(copy); + REF_PTR_RELEASE(undo); + mcpFail("cannot replace texture '%s' with '%s': the map has no room for the new texture and it needs more tiles than the old one", + textures[i].from.c_str(), textures[i].to.c_str()); + } + textureResult.push(McpJson::makeObject().set("from", textures[i].from).set("to", textures[i].to).set("cells", cells)); + totalCells += cells; + } + if (copy && totalCells > 0 && !dryRun) { + copy->optimizeTiles(); + IRegion2D all = { 0, 0, 0, 0 }; + doc->updateHeightMap(copy, false, all); + undo->addUndoable(new WBDocUndoable(doc, copy)); + changed = true; + } + + if (changed) { + mcpCommit(undo); + } else { + REF_PTR_RELEASE(undo); + } + if (copy) REF_PTR_RELEASE(copy); + if (changed && totalCells > 0) { + TerrainMaterial::updateTextures(doc->GetHeightMap()); + } + + result.set("dry_run", dryRun).set("changed", changed); + result.set("textures", textureResult).set("objects", objectResult).set("roads", roadResult); + return result; +} + +} // namespace + +void mcpRegisterReplaceCommands() +{ + mcpRegisterCommand("map.usage", cmdUsage, "Lists the textures (with cell counts), object templates and road types the map uses, flagging unknown ones."); + mcpRegisterCommand("map.replace", cmdReplace, "{textures?:{from:to}, objects?:{from:to}, roads?:{from:to}, dry_run?} Replaces textures, object templates and road types everywhere."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp index a6c8fd3c939..c297b897fbf 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp @@ -341,30 +341,6 @@ void polygonCentroid(PolygonTrigger *trig, const Frame &frame, double *px, doubl // Undoables //------------------------------------------------------------------------------------------------- -/// Moves and rotates objects in place (map.transform). -class MoveObjectsUndoable : public Undoable -{ -public: - MoveObjectsUndoable(CWorldBuilderDoc *doc) : m_doc(doc), m_list(nullptr), m_tail(nullptr) {} - virtual ~MoveObjectsUndoable() override { delete m_list; } - - void add(MapObject *obj, const Coord3D &loc, Real angle) - { - MoveInfo *info = new MoveInfo(obj); - info->m_newLocation = loc; - info->m_newAngle = angle; - if (m_tail) m_tail->m_next = info; else m_list = info; - m_tail = info; - } - virtual void Do() override { for (MoveInfo *i = m_list; i; i = i->m_next) i->DoMove(m_doc); } - virtual void Undo() override { for (MoveInfo *i = m_list; i; i = i->m_next) i->UndoMove(m_doc); } - -private: - CWorldBuilderDoc *m_doc; - MoveInfo *m_list; - MoveInfo *m_tail; -}; - /// Replaces all points of existing polygons (map.transform). class PolygonPointsUndoable : public Undoable { @@ -800,13 +776,15 @@ McpJson cmdTransform(const McpJson &args) undo->addUndoable(polys); } if (inc.objects || inc.waypoints) { - MoveObjectsUndoable *moves = new MoveObjectsUndoable(doc); + McpModifyObjectsUndoable *moves = new McpModifyObjectsUndoable(doc); for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { if (obj->isWaypoint() ? !inc.waypoints : !inc.objects) continue; Coord3D loc; Real angle; transformObject(obj, frame, m, &loc, &angle); - moves->add(obj, loc, angle); + MoveInfo *info = moves->add(obj); + info->m_newLocation = loc; + info->m_newAngle = angle; objectsMoved++; } undo->addUndoable(moves); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index 0058fd5405c..d345991056f 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -85,6 +85,7 @@ void registerAllCommands() mcpRegisterSkirmishCommands(); mcpRegisterScatterCommands(); mcpRegisterRouteCommands(); + mcpRegisterReplaceCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index d7a98ab82ed..ef2dabd1261 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -70,6 +70,10 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - Player numbers in names and owners are renumbered for each copy. - Cliff texture mapping is reset on copied cells. - The mirroring approach follows the Genesis map tools (The CWC Team, Apache-2.0). +- **Usage and bulk replace.** `map_usage` lists what a map uses; `map_replace` swaps names everywhere in one undo step. + - `map_usage` lists textures, object templates and road types, and flags templates the game does not know. + - `map_replace` takes `{from: to}` mappings for textures (blends are kept), object templates and road types. + - It can also read Genesis-style "from to" list files, and has a dry run. - **Road routing.** `roads_route` finds its own way from A to B, optionally through via points. - It goes around cliffs, water, structures and named areas, prefers gentle slopes and refuses steep steps. - Unneeded bends are removed, but a shortcut is never allowed to be noticeably steeper than the routed path. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index 6282e2ab8ba..b40e54388cc 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -116,6 +116,48 @@ def edit_redo(count: int = 1) -> dict: return wb.call("edit.redo", count=count) +# -------------------------------------------------------------------------------------------------- +# Usage and bulk replace +# -------------------------------------------------------------------------------------------------- + +def _read_mapping_file(path: str) -> dict[str, str]: + """Reads a Genesis-style replace list: one "from to" pair per line.""" + mapping: dict[str, str] = {} + with open(os.path.abspath(path), encoding="utf-8") as f: + for line in f: + parts = line.split(None, 1) + if len(parts) == 2 and not parts[0].startswith("#"): + mapping[parts[0]] = parts[1].strip() + return mapping + + +@mcp.tool() +def map_usage() -> dict: + """Lists what the map uses: textures with cell counts, object templates with counts (known=false + marks templates this game/mod does not have, e.g. in a map made for another mod) and road types. + Use it to build the mappings for map_replace.""" + return wb.call("map.usage") + + +@mcp.tool() +def map_replace(textures: dict[str, str] | None = None, objects: dict[str, str] | None = None, + roads: dict[str, str] | None = None, textures_file: str | None = None, + objects_file: str | None = None, roads_file: str | None = None, dry_run: bool = False) -> dict: + """Replaces textures, object templates and road/bridge types everywhere in the map, each given as + a {from: to} mapping (names as in map_usage; matching ignores case). Textures keep their blends; + objects keep position, rotation, owner and properties; roads keep their corner flags. The + *_file arguments read a text file with one "from to" pair per line (Genesis replace lists). + dry_run only reports how many cells/objects would change. A name cannot be both a source and a + target in one call (run chained or swapped replacements separately). One undo step.""" + def merged(mapping: dict[str, str] | None, path: str | None) -> dict[str, str] | None: + if path is None: + return mapping + return {**_read_mapping_file(path), **(mapping or {})} + + return wb.call("map.replace", textures=merged(textures, textures_file), + objects=merged(objects, objects_file), roads=merged(roads, roads_file), dry_run=dry_run) + + # -------------------------------------------------------------------------------------------------- # Symmetry # -------------------------------------------------------------------------------------------------- diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 0eeb741ba5c..574f791a75a 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -88,6 +88,25 @@ def main() -> int: wb.call("edit.undo") assert wb.call("map.info")["counts"]["road_points"] == road_points + # Usage and bulk replace: swap a texture and an object template, then undo. + usage = wb.call("map.usage") + old_template = templates[0]["name"] + assert any(o["template"] == old_template and o["known"] for o in usage["objects"]), usage + structures = wb.call("objects.list_templates", editor_sorting="STRUCTURE", limit=5)["templates"] + new_template = next(t["name"] for t in structures if t["name"] != old_template) + old_tex, new_tex = textures[0]["name"], textures[2]["name"] + dry = wb.call("map.replace", textures={old_tex: new_tex}, dry_run=True) + assert dry["textures"][0]["cells"] > 0 and not dry["changed"], dry + rep = wb.call("map.replace", textures={old_tex: new_tex}, objects={old_template: new_template}) + assert rep["changed"] and rep["objects"][0]["count"] == 1, rep + after = wb.call("map.usage") + assert not any(t["name"] == old_tex for t in after["textures"]), after["textures"] + assert any(o["template"] == new_template for o in after["objects"]), after["objects"] + wb.call("edit.undo") + restored = wb.call("map.usage") + assert any(t["name"] == old_tex for t in restored["textures"]) + assert any(o["template"] == old_template for o in restored["objects"]) + # Symmetry: mirror the west half onto the east half, then undo. before_wp = len(wb.call("waypoints.list")["waypoints"]) sym = wb.call("map.symmetrize", mode="mirror_x", source="west") From 533636e25b5a4d657a126ed31c00c5e0e8dcd310 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Thu, 1 Oct 2026 07:30:34 +0200 Subject: [PATCH 11/30] feat(worldbuilder): Adds MCP map.generate_starts Places Player__Start waypoints for 2 to 8 players, evenly spaced around the map center on a rectangle inset from the playable edge. Each base area is flattened with a feathered edge and lifted above water. Existing start waypoints are replaced, and the whole operation is one undo step. Co-Authored-By: Claude Opus 5.5 --- .../WorldBuilder/src/mcp/McpCmdSkirmish.cpp | 190 ++++++++++++++++++ scripts/worldbuilder-mcp/README.md | 4 + scripts/worldbuilder-mcp/server.py | 19 ++ scripts/worldbuilder-mcp/smoke_test.py | 10 + 4 files changed, 223 insertions(+) diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp index 9843be924e1..20f74fc9866 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp @@ -447,10 +447,200 @@ McpJson cmdSkirmishSetup(const McpJson &args) return j; } +//------------------------------------------------------------------------------------------------- +// map.generate_starts +//------------------------------------------------------------------------------------------------- + +/// Height of the highest water surface covering a world point, or a negative value if there is none. +double waterHeightAt(double x, double y) +{ + ICoord3D pt; + pt.x = (Int)floor(x + 0.5); + pt.y = (Int)floor(y + 0.5); + pt.z = 0; + double best = -1; + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + if (!trig->isWaterArea() || trig->getNumPoints() < 3) continue; + if (trig->pointInTrigger(pt) && trig->getPoint(0)->z > best) best = trig->getPoint(0)->z; + } + return best; +} + +/// Places Player__Start waypoints at even angles around the map center, on a rectangle inset +/// from the map edges (Genesis StartingPositionGenerator), and levels each base area. +McpJson cmdGenerateStarts(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + WorldHeightMapEdit *map = mcpHeightMap(); + MapBounds bounds; + bounds.init(map); + const Int numPlayers = mcpArgInt(args, "players"); + if (numPlayers < 2 || numPlayers > 8) mcpFail("players must be 2..8"); + const double baseRadius = mcpArgNumber(args, "base_radius", 300); + const double margin = mcpArgNumber(args, "edge_margin", baseRadius + 50); + const double distance = mcpArgNumber(args, "distance", 1.0); + const bool flatten = mcpArgBool(args, "flatten", true); + const double feather = mcpArgNumber(args, "flatten_feather", 150); + const bool replace = mcpArgBool(args, "replace", true); + if (baseRadius < 50) mcpFail("base_radius must be >= 50"); + if (distance <= 0 || distance > 1) mcpFail("distance must be in (0, 1]"); + + const double cx = bounds.maxX / 2, cy = bounds.maxY / 2; + const double hx = cx - margin, hy = cy - margin; + if (hx <= 0 || hy <= 0) { + mcpFail("the map is too small for edge_margin %.0f (playable size %.0f x %.0f)", margin, bounds.maxX, bounds.maxY); + } + // Default: the first player sits toward the south-west corner, which puts two players on a + // diagonal and four players into the corners. + const double firstAngle = args.has("angle_deg") ? mcpArgNumber(args, "angle_deg") * PI / 180.0 : atan2(-hy, -hx); + + std::vector positions; + for (Int i = 0; i < numPlayers; i++) { + const double a = firstAngle + i * 2 * PI / numPlayers; + const double dx = cos(a), dy = sin(a); + // Distance along the ray to the inset rectangle. + const double tx = fabs(dx) > 1e-9 ? hx / fabs(dx) : 1e30; + const double ty = fabs(dy) > 1e-9 ? hy / fabs(dy) : 1e30; + const double t = (tx < ty ? tx : ty) * distance; + Vec2 p = { cx + dx * t, cy + dy * t }; + positions.push_back(p); + } + double closest = 1e30; + for (size_t i = 0; i < positions.size(); i++) { + for (size_t k = i + 1; k < positions.size(); k++) { + const double d = hypot(positions[i].x - positions[k].x, positions[i].y - positions[k].y); + if (d < closest) closest = d; + } + } + if (closest < 2 * baseRadius) { + mcpFail("the starts would be only %.0f apart, less than two base radii (%.0f); use a larger map, fewer players or a smaller base_radius", + closest, 2 * baseRadius); + } + + // Existing start waypoints are replaced. + std::vector oldStarts; + std::set oldIds; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (!obj->isWaypoint()) continue; + int number = 0, consumed = 0; + const AsciiString name = obj->getWaypointName(); + if (sscanf(name.str(), "Player_%d_Start%n", &number, &consumed) == 1 && consumed == name.getLength()) { + oldStarts.push_back(obj); + oldIds.insert(obj->getWaypointID()); + } + } + if (!replace && !oldStarts.empty()) { + mcpFail("the map already has %d start waypoints; pass replace=true to replace them", (int)oldStarts.size()); + } + + MultipleUndoable *undo = new MultipleUndoable; + McpWaypointLinksUndoable *links = new McpWaypointLinksUndoable(doc); + for (Int i = 0; i < doc->getNumWaypointLinks(); i++) { + Int a, b; + doc->getWaypointLink(i, &a, &b); + if (oldIds.count(a) || oldIds.count(b)) links->removed.push_back(std::make_pair(a, b)); + } + undo->addUndoable(links); + + MapObject *head = nullptr, *tail = nullptr; + McpJson starts = McpJson::makeArray(); + std::vector targets(positions.size(), 0); + const Int border = map->getBorderSize(); + for (size_t i = 0; i < positions.size(); i++) { + // Level the base to the average height of the area, but never below the water surface. + double sum = 0; + Int count = 0; + const Int r = (Int)ceil(baseRadius / MAP_XY_FACTOR); + const Int vx = (Int)floor(positions[i].x / MAP_XY_FACTOR + 0.5) + border; + const Int vy = (Int)floor(positions[i].y / MAP_XY_FACTOR + 0.5) + border; + for (Int y = vy - r; y <= vy + r; y++) { + for (Int x = vx - r; x <= vx + r; x++) { + if (x < 0 || y < 0 || x >= map->getXExtent() || y >= map->getYExtent()) continue; + if ((x - vx) * (x - vx) + (y - vy) * (y - vy) > r * r) continue; + sum += map->getHeight(x, y); + count++; + } + } + double target = count ? sum / count : map->getHeight(vx, vy); + const double water = waterHeightAt(positions[i].x, positions[i].y); + if (water >= 0 && target * MAP_HEIGHT_SCALE <= water + 1) { + target = (water + 2) / MAP_HEIGHT_SCALE; + } + targets[i] = floor(target + 0.5); + + Coord3D loc; + loc.x = (Real)positions[i].x; + loc.y = (Real)positions[i].y; + loc.z = 0; + MapObject *way = newInstance(MapObject)(loc, "*Waypoints/Waypoint", 0, 0, nullptr, nullptr); + AsciiString name; + name.format("Player_%d_Start", (Int)i + 1); + way->setIsWaypoint(); + way->setWaypointID(doc->getNextWaypointID()); + way->setWaypointName(name); + way->getProperties()->setAsciiString(TheKey_originalOwner, "team"); + if (tail) tail->setNextMap(way); else head = way; + tail = way; + McpJson s = McpJson::makeObject(); + s.set("player", (Int)i + 1).set("x", positions[i].x).set("y", positions[i].y); + if (flatten) s.set("flattened_to_raw", targets[i]); + starts.push(s); + } + undo->addUndoable(new AddObjectUndoable(doc, head)); + if (!oldStarts.empty()) { + PointerTool::clearSelection(); + for (size_t i = 0; i < oldStarts.size(); i++) oldStarts[i]->setSelected(true); + undo->addUndoable(new DeleteObjectUndoable(doc)); + PointerTool::clearSelection(); + } + + WorldHeightMapEdit *copy = nullptr; + Int flattened = 0; + if (flatten) { + copy = map->duplicate(); + const double reach = baseRadius + feather; + for (size_t i = 0; i < positions.size(); i++) { + const Int r = (Int)ceil(reach / MAP_XY_FACTOR) + 1; + const Int vx = (Int)floor(positions[i].x / MAP_XY_FACTOR + 0.5) + border; + const Int vy = (Int)floor(positions[i].y / MAP_XY_FACTOR + 0.5) + border; + for (Int y = vy - r; y <= vy + r; y++) { + for (Int x = vx - r; x <= vx + r; x++) { + if (x < 0 || y < 0 || x >= map->getXExtent() || y >= map->getYExtent()) continue; + const double d = hypot((x - border) * MAP_XY_FACTOR - positions[i].x, (y - border) * MAP_XY_FACTOR - positions[i].y); + if (d > reach) continue; + const double w = d <= baseRadius ? 1.0 : (feather > 0 ? 1.0 - (d - baseRadius) / feather : 0); + const double cur = copy->getHeight(x, y); + double h = floor(cur + (targets[i] - cur) * w + 0.5); + if (h < 0) h = 0; + if (h > 255) h = 255; + if ((Int)h != copy->getHeight(x, y)) { + copy->setHeight(x, y, (UnsignedByte)h); + flattened++; + } + } + } + } + if (flattened > 0) { + IRegion2D all = { 0, 0, 0, 0 }; + doc->updateHeightMap(copy, false, all); + undo->addUndoable(new WBDocUndoable(doc, copy)); + } + } + mcpCommit(undo); + if (copy) REF_PTR_RELEASE(copy); + mcpResetObjectHandles(); + + McpJson j = McpJson::makeObject(); + j.set("starts", starts).set("closest_distance", closest); + j.set("starts_replaced", (int)oldStarts.size()).set("flattened_vertices", flattened); + return j; +} + } // namespace void mcpRegisterSkirmishCommands() { mcpRegisterCommand("ai.skirmish_setup", cmdSkirmishSetup, "{inner_radius?=350, outer_radius?=600, flank_angle?=70, backdoor_angle?=70, path_points?=5, combat_zone?=true, replace?=true} Creates perimeters and Center/Flank/Backdoor approach paths for every Player__Start."); + mcpRegisterCommand("map.generate_starts", cmdGenerateStarts, "{players, base_radius?=300, edge_margin?, angle_deg?, distance?=1, flatten?=true, flatten_feather?=150, replace?=true} Places Player__Start waypoints evenly around the map center and levels the bases."); mcpRegisterCommand("ai.skirmish_check", cmdSkirmishCheck, "Reports which skirmish AI areas and approach paths each start position has, and what is missing."); } diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index ef2dabd1261..4c064fef96a 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -83,6 +83,10 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - It takes weighted templates and a count or density, and can group objects into noise-driven groves. - It keeps clear of roads, building footprints, start positions, cliffs and water. - Optional filters limit it by texture, slope or named area. +- **Start positions.** `map_generate_starts` places `Player__Start` waypoints for 2 to 8 players, evenly spaced around the map center. + - The first player sits in the lower-left corner unless an angle is given; `distance` pulls all starts toward the center. + - Each base area is flattened with a feathered edge, and lifted above water if needed. + - Existing start waypoints are replaced. It fails if the bases would overlap. - **Skirmish AI.** `ai_skirmish_setup` creates what the AI looks up by name for every `Player__Start`. `ai_skirmish_check` reports what is missing. - Areas: `InnerPerimeter` and `OuterPerimeter`, plus a `CombatZone`. - Paths: approach paths into each base, labelled `Center`, `Flank` and `Backdoor`. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index b40e54388cc..fbbdc361047 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -719,6 +719,25 @@ def roads_route(type: str, from_point: list[float], to_point: list[float], via: # Skirmish AI # -------------------------------------------------------------------------------------------------- +@mcp.tool() +def map_generate_starts(players: int, base_radius: float = 300, edge_margin: float | None = None, + angle_deg: float | None = None, distance: float = 1.0, flatten: bool = True, + flatten_feather: float = 150, replace: bool = True) -> dict: + """Places the start positions for `players` (2..8) as Player__Start waypoints, spaced at even + angles around the map center (Genesis StartingPositionGenerator), so the layout is rotationally + symmetric. Each start lies on a rectangle inset from the playable edge by edge_margin (default + base_radius + 50); distance < 1 pulls them toward the center. angle_deg is the direction of + player 1 from the center (0 = east, counterclockwise); by default it points at the south-west + corner, which puts 2 players on the diagonal and 4 players into the corners. flatten levels each + base (base_radius, plus flatten_feather falloff) to its average height, lifted above water if + needed. replace=true removes existing start waypoints first. Fails if the starts would be closer + than two base radii. Follow with ai_skirmish_setup for the AI data. One undo step; object ids are + invalidated.""" + return wb.call("map.generate_starts", players=players, base_radius=base_radius, edge_margin=edge_margin, + angle_deg=angle_deg, distance=distance, flatten=flatten, flatten_feather=flatten_feather, + replace=replace) + + @mcp.tool() def ai_skirmish_setup(inner_radius: float = 350, outer_radius: float = 600, flank_angle: float = 70, backdoor_angle: float = 70, path_points: int = 5, combat_zone: bool = True, diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 574f791a75a..3fef1559352 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -61,6 +61,16 @@ def main() -> int: if roads: wb.call("roads.add", type=roads[0], points=[[50, 500], [300, 500], [400, 700]]) + # Start positions: two players land on the diagonal, replacing the existing start; then undo. + wp_before = len(wb.call("waypoints.list")["waypoints"]) + gs = wb.call("map.generate_starts", players=2, base_radius=150) + assert len(gs["starts"]) == 2 and gs["starts_replaced"] == 1, gs + s1, s2 = gs["starts"] + assert abs(s1["x"] + s2["x"] - 950) < 1 and abs(s1["y"] + s2["y"] - 950) < 1, gs + assert len(wb.call("waypoints.list")["waypoints"]) == wp_before + 1 + wb.call("edit.undo") + assert len(wb.call("waypoints.list")["waypoints"]) == wp_before + # Skirmish AI data: needs two starts, so add a second one first. wb.call("waypoints.add", x=800, y=800, name="Player_2_Start") assert not wb.call("ai.skirmish_check")["ready"] From a6cd4a952c38329995dfba5a9ad5ccc3ad657876 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Thu, 1 Oct 2026 07:41:59 +0200 Subject: [PATCH 12/30] feat(worldbuilder): Adds MCP map.resize Grows or crops the map, either to a new size with an anchor or by a number of cells per side. Terrain, textures, passability, objects, waypoints, roads, areas, water and build lists keep their place on the terrain. New cells continue the old edge or get a fixed height and texture. Objects and waypoints that fall off the map can be removed, and areas that cover the whole map are refit. One undo step. Adds WorldHeightMapEdit::resizeBySides for this. Unlike resize() it keeps manual passability, updates the map boundaries and never shows a dialog. Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 1 + .../WorldBuilder/include/WHeightMapEdit.h | 5 + .../WorldBuilder/include/mcp/McpCommands.h | 1 + .../Tools/WorldBuilder/src/WHeightMapEdit.cpp | 138 ++++++ .../WorldBuilder/src/mcp/McpCmdResize.cpp | 413 ++++++++++++++++++ .../WorldBuilder/src/mcp/McpCommands.cpp | 1 + scripts/worldbuilder-mcp/README.md | 5 + scripts/worldbuilder-mcp/server.py | 32 ++ scripts/worldbuilder-mcp/smoke_test.py | 24 + 9 files changed, 620 insertions(+) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdResize.cpp diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index 7fd96c6e872..34bf2610625 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -207,6 +207,7 @@ set(WORLDBUILDER_SRC "src/mcp/McpCmdMap.cpp" "src/mcp/McpCmdObjects.cpp" "src/mcp/McpCmdReplace.cpp" + "src/mcp/McpCmdResize.cpp" "src/mcp/McpCmdRoute.cpp" "src/mcp/McpCmdScatter.cpp" "src/mcp/McpCmdSkirmish.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h index 4bb6622733b..b622e1b5a37 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h @@ -162,6 +162,11 @@ class WorldHeightMapEdit : public WorldHeightMap Bool resize(Int newXSize, Int newYSize, Int newHeight, Int newBorder, Bool anchorTop, Bool anchorBottom, Bool anchorLeft, Bool anchorRight, Coord3D *pObjOffset); + /// Changes the map size by adding cells on each side (negative values remove cells). New cells + /// continue the old edge: its height (or fillHeight if that is not negative) and its base texture + /// (or fillTextureClass if that is not negative). + /// Unlike resize() it keeps manual passability and updates the boundaries. Never shows a dialog. + Bool resizeBySides(Int addLeft, Int addBottom, Int addRight, Int addTop, Int newBorder, Int fillHeight, Int fillTextureClass); Bool remapTextures(); ///< returns true if the operation had an effect. void reloadTextures(); ///< Reloads textures from disk. void resetResources(); ///< Releases textures in preparation for device reset. diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index 1dd33938f0e..df395fe58e5 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -58,6 +58,7 @@ void mcpRegisterSkirmishCommands(); void mcpRegisterScatterCommands(); void mcpRegisterRouteCommands(); void mcpRegisterReplaceCommands(); +void mcpRegisterResizeCommands(); //------------------------------------------------------------------------------------------------- // Helpers diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp index b6cc1913d18..b9a0ec999cb 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp @@ -2033,6 +2033,144 @@ Bool WorldHeightMapEdit::resize(Int newXSize, Int newYSize, Int newHeight, Int n return(true); } +static Int clampIndex(Int value, Int maxValue) +{ + if (value < 0) return 0; + if (value > maxValue) return maxValue; + return value; +} + +Bool WorldHeightMapEdit::resizeBySides(Int addLeft, Int addBottom, Int addRight, Int addTop, Int newBorder, Int fillHeight, Int fillTextureClass) +{ + if (newBorder<0) newBorder = 0; + const Int oldPlayableX = m_width - 2*m_borderSize; + const Int oldPlayableY = m_height - 2*m_borderSize; + const Int newPlayableX = oldPlayableX + addLeft + addRight; + const Int newPlayableY = oldPlayableY + addBottom + addTop; + if (newPlayableX < 2 || newPlayableY < 2) { + return(false); + } + const Int newXSize = newPlayableX + 2*newBorder; + const Int newYSize = newPlayableY + 2*newBorder; + const Int newDataSize = newXSize*newYSize; + // Old index of the new index 0. + const Int xOffset = m_borderSize - newBorder - addLeft; + const Int yOffset = m_borderSize - newBorder - addBottom; + + Short *tileNdxes = new Short[newDataSize]; + Short *blendTileNdxes = new Short[newDataSize]; + Short *extraBlendTileNdxes = new Short[newDataSize]; + Short *cliffInfoNdxes = new Short[newDataSize]; + // Same paranoia row as in the constructor. + HeightSampleType *data = new HeightSampleType[newDataSize + newXSize+1]; + memset(data, 0, newDataSize + newXSize+1); + const Int numBytesX = (newXSize+7)/8; //how many bytes to fit all bitflags + UnsignedByte *flipState = new UnsignedByte[numBytesX*newYSize]; + UnsignedByte *cliffState = new UnsignedByte[numBytesX*newYSize]; + memset(flipState, 0, numBytesX*newYSize); + memset(cliffState, 0, numBytesX*newYSize); + + Int i, j; + for (j=0; j=0 && oldI=0 && oldJ=0 && oldI=0 && oldJ>3)] |= bit; + if (getCliffState(oldI, oldJ)) cliffState[j*numBytesX + (i>>3)] |= bit; + } else { + tileNdxes[newNdx] = m_tileNdxes[oldNdx]; + const Int texClass = fillTextureClass >= 0 ? fillTextureClass : getTextureClassFromNdx(m_tileNdxes[oldNdx]); + if (texClass >= 0) { + // Tiles depend on the cell position inside the texture. Use the position the cell + // would have in the old map, so the new cells continue the old tiling. + const Int period = 2*m_globalTextureClasses[texClass].width; + Int tileX = oldI, tileY = oldJ; + if (period > 0) { + tileX = ((oldI % period) + period) % period; + tileY = ((oldJ % period) + period) % period; + } + tileNdxes[newNdx] = getTileNdxForClass(tileX, tileY, texClass); + } + blendTileNdxes[newNdx] = 0; + extraBlendTileNdxes[newNdx] = 0; + cliffInfoNdxes[newNdx] = 0; + } + } + } + + delete[] m_tileNdxes; + delete[] m_cliffInfoNdxes; + delete[] m_blendTileNdxes; + delete[] m_extraBlendTileNdxes; + delete[] m_data; + delete[] m_cellCliffState; + delete[] m_cellFlipState; + const Int oldWidth = m_width; + const Int oldHeight = m_height; + m_tileNdxes = tileNdxes; + m_blendTileNdxes = blendTileNdxes; + m_extraBlendTileNdxes = extraBlendTileNdxes; + m_cliffInfoNdxes = cliffInfoNdxes; + m_data = data; + m_cellFlipState = flipState; + m_cellCliffState = cliffState; + m_flipStateWidth = numBytesX; + m_width = newXSize; + m_height = newYSize; + m_borderSize = newBorder; + m_dataSize = newDataSize; + + // Old cells keep their passability; new cells get it from their heights. + for (j=0; j=oldWidth-1 || oldJ<0 || oldJ>=oldHeight-1) { + setCellCliffFlagFromHeights(i, j); + } + } + } + + // Boundaries are measured from the lower left corner of the playable area. One that covered + // the whole map keeps doing so; the others move with the terrain as far as they can. + for (size_t b=0; b. +*/ + +// McpCmdResize.cpp +// MCP bridge command that grows or crops the map on any side while everything on it stays where +// it is on the terrain (idea from the Resize command of the Genesis map tools, The CWC Team). +// WorldBuilder's own File > Resize moves objects only; here areas, water and build lists move too. + +#include "StdAfx.h" +#include "mcp/McpCommands.h" +#include "mcp/McpUndoables.h" + +#include "PointerTool.h" +#include "TerrainMaterial.h" +#include "WHeightMapEdit.h" +#include "WorldBuilderDoc.h" +#include "wbview.h" +#include "Common/MapObject.h" +#include "GameLogic/PolygonTrigger.h" +#include "GameLogic/SidesList.h" + +#include +#include + +namespace +{ + +// Same limits as map.new. +const Int MIN_MAP_SIZE = 8; +const Int MAX_MAP_SIZE = 1024; +const Int MAX_BORDER = 100; + +//------------------------------------------------------------------------------------------------- +// Undoables +//------------------------------------------------------------------------------------------------- + +/// Swaps in the resized heightmap and makes the views pick up the new map size. +class ResizeHeightMapUndoable : public WBDocUndoable +{ +public: + ResizeHeightMapUndoable(CWorldBuilderDoc *doc, WorldHeightMapEdit *newMap) : WBDocUndoable(doc, newMap), m_doc(doc) {} + + virtual void Do() override + { + WBDocUndoable::Do(); + // Do() sets the heightmap without telling the views. + IRegion2D all = { 0, 0, 0, 0 }; + m_doc->updateHeightMap(m_doc->GetHeightMap(), false, all); + adjustViews(); + } + virtual void Undo() override + { + WBDocUndoable::Undo(); + adjustViews(); + } + virtual void Redo() override + { + WBDocUndoable::Redo(); + adjustViews(); + } + +private: + void adjustViews() + { + POSITION pos = m_doc->GetFirstViewPosition(); + while (pos != nullptr) { + WbView *view = (WbView *)m_doc->GetNextView(pos); + view->adjustDocSize(); + view->Invalidate(); + } + } + + CWorldBuilderDoc *m_doc; +}; + +/// Shifts everything that has a world position: objects, waypoints, road points and build list +/// entries by an offset, areas and water to the points given with addPolygon(). +class OffsetWorldUndoable : public Undoable +{ +public: + OffsetWorldUndoable(CWorldBuilderDoc *doc, Int dx, Int dy) : m_doc(doc), m_dx(dx), m_dy(dy) {} + + void addPolygon(PolygonTrigger *trig, const std::vector &newPoints) + { + PolygonMove entry; + entry.trig = trig; + entry.newPoints = newPoints; + for (Int p = 0; p < trig->getNumPoints(); p++) { + entry.oldPoints.push_back(*trig->getPoint(p)); + } + m_polygons.push_back(entry); + } + + virtual void Do() override { apply(1); } + virtual void Undo() override { apply(-1); } + +private: + struct PolygonMove + { + PolygonTrigger *trig; + std::vector oldPoints, newPoints; + }; + + void apply(Int sign) + { + const Int dx = sign * m_dx, dy = sign * m_dy; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + Coord3D loc = *obj->getLocation(); + loc.x += dx; + loc.y += dy; + obj->setLocation(&loc); + } + for (Int i = 0; i < TheSidesList->getNumSides(); i++) { + for (BuildListInfo *build = TheSidesList->getSideInfo(i)->getBuildList(); build; build = build->getNext()) { + Coord3D loc = *build->getLocation(); + loc.x += dx; + loc.y += dy; + build->setLocation(loc); + } + } + for (size_t i = 0; i < m_polygons.size(); i++) { + const std::vector &pts = sign > 0 ? m_polygons[i].newPoints : m_polygons[i].oldPoints; + for (size_t p = 0; p < pts.size(); p++) { + m_polygons[i].trig->setPoint(pts[p], (Int)p); + } + } + m_doc->invalObject(nullptr); + } + + CWorldBuilderDoc *m_doc; + Int m_dx, m_dy; + std::vector m_polygons; +}; + +/// World extents of a heightmap, border included. +struct Extents +{ + Int minX, minY, maxX, maxY; + + void init(Int width, Int height, Int border) + { + minX = minY = -border * (Int)MAP_XY_FACTOR; + maxX = (width - 1 + border) * (Int)MAP_XY_FACTOR; + maxY = (height - 1 + border) * (Int)MAP_XY_FACTOR; + } + bool contains(double x, double y) const + { + return x >= minX && y >= minY && x <= maxX && y <= maxY; + } +}; + +/// True if every point lies on or beyond a corner of the map, as for the "Default Water" area that +/// WorldBuilder creates around a new map. Such a polygon is meant to cover the map whatever its size. +bool coversWholeMap(PolygonTrigger *trig, const Extents &ext) +{ + if (trig->getNumPoints() < 4) return false; + bool corner[4] = { false, false, false, false }; + for (Int p = 0; p < trig->getNumPoints(); p++) { + const ICoord3D *pt = trig->getPoint(p); + const bool low = pt->x <= ext.minX, right = pt->x >= ext.maxX; + const bool bottom = pt->y <= ext.minY, top = pt->y >= ext.maxY; + if (!(low || right) || !(bottom || top)) return false; + corner[(right ? 1 : 0) + (top ? 2 : 0)] = true; + } + return corner[0] && corner[1] && corner[2] && corner[3]; +} + +//------------------------------------------------------------------------------------------------- +// map.resize +//------------------------------------------------------------------------------------------------- + +bool isRoadPoint(MapObject *obj) +{ + return (obj->getFlags() & (FLAG_ROAD_FLAGS | FLAG_BRIDGE_FLAGS)) != 0; +} + +bool isRoadStart(MapObject *obj) +{ + return (obj->getFlag(FLAG_ROAD_POINT1) || obj->getFlag(FLAG_BRIDGE_POINT1)) && obj->getNext() != nullptr; +} + +/// Splits a size change between the two sides of an axis. anchor is -1 to keep the low side +/// (left or bottom) in place, 1 to keep the high side, 0 to keep the center. +void splitChange(Int change, Int anchor, Int *addLow, Int *addHigh) +{ + if (anchor < 0) { + *addLow = 0; + } else if (anchor > 0) { + *addLow = change; + } else { + *addLow = change / 2; + } + *addHigh = change - *addLow; +} + +McpJson cmdResize(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + WorldHeightMapEdit *map = mcpHeightMap(); + const Int oldBorder = map->getBorderSize(); + const Int oldWidth = map->getXExtent() - 2 * oldBorder; + const Int oldHeight = map->getYExtent() - 2 * oldBorder; + + const bool bySides = args.has("add_left") || args.has("add_right") || args.has("add_bottom") || args.has("add_top"); + const bool bySize = args.has("width") || args.has("height") || args.has("anchor"); + if (bySides && bySize) { + mcpFail("give either width/height with an anchor, or add_left/add_right/add_bottom/add_top, not both"); + } + Int addLeft = 0, addRight = 0, addBottom = 0, addTop = 0; + if (bySides) { + addLeft = mcpArgInt(args, "add_left", 0); + addRight = mcpArgInt(args, "add_right", 0); + addBottom = mcpArgInt(args, "add_bottom", 0); + addTop = mcpArgInt(args, "add_top", 0); + } else { + const std::string anchor = mcpArgString(args, "anchor", "center"); + Int anchorX = 0, anchorY = 0; + if (anchor == "center") { anchorX = 0; anchorY = 0; } + else if (anchor == "left") { anchorX = -1; anchorY = 0; } + else if (anchor == "right") { anchorX = 1; anchorY = 0; } + else if (anchor == "bottom") { anchorX = 0; anchorY = -1; } + else if (anchor == "top") { anchorX = 0; anchorY = 1; } + else if (anchor == "bottom_left") { anchorX = -1; anchorY = -1; } + else if (anchor == "bottom_right") { anchorX = 1; anchorY = -1; } + else if (anchor == "top_left") { anchorX = -1; anchorY = 1; } + else if (anchor == "top_right") { anchorX = 1; anchorY = 1; } + else mcpFail("anchor must be center, left, right, bottom, top, bottom_left, bottom_right, top_left or top_right"); + splitChange(mcpArgInt(args, "width", oldWidth) - oldWidth, anchorX, &addLeft, &addRight); + splitChange(mcpArgInt(args, "height", oldHeight) - oldHeight, anchorY, &addBottom, &addTop); + } + const Int newWidth = oldWidth + addLeft + addRight; + const Int newHeight = oldHeight + addBottom + addTop; + if (newWidth < MIN_MAP_SIZE || newHeight < MIN_MAP_SIZE || newWidth > MAX_MAP_SIZE || newHeight > MAX_MAP_SIZE) { + mcpFail("the new size would be %d x %d cells; width and height must be between %d and %d", + newWidth, newHeight, MIN_MAP_SIZE, MAX_MAP_SIZE); + } + const Int border = mcpArgInt(args, "border", oldBorder); + if (border < 0 || border > MAX_BORDER) { + mcpFail("border must be 0..%d", MAX_BORDER); + } + const Int fillHeight = args.has("fill_height") ? mcpArgInt(args, "fill_height") : -1; + if (args.has("fill_height") && (fillHeight < 0 || fillHeight > 255)) { + mcpFail("fill_height must be 0..255"); + } + Int fillTexture = -1; + if (args.has("fill_texture")) { + fillTexture = mcpFindTextureClass(args, "fill_texture"); + if (!map->isTexClassUsed(fillTexture) && !map->canFitTexture(fillTexture)) { + mcpFail("the map has no room for another texture; use a fill_texture the map already has"); + } + } + const std::string removeOutside = mcpArgString(args, "remove_outside", "none"); + if (removeOutside != "none" && removeOutside != "objects" && removeOutside != "waypoints" && removeOutside != "all") { + mcpFail("remove_outside must be none, objects, waypoints or all"); + } + const bool removeObjects = removeOutside == "objects" || removeOutside == "all"; + const bool removeWaypoints = removeOutside == "waypoints" || removeOutside == "all"; + + const Int offsetX = addLeft * (Int)MAP_XY_FACTOR; + const Int offsetY = addBottom * (Int)MAP_XY_FACTOR; + McpJson j = McpJson::makeObject(); + j.set("width", newWidth).set("height", newHeight).set("border", border); + j.set("added", McpJson::makeObject().set("left", addLeft).set("right", addRight).set("bottom", addBottom).set("top", addTop)); + j.set("world_offset", McpJson::makeObject().set("x", offsetX).set("y", offsetY)); + if (addLeft == 0 && addRight == 0 && addBottom == 0 && addTop == 0 && border == oldBorder) { + j.set("changed", false); + return j; + } + + WorldHeightMapEdit *copy = map->duplicate(); + if (copy == nullptr || !copy->resizeBySides(addLeft, addBottom, addRight, addTop, border, fillHeight, fillTexture)) { + REF_PTR_RELEASE(copy); + mcpFail("the heightmap could not be resized"); + } + + Extents oldExt, newExt; + oldExt.init(oldWidth, oldHeight, oldBorder); + newExt.init(newWidth, newHeight, border); + // Objects that end up outside the new heightmap (border included), judged by their new position. + struct Outside + { + Extents ext; + Int dx, dy; + bool operator()(MapObject *obj) const + { + return !ext.contains(obj->getLocation()->x + dx, obj->getLocation()->y + dy); + } + }; + const Outside outside = { newExt, offsetX, offsetY }; + + Int objectsMoved = 0, removedObjects = 0, removedWaypoints = 0, removedRoadSegments = 0, outsideKept = 0; + std::vector toDelete; + std::set deletedWaypointIds; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + objectsMoved++; + if (isRoadStart(obj)) { + // A road segment is two consecutive objects. It only goes when both ends are outside: + // roads that run off the map are normal. + MapObject *end = obj->getNext(); + objectsMoved++; + if (outside(obj) && outside(end)) { + if (removeObjects) { + toDelete.push_back(obj); + toDelete.push_back(end); + removedRoadSegments++; + } else { + outsideKept += 2; + } + } + obj = end; + continue; + } + if (isRoadPoint(obj) || !outside(obj)) { + continue; + } + if (obj->isWaypoint() ? removeWaypoints : removeObjects) { + toDelete.push_back(obj); + if (obj->isWaypoint()) { + deletedWaypointIds.insert(obj->getWaypointID()); + removedWaypoints++; + } else { + removedObjects++; + } + } else { + outsideKept++; + } + } + objectsMoved -= (Int)toDelete.size(); + + // Areas and water move with the terrain. One that covers the whole map is refit to the new + // map instead, keeping its margin to each edge. + OffsetWorldUndoable *offset = new OffsetWorldUndoable(doc, offsetX, offsetY); + Int areasMoved = 0, areasRefit = 0, areasOutside = 0; + for (PolygonTrigger *trig = PolygonTrigger::getFirstPolygonTrigger(); trig; trig = trig->getNext()) { + const bool refit = coversWholeMap(trig, oldExt); + std::vector pts; + bool anyInside = refit || trig->getNumPoints() == 0; + bool changed = false; + for (Int p = 0; p < trig->getNumPoints(); p++) { + ICoord3D pt = *trig->getPoint(p); + if (refit) { + pt.x = pt.x <= oldExt.minX ? newExt.minX + (pt.x - oldExt.minX) : newExt.maxX + (pt.x - oldExt.maxX); + pt.y = pt.y <= oldExt.minY ? newExt.minY + (pt.y - oldExt.minY) : newExt.maxY + (pt.y - oldExt.maxY); + } else { + pt.x += offsetX; + pt.y += offsetY; + if (newExt.contains(pt.x, pt.y)) anyInside = true; + } + if (pt.x != trig->getPoint(p)->x || pt.y != trig->getPoint(p)->y) changed = true; + pts.push_back(pt); + } + if (changed) { + offset->addPolygon(trig, pts); + if (refit) areasRefit++; else areasMoved++; + } + if (!anyInside) areasOutside++; + } + + // MultipleUndoable runs the step added last first: remove the links and objects that fall off + // the map, swap the heightmap, then shift what is left. Undo runs the other way round. + MultipleUndoable *undo = new MultipleUndoable; + undo->addUndoable(offset); + undo->addUndoable(new ResizeHeightMapUndoable(doc, copy)); + if (!toDelete.empty()) { + PointerTool::clearSelection(); + for (size_t i = 0; i < toDelete.size(); i++) toDelete[i]->setSelected(true); + undo->addUndoable(new DeleteObjectUndoable(doc)); + PointerTool::clearSelection(); + } + if (!deletedWaypointIds.empty()) { + McpWaypointLinksUndoable *links = new McpWaypointLinksUndoable(doc); + for (Int i = 0; i < doc->getNumWaypointLinks(); i++) { + Int a, b; + doc->getWaypointLink(i, &a, &b); + if (deletedWaypointIds.count(a) || deletedWaypointIds.count(b)) links->removed.push_back(std::make_pair(a, b)); + } + undo->addUndoable(links); + } + mcpCommit(undo); + REF_PTR_RELEASE(copy); + TerrainMaterial::updateTextures(doc->GetHeightMap()); + mcpResetObjectHandles(); + + j.set("changed", true); + j.set("objects_moved", offsetX != 0 || offsetY != 0 ? objectsMoved : 0); + j.set("areas_moved", areasMoved).set("areas_refit", areasRefit); + j.set("removed_objects", removedObjects).set("removed_waypoints", removedWaypoints); + j.set("removed_road_segments", removedRoadSegments); + j.set("objects_outside", outsideKept).set("areas_outside", areasOutside); + return j; +} + +} // namespace + +void mcpRegisterResizeCommands() +{ + mcpRegisterCommand("map.resize", cmdResize, "{width?, height?, anchor?=center | add_left?, add_right?, add_bottom?, add_top?, border?, fill_height?, fill_texture?, remove_outside?=none|objects|waypoints|all} Grows or crops the map; terrain, objects and areas keep their place."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index d345991056f..a04b01067b2 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -86,6 +86,7 @@ void registerAllCommands() mcpRegisterScatterCommands(); mcpRegisterRouteCommands(); mcpRegisterReplaceCommands(); + mcpRegisterResizeCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index 4c064fef96a..065b5ef382e 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -70,6 +70,11 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - Player numbers in names and owners are renumbered for each copy. - Cliff texture mapping is reset on copied cells. - The mirroring approach follows the Genesis map tools (The CWC Team, Apache-2.0). +- **Resize.** `map_resize` grows or crops the map, by a new size with an anchor or by cells per side. + - Terrain, textures, passability, objects, waypoints, roads, areas, water and build lists keep their place on the terrain. + - New cells continue the old edge, or get a fixed height and texture. + - Objects and waypoints that fall off the map can be removed. Areas that cover the whole map, like Default Water, are refit. + - Unlike WorldBuilder's own File > Resize it also moves areas and updates the map boundary. - **Usage and bulk replace.** `map_usage` lists what a map uses; `map_replace` swaps names everywhere in one undo step. - `map_usage` lists textures, object templates and road types, and flags templates the game does not know. - `map_replace` takes `{from: to}` mappings for textures (blends are kept), object templates and road types. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index fbbdc361047..67547cfa1f8 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -158,6 +158,38 @@ def merged(mapping: dict[str, str] | None, path: str | None) -> dict[str, str] | objects=merged(objects, objects_file), roads=merged(roads, roads_file), dry_run=dry_run) +# -------------------------------------------------------------------------------------------------- +# Resize +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def map_resize(width: int | None = None, height: int | None = None, + anchor: Literal["center", "left", "right", "bottom", "top", "bottom_left", "bottom_right", + "top_left", "top_right"] | None = None, + add_left: int | None = None, add_right: int | None = None, add_bottom: int | None = None, + add_top: int | None = None, border: int | None = None, fill_height: int | None = None, + fill_texture: str | None = None, + remove_outside: Literal["none", "objects", "waypoints", "all"] = "none") -> dict: + """Grows or crops the map. Terrain, textures, passability, objects, waypoints, roads, areas, water + and build lists keep their place on the terrain, so their world coordinates shift by the returned + world_offset when cells are added or removed on the left or bottom. + Two ways to give the new size (cells, as in map_new): + - width/height with an anchor: the anchored side or corner stays put (default center: the change + is split between both sides). + - add_left/add_right/add_bottom/add_top: cells to add per side; negative values crop. + border changes the border width. New cells continue the old edge, which smears edge detail into + stripes: fill_height (raw 0..255) and fill_texture give them a fixed height and texture instead. + Rework a grown strip afterwards with terrain_generate / terrain_auto_texture limited to a rect. + remove_outside deletes objects and/or waypoints that end up outside the new map (a road segment + only when both ends are outside); by default they are kept and counted in objects_outside. + Areas are never deleted (areas_outside counts those entirely off the map). An area that covers + the whole map, like Default Water, is refit to the new map. Script actions with literal + coordinates are not adjusted. One undo step.""" + return wb.call("map.resize", width=width, height=height, anchor=anchor, add_left=add_left, + add_right=add_right, add_bottom=add_bottom, add_top=add_top, border=border, + fill_height=fill_height, fill_texture=fill_texture, remove_outside=remove_outside) + + # -------------------------------------------------------------------------------------------------- # Symmetry # -------------------------------------------------------------------------------------------------- diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 3fef1559352..484ab941dac 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -117,6 +117,30 @@ def main() -> int: assert any(t["name"] == old_tex for t in restored["textures"]) assert any(o["template"] == old_template for o in restored["objects"]) + # Resize: grow on three sides, everything keeps its place on the terrain; then undo. + info_before = wb.call("map.info") + rs = wb.call("map.resize", add_left=20, add_bottom=10, add_right=4, fill_height=30, + fill_texture=textures[1]["name"]) + assert rs["changed"] and (rs["width"], rs["height"]) == (120, 106), rs + assert rs["world_offset"] == {"x": 200, "y": 100} and rs["areas_refit"] == 1, rs + strip = wb.call("terrain.sample", x=50, y=500) + assert strip["height_raw"] == 30 and strip["texture"] == textures[1]["name"], strip + grown = wb.call("map.info") + assert grown["boundaries"][0] == {"width_cells": 120, "height_cells": 106}, grown + assert wb.call("terrain.sample", x=500, y=400)["height_raw"] == 60 + moved = next(o for o in wb.call("objects.list")["objects"] if o.get("name") == "MyBarracks") + assert abs(moved["x"] - 520) < 0.01 and abs(moved["y"] - 400) < 0.01, moved + wb.call("edit.undo") + assert wb.call("map.info") == info_before + + # Crop to the top right corner: what falls off the map is removed on request and comes back on undo. + crop = wb.call("map.resize", width=40, height=40, anchor="top_right", remove_outside="all") + assert crop["removed_waypoints"] == 3 and crop["removed_objects"] >= 1 and crop["areas_outside"] >= 2, crop + assert not wb.call("waypoints.list")["links"] + wb.call("edit.undo") + assert wb.call("map.info") == info_before + assert len(wb.call("waypoints.list")["links"]) == 1 + # Symmetry: mirror the west half onto the east half, then undo. before_wp = len(wb.call("waypoints.list")["waypoints"]) sym = wb.call("map.symmetrize", mode="mirror_x", source="west") From 89dd0ea2d63365659034ac6f2785f978a12b6b79 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Thu, 1 Oct 2026 22:28:56 +0200 Subject: [PATCH 13/30] worldbuilder mcp stuff --- .../W3DDevice/GameClient/WorldHeightMap.h | 3 +- .../W3DDevice/GameClient/WorldHeightMap.cpp | 7 ++++ .../WorldBuilder/include/WHeightMapEdit.h | 8 ++++- .../WorldBuilder/include/WorldBuilderDoc.h | 2 ++ .../WorldBuilder/include/WorldBuilderView.h | 2 +- .../Code/Tools/WorldBuilder/src/CUndoable.cpp | 36 +++++++++++++++++++ .../Tools/WorldBuilder/src/FeatherTool.cpp | 5 +-- .../WorldBuilder/src/MeshMoldOptions.cpp | 3 +- .../Code/Tools/WorldBuilder/src/RampTool.cpp | 2 +- .../Tools/WorldBuilder/src/WHeightMapEdit.cpp | 29 ++++++++++++--- .../Tools/WorldBuilder/src/WaterOptions.cpp | 3 +- .../Code/Tools/WorldBuilder/src/WaterTool.cpp | 2 +- .../WorldBuilder/src/WorldBuilderDoc.cpp | 3 +- .../WorldBuilder/src/WorldBuilderView.cpp | 11 ++++-- .../Tools/WorldBuilder/src/brushoptions.cpp | 3 +- .../WorldBuilder/src/mcp/McpCmdGenerate.cpp | 11 +++--- .../WorldBuilder/src/mcp/McpCmdImage.cpp | 17 ++++++--- .../Tools/WorldBuilder/src/mcp/McpCmdMap.cpp | 11 +++++- .../WorldBuilder/src/mcp/McpCmdResize.cpp | 4 +-- .../WorldBuilder/src/mcp/McpCmdRoute.cpp | 4 +-- .../WorldBuilder/src/mcp/McpCmdScatter.cpp | 2 +- .../WorldBuilder/src/mcp/McpCmdSkirmish.cpp | 4 +-- .../WorldBuilder/src/mcp/McpCmdTerrain.cpp | 12 ++++--- scripts/worldbuilder-mcp/README.md | 3 ++ scripts/worldbuilder-mcp/server.py | 12 ++++--- scripts/worldbuilder-mcp/smoke_test.py | 33 +++++++++++++++++ 26 files changed, 186 insertions(+), 46 deletions(-) diff --git a/Core/GameEngineDevice/Include/W3DDevice/GameClient/WorldHeightMap.h b/Core/GameEngineDevice/Include/W3DDevice/GameClient/WorldHeightMap.h index 4f469567c8f..5e60e658838 100644 --- a/Core/GameEngineDevice/Include/W3DDevice/GameClient/WorldHeightMap.h +++ b/Core/GameEngineDevice/Include/W3DDevice/GameClient/WorldHeightMap.h @@ -236,7 +236,8 @@ class WorldHeightMap : public RefCountClass, public: // height map info. static Int getMinHeightValue() {return K_MIN_HEIGHT;} - static Int getMaxHeightValue() {return K_MAX_HEIGHT;} + /// Highest value a height sample can have: K_MAX_HEIGHT times the HeightMapScale of the map. + static Int getMaxHeightValue(); HeightSampleType *getDataPtr() {return m_data;} diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/WorldHeightMap.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/WorldHeightMap.cpp index ee68b133297..bedf765cb89 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/WorldHeightMap.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/WorldHeightMap.cpp @@ -2254,6 +2254,13 @@ Bool WorldHeightMap::setDrawArea(const DrawArea& area) return false; } +Int WorldHeightMap::getMaxHeightValue() +{ + // Heights are stored as bytes in the map file and multiplied by the scale when loaded. + const Real scale = TheMapData ? TheMapData->m_HeightmapScale : 1.0f; + return (Int)std::round(K_MAX_HEIGHT * scale); +} + Bool WorldHeightMap::setDrawOrg(Int xOrg, Int yOrg) { return setDrawArea(createDrawArea(xOrg, yOrg)); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h index b622e1b5a37..b5da566c8f4 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h @@ -130,7 +130,13 @@ class WorldHeightMapEdit : public WorldHeightMap void blendTile(Int xIndex, Int yIndex, Int srcXIndex, Int srcYIndex, Int srcClass, Int edgeClass); void autoBlendOut(Int xIndex, Int yIndex, Int edgeIndex = -1); Int getTextureClass(Int xIndex, Int yIndex, Bool baseClass=false); - void setHeight(Int xIndex, Int yIndex, UnsignedByte height); + /// Sets a height, limited to the valid range and snapped with snapHeight(). + void setHeight(Int xIndex, Int yIndex, Int height); + /// Heights are saved as bytes and multiplied by the map's HeightMapScale when loaded, so only + /// multiples of the scale survive a save. Returns the nearest such height in the valid range. + /// A height exactly between two steps moves away from currentHeight, so that a small change + /// has an effect in both directions. + static Int snapHeight(Int height, Int currentHeight); void setCliff(Int xIndex, Int yIndex, Bool impassable) {setCliffState(xIndex, yIndex, impassable);} Bool setTileNdx(Int xIndex, Int yIndex, Int textureClass, Bool singleTile); Bool floodFill(Int xIndex, Int yIndex, Int textureClass, Bool doReplace, Bool askToConfirm = true); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h index 76af410d780..cdc03f01549 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h @@ -152,6 +152,8 @@ class CWorldBuilderDoc : public CDocument /// Makes the next OnNewDocument use these settings instead of asking with the New Map dialog. static void setNewMapOverride(Int xExtent, Int yExtent, Int initialHeight, Int border); + /// Makes MFC treat the next open of the same file as a new document, so it is read again. + void forgetPathName() { m_strPathName.Empty(); } // Operations public: diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderView.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderView.h index bf0a66e963c..5ffccef823f 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderView.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderView.h @@ -79,7 +79,7 @@ class CWorldBuilderView : public WbView void drawMyTexture(CDC *pDc, CRect *pRect, Int width, UnsignedByte *rgbData); /// Get a color for a height value. - DWORD getColorForHeight(UnsignedByte ht); + DWORD getColorForHeight(Int height); /// Draw the contours for the height map in the dc. void drawContours(CDC *pDc, CRgn *pRgn, Int minX, Int maxX, Int minY, Int maxY); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/CUndoable.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/CUndoable.cpp index 93d9ee1ce0e..7075a187d9a 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/CUndoable.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/CUndoable.cpp @@ -201,6 +201,17 @@ void WBDocUndoable::Undo() /************************************************************************* ** AddObjectUndoable ***************************************************************************/ +// +// Returns the number at the end of an object's unique ID ("TreePalm1 12"), or -1 if it has none. +// +static Int getUniqueIDIndex(MapObject *pObj) +{ + Bool exists; + AsciiString uniqueID = pObj->getProperties()->getAsciiString(TheKey_uniqueID, &exists); + const char *lastSpace = uniqueID.reverseFind(' '); + return lastSpace ? atoi(lastSpace) : -1; +} + // // AddObjectUndoable - destructor. // @@ -251,6 +262,21 @@ void AddObjectUndoable::Do() if (pLast==nullptr) { return; } + + // validate() only looks at the object that follows in the list when it picks an ID, so every + // object after the first in a chain added at once would get the same one. Number the chain here, + // counting on from the highest ID already in the map. + Int highestIndex = -1; + for (pCur = MapObject::getFirstMapObject(); pCur; pCur = pCur->getNext()) { + if (pCur->isWaypoint()) { + continue; + } + Int index = getUniqueIDIndex(pCur); + if (index > highestIndex) { + highestIndex = index; + } + } + pLast->setNextMap(MapObject::getFirstMapObject()); MapObject::TheMapObjectListPtr = m_objectToAdd; @@ -262,6 +288,16 @@ void AddObjectUndoable::Do() pCur->validate(); Dict *props = pCur->getProperties(); Bool dontCare; + if (!pCur->isWaypoint()) { + AsciiString uniqueID = props->getAsciiString(TheKey_uniqueID, &dontCare); + const char *lastSpace = uniqueID.reverseFind(' '); + if (lastSpace) { + AsciiString newID; + uniqueID.truncateTo(lastSpace - uniqueID.str()); + newID.format("%s %d", uniqueID.str(), ++highestIndex); + props->setAsciiString(TheKey_uniqueID, newID); + } + } TheLayersList->addMapObjectToLayersList(pCur, props->getAsciiString(TheKey_objectLayer, &dontCare)); m_pDoc->invalObject(pCur); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/FeatherTool.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/FeatherTool.cpp index ce803188223..7870dc981cf 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/FeatherTool.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/FeatherTool.cpp @@ -182,7 +182,8 @@ void FeatherTool::mouseMoved(TTrackingMode m, CPoint viewPt, WbView* pView, CWor rate = 255; redoRate = true; } - m_htMapRateCopy->setHeight(i,j,rate); + // The rate map only borrows the height storage; its values are not heights. + m_htMapRateCopy->setRawHeight(i,j,rate); Int total=0; Real numSamples=0; Int ii, jj; @@ -218,7 +219,7 @@ void FeatherTool::mouseMoved(TTrackingMode m, CPoint viewPt, WbView* pView, CWor } } total = floor((total/numSamples)); - UnsignedByte origHeight = m_htMapFeatherCopy->getHeight(i, j); + Int origHeight = m_htMapFeatherCopy->getHeight(i, j); float rateF = rate/255.0; total = floor(origHeight*(1.0f-rateF) + total*rateF + 0.5f); m_htMapEditCopy->setHeight(i, j, total); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/MeshMoldOptions.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/MeshMoldOptions.cpp index d97e2bfc42d..0d094702bf5 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/MeshMoldOptions.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/MeshMoldOptions.cpp @@ -23,6 +23,7 @@ #include "WorldBuilder.h" #include "WorldBuilderDoc.h" #include "MeshMoldOptions.h" +#include "WHeightMapEdit.h" #include "Common/FileSystem.h" ///////////////////////////////////////////////////////////////////////////// @@ -182,7 +183,7 @@ void MeshMoldOptions::GetPopSliderInfo(const long sliderID, long *pMin, long *pM case IDC_HEIGHT_POPUP: *pMin = MIN_HEIGHT; - *pMax = MAX_HEIGHT; + *pMax = max((Int)MAX_HEIGHT, WorldHeightMap::getMaxHeightValue()); *pInitial = floor(m_currentHeight/MAP_HEIGHT_SCALE+0.5f); *pLineSize = 1; break; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/RampTool.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/RampTool.cpp index 4e2cf875af8..4720f47d585 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/RampTool.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/RampTool.cpp @@ -168,7 +168,7 @@ void RampTool::applyRamp(CWorldBuilderDoc* pDoc) ShortestDistancePointToSegment2D(&start, &end, &pt2D, nullptr, nullptr, &uVal); Real height = mStartPoint.z + uVal * (mEndPoint.z - mStartPoint.z); - worldHeightDup->setHeight(indices[i].x, indices[i].y, (UnsignedByte) (height / MAP_HEIGHT_SCALE)); + worldHeightDup->setHeight(indices[i].x, indices[i].y, (Int) (height / MAP_HEIGHT_SCALE)); } IRegion2D partialRange = {0,0,0,0}; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp index b9a0ec999cb..1b29fdd50ba 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp @@ -593,7 +593,8 @@ void WorldHeightMapEdit::saveToFile(DataChunkOutput &chunkWriter) std::vector binary_data(m_dataSize); const Real f = 1.0f / TheMapData->m_HeightmapScale; for (size_t i = 0; i < m_dataSize; i++) { - binary_data[i] = std::clamp(static_cast(std::round(m_data[i] * f)), static_cast(0U), static_cast(255U)); + // Clamp before narrowing to a byte, otherwise an out of range height wraps around. + binary_data[i] = static_cast(std::clamp(static_cast(std::round(m_data[i] * f)), K_MIN_HEIGHT, K_MAX_HEIGHT)); } chunkWriter.writeArrayOfBytes((char *)&binary_data[0], m_dataSize); @@ -1641,9 +1642,27 @@ void WorldHeightMapEdit::showTileStatusInfo() setHeight This sets the height, and adjusts the cliff flag for the cells affected. */ -void WorldHeightMapEdit::setHeight(Int xIndex, Int yIndex, UnsignedByte height) { +Int WorldHeightMapEdit::snapHeight(Int height, Int currentHeight) +{ + Real scale = TheMapData->m_HeightmapScale; + if (scale <= 0.0f) scale = 1.0f; + const Real stored = height / scale; + Int step = (Int)floor(stored); + const Real fraction = stored - step; + if (fabs(fraction - 0.5f) < 0.001f) { + if (height > currentHeight) step++; + } else if (fraction > 0.5f) { + step++; + } + if (step < K_MIN_HEIGHT) step = K_MIN_HEIGHT; + if (step > K_MAX_HEIGHT) step = K_MAX_HEIGHT; + // Same rounding as when the map is loaded. + return (Int)std::round(step * scale); +} + +void WorldHeightMapEdit::setHeight(Int xIndex, Int yIndex, Int height) { Int ndx = (yIndex*m_width)+xIndex; - if ((ndx>=0) && (ndx=0) && (ndxinitMapStringFile(s); s_mapini.concat("map.ini"); - if (TheFileSystem->doesFileExist(s_mapini.str())) + // No doesFileExist() check: the file system caches that a file is missing, so a map.ini + // created while WorldBuilder runs would never be seen. The load is optional anyway. { INI ini; ini.load(s_mapini, INI_LOAD_MAPDATA_ONLY, nullptr, true); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilderView.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilderView.cpp index 671787ff327..bc5e6898c20 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilderView.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilderView.cpp @@ -186,8 +186,13 @@ void CWorldBuilderView::setShowContours(Bool show) /** Gets the color for a given height in the height map. Used when we are not displaying terrain, and for the contour lines. */ //============================================================================= -DWORD CWorldBuilderView::getColorForHeight(UnsignedByte ht) +DWORD CWorldBuilderView::getColorForHeight(Int height) { + // The color bands cover the whole height range of the map, whatever its HeightMapScale. + const Int maxHeight = WorldHeightMap::getMaxHeightValue(); + Int ht = maxHeight > 0 ? height*0xff/maxHeight : 0; + if (ht < 0) ht = 0; + if (ht > 0xff) ht = 0xff; Int fromR, fromG, fromB; Int toR, toG, toB; Int min, max; @@ -346,7 +351,7 @@ void CWorldBuilderView::OnPaint() rect.bottom = (pMap->getYExtent()-j)*m_cellSize; rect.top = rect.bottom-m_cellSize; for (i=minI; igetHeight(i, j); + Int ht = pMap->getHeight(i, j); rect.left = i*m_cellSize; rect.right = rect.left+m_cellSize; // If this cell is not visible, don't bother drawing it. @@ -552,7 +557,7 @@ void CWorldBuilderView::drawContours(CDC *pDc, CRgn *pRgn, Int minX, Int maxX, I Int curHeight; Bool didWater = false; // We do the water level first, then step through the contours. - for (curHeight = ContourOptions::getContourOffset(); curHeight < 255; + for (curHeight = ContourOptions::getContourOffset(); curHeight < WorldHeightMap::getMaxHeightValue(); curHeight+= ContourOptions::getContourStep()) { Bool doingWater = false; CPen pen; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/brushoptions.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/brushoptions.cpp index bcbb966fa22..1d4a016dbdf 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/brushoptions.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/brushoptions.cpp @@ -25,6 +25,7 @@ #include "brushoptions.h" #include "WorldBuilderView.h" #include "BrushTool.h" +#include "WHeightMapEdit.h" BrushOptions *BrushOptions::m_staticThis = nullptr; Int BrushOptions::m_currentWidth = 0; @@ -196,7 +197,7 @@ void BrushOptions::GetPopSliderInfo(const long sliderID, long *pMin, long *pMax, case IDC_HEIGHT_POPUP: *pMin = 0; - *pMax = 255; + *pMax = WorldHeightMap::getMaxHeightValue(); *pInitial = m_currentHeight; *pLineSize = 1; break; diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp index 5ab2d5590d1..0be75320b3c 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp @@ -65,11 +65,12 @@ struct Area } }; -UnsignedByte clampHeight(double h) +Int clampHeight(double h) { + const Int maxHeight = WorldHeightMap::getMaxHeightValue(); if (h < 0) return 0; - if (h > 255) return 255; - return (UnsignedByte)floor(h + 0.5); + if (h > maxHeight) return maxHeight; + return (Int)floor(h + 0.5); } //------------------------------------------------------------------------------------------------- @@ -150,7 +151,7 @@ McpJson cmdGenerate(const McpJson &args) : noises[i]->fractal(x, y, l.frequency, l.octaves, l.persistence, l.amplitude, l.signedResult); target += v * l.height; } - const UnsignedByte h = clampHeight(cur + (target - cur) * (w > 1 ? 1 : w)); + const Int h = clampHeight(cur + (target - cur) * (w > 1 ? 1 : w)); if (h != map->getHeight(x, y)) { copy->setHeight(x, y, h); changed++; @@ -221,7 +222,7 @@ McpJson cmdLimitSlope(const McpJson &args) Int changed = 0; for (Int y = 0; y < h; y++) { for (Int x = 0; x < w; x++) { - const UnsignedByte nh = clampHeight(heights[y * w + x]); + const Int nh = clampHeight(heights[y * w + x]); if (nh != map->getHeight(x0 + x, y0 + y)) { // setHeight also updates the cliff (impassable) flags from the new slopes. copy->setHeight(x0 + x, y0 + y, nh); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp index 31ad767b6f3..6339527aa12 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp @@ -26,6 +26,7 @@ #include "mcp/McpCommands.h" #include "WHeightMapEdit.h" +#include "Common/MapData.h" #include "WorldBuilderDoc.h" #include "GameLogic/PolygonTrigger.h" @@ -203,7 +204,7 @@ McpJson cmdExportHeightmap(const McpJson &args) path = defaultImagePath("height"); } - Int minH = 255, maxH = 0; + Int minH = WorldHeightMap::getMaxHeightValue(), maxH = 0; for (Int y = 0; y < r.h; y++) { for (Int x = 0; x < r.w; x++) { const Int h = map->getHeight(r.x0 + x, r.y0 + y); @@ -211,12 +212,18 @@ McpJson cmdExportHeightmap(const McpJson &args) if (h > maxH) maxH = h; } } + const Int heightLimit = WorldHeightMap::getMaxHeightValue(); + const double heightScale = TheMapData->m_HeightmapScale > 0 ? TheMapData->m_HeightmapScale : 1.0; std::vector pixels(r.w * r.h); for (Int y = 0; y < r.h; y++) { for (Int x = 0; x < r.w; x++) { Int h = map->getHeight(r.x0 + x, r.y0 + y); if (normalize) { h = maxH > minH ? (h - minH) * 255 / (maxH - minH) : 0; + } else { + // An 8-bit image holds what the map file holds: the height divided by the HeightMapScale. + h = (Int)floor(h / heightScale + 0.5); + if (h > 255) h = 255; } // Row 0 is the north edge. pixels[(r.h - 1 - y) * r.w + x] = (unsigned char)h; @@ -228,6 +235,7 @@ McpJson cmdExportHeightmap(const McpJson &args) j.set("path", path).set("width", r.w).set("height", r.h); j.set("height_raw_min", minH).set("height_raw_max", maxH).set("normalized", normalize); j.set("include_border", includeBorder); + j.set("white_is_raw_height", normalize ? maxH : heightLimit); return j; } @@ -239,7 +247,8 @@ McpJson cmdImportHeightmap(const McpJson &args) mcpFail("mode must be set or add"); } double lo = mode == "set" ? 0 : -64; - double hi = mode == "set" ? 255 : 64; + // By default a white pixel is the highest height the map can store. + double hi = mode == "set" ? 255.0 * (TheMapData->m_HeightmapScale > 0 ? TheMapData->m_HeightmapScale : 1.0) : 64; if (args.has("range")) { const McpJson &range = mcpArgArray(args, "range"); if (range.size() != 2 || !range.at(0).isNumber() || !range.at(1).isNumber()) { @@ -306,8 +315,8 @@ McpJson cmdImportHeightmap(const McpJson &args) if (!touched[y * r.w + x]) continue; double h = floor(heights[y * r.w + x] + 0.5); if (h < 0) h = 0; - if (h > 255) h = 255; - copy->setHeight(r.x0 + x, r.y0 + y, (UnsignedByte)h); + if (h > WorldHeightMap::getMaxHeightValue()) h = WorldHeightMap::getMaxHeightValue(); + copy->setHeight(r.x0 + x, r.y0 + y, (Int)h); } } McpJson j = mcpCommitHeightMapEdit(copy, false); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp index 4c2a7788f99..1ca4bf3cc87 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp @@ -30,6 +30,7 @@ #include "wbview3d.h" #include "Common/GameType.h" #include "Common/GlobalData.h" +#include "Common/MapData.h" #include "Common/MapObject.h" #include "Common/WellKnownKeys.h" #include "GameLogic/PolygonTrigger.h" @@ -56,6 +57,8 @@ McpJson cmdMapInfo(const McpJson &) McpJson grid = McpJson::makeObject(); grid.set("x_extent", xExtent).set("y_extent", yExtent).set("border", border); grid.set("playable_cells_x", xExtent - 1 - 2 * border).set("playable_cells_y", yExtent - 1 - 2 * border); + // HeightMapScale from the map.ini next to the map: raw heights are multiples of it, up to 255 times it. + grid.set("height_map_scale", TheMapData->m_HeightmapScale).set("height_raw_limit", WorldHeightMap::getMaxHeightValue()); j.set("heightmap", grid); McpJson world = McpJson::makeObject(); @@ -66,7 +69,7 @@ McpJson cmdMapInfo(const McpJson &) world.set("cell_size", MAP_XY_FACTOR).set("height_scale", MAP_HEIGHT_SCALE); j.set("world", world); - Int minH = 255, maxH = 0; + Int minH = WorldHeightMap::getMaxHeightValue(), maxH = 0; for (Int y = 0; y < yExtent; y++) { for (Int x = 0; x < xExtent; x++) { Int h = map->getHeight(x, y); @@ -150,6 +153,12 @@ McpJson cmdMapOpen(const McpJson &args) mcpFail("file not found: %s", path.c_str()); } requireUnmodifiedOrDiscard(args); + // MFC only activates a document that is already open under the same path. Forget the path so + // the map (and its map.ini) is really read again. + CWorldBuilderDoc *current = CWorldBuilderDoc::GetActiveDoc(); + if (current != nullptr && current->GetPathName().CompareNoCase(path.c_str()) == 0) { + current->forgetPathName(); + } CDocument *opened = WbApp()->OpenDocumentFile(path.c_str()); mcpResetObjectHandles(); if (opened == nullptr) { diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdResize.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdResize.cpp index 785b000e741..08468a24a4b 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdResize.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdResize.cpp @@ -254,8 +254,8 @@ McpJson cmdResize(const McpJson &args) mcpFail("border must be 0..%d", MAX_BORDER); } const Int fillHeight = args.has("fill_height") ? mcpArgInt(args, "fill_height") : -1; - if (args.has("fill_height") && (fillHeight < 0 || fillHeight > 255)) { - mcpFail("fill_height must be 0..255"); + if (args.has("fill_height") && (fillHeight < 0 || fillHeight > WorldHeightMap::getMaxHeightValue())) { + mcpFail("fill_height must be 0..%d", WorldHeightMap::getMaxHeightValue()); } Int fillTexture = -1; if (args.has("fill_texture")) { diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp index 1a1d4982d3d..4b7324f2e6c 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp @@ -497,9 +497,9 @@ McpJson cmdRoute(const McpJson &args) const double cur = map->getHeight(x, y); double h = floor(cur + (profile[bestNdx] - cur) * w + 0.5); if (h < 0) h = 0; - if (h > 255) h = 255; + if (h > WorldHeightMap::getMaxHeightValue()) h = WorldHeightMap::getMaxHeightValue(); if ((Int)h != map->getHeight(x, y)) { - copy->setHeight(x, y, (UnsignedByte)h); + copy->setHeight(x, y, (Int)h); flattened++; } } diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp index 7c67a4014cd..26a604ab875 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp @@ -266,7 +266,7 @@ McpJson cmdScatter(const McpJson &args) if (avoidCliffs && map->getCliffState(cx, cy)) terrainOk = false; if (terrainOk && avoidWater && mcpIsCellUnderWater(map, cx, cy)) terrainOk = false; if (terrainOk && maxSlope >= 0) { - Int lo = 255, hi = 0; + Int lo = WorldHeightMap::getMaxHeightValue(), hi = 0; for (Int dy = 0; dy <= 1; dy++) { for (Int dx = 0; dx <= 1; dx++) { const Int h = map->getHeight(cx + dx, cy + dy); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp index 20f74fc9866..f9f93b584f0 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp @@ -612,9 +612,9 @@ McpJson cmdGenerateStarts(const McpJson &args) const double cur = copy->getHeight(x, y); double h = floor(cur + (targets[i] - cur) * w + 0.5); if (h < 0) h = 0; - if (h > 255) h = 255; + if (h > WorldHeightMap::getMaxHeightValue()) h = WorldHeightMap::getMaxHeightValue(); if ((Int)h != copy->getHeight(x, y)) { - copy->setHeight(x, y, (UnsignedByte)h); + copy->setHeight(x, y, (Int)h); flattened++; } } diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp index f39da38b76b..f414e268f15 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp @@ -87,11 +87,12 @@ McpJson commitHeightMapEdit(WorldHeightMapEdit *copy, const EditRange &range) return result; } -UnsignedByte clampHeight(double h) +Int clampHeight(double h) { + const Int maxHeight = WorldHeightMap::getMaxHeightValue(); if (h < 0) return 0; - if (h > 255) return 255; - return (UnsignedByte)floor(h + 0.5); + if (h > maxHeight) return maxHeight; + return (Int)floor(h + 0.5); } Int findTextureClass(const McpJson &args, const char *key) @@ -262,7 +263,8 @@ McpJson cmdBrush(const McpJson &args) } next = cur + (sum / 9.0 - cur) * (wgt > 1 ? 1 : wgt); } - UnsignedByte newHeight = clampHeight(next); + // Compare what would really be stored: heights snap to the map's HeightMapScale. + const Int newHeight = WorldHeightMapEdit::snapHeight(clampHeight(next), copy->getHeight(x, y)); if (newHeight != copy->getHeight(x, y)) { copy->setHeight(x, y, newHeight); range.add(x, y); @@ -443,7 +445,7 @@ McpJson mcpCommitHeightMapEdit(WorldHeightMapEdit *copy, bool texturesChanged) void mcpRegisterTerrainCommands() { - mcpRegisterCommand("terrain.get_heights", cmdGetHeights, "{x0?,y0?,w?,h?,step?} Raw vertex heights (0..255) as rows; indices include the border."); + mcpRegisterCommand("terrain.get_heights", cmdGetHeights, "{x0?,y0?,w?,h?,step?} Raw vertex heights (0..255 times the map's HeightMapScale) as rows; indices include the border."); mcpRegisterCommand("terrain.set_heights", cmdSetHeights, "{x0,y0,heights:[[...]]} Writes raw vertex heights; null entries are skipped."); mcpRegisterCommand("terrain.brush", cmdBrush, "{op:raise|lower|set|flatten|smooth|noise, shape, x,y,radius | x0,y0,x1,y1, feather?, amount?, height?, strength?, iterations?, seed?}"); mcpRegisterCommand("terrain.list_textures", cmdListTextures, "{filter?} Available terrain texture classes."); diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index 065b5ef382e..80a48a855fd 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -63,6 +63,9 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - **World coordinates.** One terrain cell is 10 world units. (0,0) is the lower-left corner of the playable area. - **Heights.** Raw heights are 0..255; world height is raw × 0.625. + - A `map.ini` next to the map can set `HeightMapScale` for taller terrain. Raw heights then go up to 255 × scale. + - The map file still stores bytes, so heights are always multiples of the scale. Edits snap to them. + - Heightmap PNGs hold the stored bytes (raw ÷ scale). - **Undo.** WorldBuilder keeps 15 undo steps. Waypoint links are not undoable (same as in the UI). - **Screenshots.** They render the 3D view, so the WorldBuilder window must not be minimized. - **Symmetry.** `map_transform` mirrors or rotates the whole map. `map_symmetrize` copies one half or quarter onto the rest. diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index 67547cfa1f8..372589284cd 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -21,6 +21,8 @@ - World units: 10 per terrain cell. (0,0) is the lower left corner of the playable area; the border extends to negative coordinates. Call map_info for the exact extents. - Heights are raw values 0..255 on the heightmap vertices; world height = raw * 0.625. + A map.ini next to the map can set HeightMapScale (map_info: height_map_scale): raw heights then + go up to 255 * scale (height_raw_limit) and are always multiples of the scale, so edits snap to it. terrain_get_heights/terrain_set_heights use heightmap vertex indices (which include the border); every other command uses world units. - Object angles are in degrees, counterclockwise, 0 = facing +x. @@ -178,7 +180,7 @@ def map_resize(width: int | None = None, height: int | None = None, is split between both sides). - add_left/add_right/add_bottom/add_top: cells to add per side; negative values crop. border changes the border width. New cells continue the old edge, which smears edge detail into - stripes: fill_height (raw 0..255) and fill_texture give them a fixed height and texture instead. + stripes: fill_height (raw) and fill_texture give them a fixed height and texture instead. Rework a grown strip afterwards with terrain_generate / terrain_auto_texture limited to a rect. remove_outside deletes objects and/or waypoints that end up outside the new map (a road segment only when both ends are outside); by default they are kept and counted in objects_outside. @@ -269,7 +271,7 @@ def view_screenshot(x: float | None = None, y: float | None = None, angle_deg: f @mcp.tool() def terrain_get_heights(x0: int = 0, y0: int = 0, w: int | None = None, h: int | None = None, step: int = 1) -> dict: - """Reads raw vertex heights (0..255) as rows (row 0 = y0). Indices are heightmap vertex indices + """Reads raw vertex heights (0..height_raw_limit, see map_info) as rows (row 0 = y0). Indices are heightmap vertex indices including the border; vertex (border, border) is world (0,0). Use step>1 to downsample large areas. At most 65536 values per call.""" return wb.call("terrain.get_heights", x0=x0, y0=y0, w=w, h=h, step=step) @@ -277,7 +279,7 @@ def terrain_get_heights(x0: int = 0, y0: int = 0, w: int | None = None, h: int | @mcp.tool() def terrain_set_heights(x0: int, y0: int, heights: list[list[int | None]]) -> dict: - """Writes raw vertex heights (0..255). heights[r][c] goes to vertex (x0+c, y0+r); null leaves a + """Writes raw vertex heights (0..height_raw_limit, see map_info). heights[r][c] goes to vertex (x0+c, y0+r); null leaves a vertex unchanged. One undo step.""" return wb.call("terrain.set_heights", x0=x0, y0=y0, heights=heights) @@ -479,7 +481,7 @@ def _abspath(path: str | None) -> str | None: def terrain_export_heightmap(path: str | None = None, normalize: bool = False, include_border: bool = True, view: bool = False) -> Any: """Writes the heightmap as an 8-bit grayscale PNG: one pixel per heightmap vertex, pixel value = - raw height 0..255, top row = north edge. normalize stretches the used height range to 0..255 for + raw height 0..255 (divided by the map's HeightMapScale, like in the map file), top row = north edge. normalize stretches the used height range to 0..255 for easier viewing (not for round trips). include_border=false exports only the playable area. view=true also returns the image so you can look at the relief. Default path is a temp file.""" result = wb.call("terrain.export_heightmap", path=_abspath(path), normalize=normalize, @@ -494,7 +496,7 @@ def terrain_import_heightmap(path: str, mode: Literal["set", "add"] = "set", ran channel: Channel = "luma", fit: ImageFit = "stretch", include_border: bool = True, smooth: int = 0) -> dict: """Loads terrain heights from a PNG/BMP/TGA image (8 or 16 bit; top row = north). Pixel 0..max is - mapped onto range=[low, high] raw heights (default [0, 255] for set, [-64, 64] for add). mode=add + mapped onto range=[low, high] raw heights (default [0, height_raw_limit] for set, [-64, 64] for add). mode=add adds the mapped value to the current heights. fit: stretch = resample to the map (bilinear), exact = image must match the vertex grid size, center = place 1:1 in the middle. include_border=false targets only the playable area. smooth = smoothing passes afterwards. diff --git a/scripts/worldbuilder-mcp/smoke_test.py b/scripts/worldbuilder-mcp/smoke_test.py index 484ab941dac..7ef93bd7283 100644 --- a/scripts/worldbuilder-mcp/smoke_test.py +++ b/scripts/worldbuilder-mcp/smoke_test.py @@ -86,6 +86,9 @@ def main() -> int: sc = wb.call("objects.scatter", templates=[trees[0]["name"]], count=25, seed=3, start_clearance=60) assert 0 < sc["placed"] <= 25, sc assert wb.call("map.info")["counts"]["objects"] == objects_before + sc["placed"] + # The scattered objects are added as one chain; each must still get its own unique ID. + ids = [o["unique_id"] for o in wb.call("objects.list", limit=1000)["objects"]] + assert len(ids) == len(set(ids)), sorted(i for i in set(ids) if ids.count(i) > 1) wb.call("edit.undo") assert wb.call("map.info")["counts"]["objects"] == objects_before @@ -197,6 +200,36 @@ def main() -> int: names = [o.get("name") for o in wb.call("objects.list")["objects"]] assert "MyBarracks" in names, names print("saved and reopened:", path, reopened) + + # HeightMapScale: a map.ini next to the map doubles all heights and the height limit. + plain = wb.call("terrain.sample", x=300, y=300)["height_raw"] + assert wb.call("map.info")["heightmap"]["height_raw_limit"] == 255 + map_ini = os.path.join(out_dir, "map.ini") + with open(map_ini, "w") as f: + f.write("MapData\n HeightMapScale = 2.0\nEnd\n") + wb.call("map.open", path=path) + grid = wb.call("map.info")["heightmap"] + assert grid["height_map_scale"] == 2 and grid["height_raw_limit"] == 510, grid + assert wb.call("terrain.sample", x=300, y=300)["height_raw"] == 2 * plain + # Heights above 255 are kept, odd heights snap to the scale, and nothing exceeds the limit. + wb.call("terrain.brush", op="set", x=600, y=300, radius=40, height=401) + assert wb.call("terrain.sample", x=600, y=300)["height_raw"] == 402 + wb.call("terrain.brush", op="raise", x=600, y=300, radius=40, amount=500) + assert wb.call("terrain.sample", x=600, y=300)["height_raw"] == 510 + wb.call("terrain.brush", op="lower", x=600, y=300, radius=40, amount=1) + assert wb.call("terrain.sample", x=600, y=300)["height_raw"] == 508 + scaled_png = os.path.join(out_dir, "scaled.png") + wb.call("terrain.export_heightmap", path=scaled_png) + wb.call("terrain.import_heightmap", path=scaled_png, fit="exact") + assert wb.call("terrain.sample", x=600, y=300)["height_raw"] == 508 + wb.call("map.save") + wb.call("map.open", path=path) + assert wb.call("terrain.sample", x=600, y=300)["height_raw"] == 508 + assert wb.call("terrain.sample", x=300, y=300)["height_raw"] == 2 * plain + os.remove(map_ini) + wb.call("map.open", path=path) + assert wb.call("terrain.sample", x=600, y=300)["height_raw"] == 254 + print("height map scale: ok") print("OK") return 0 From e3088134e818cb94a2ba7ff2d877341f5c0d2e52 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Fri, 9 Oct 2026 21:32:08 +0200 Subject: [PATCH 14/30] fix(filesystem): Skips the Generals install assert when its archives are found locally Self-contained folders (mods, repacks) carry the original Generals archives in a subfolder that the local scan already loads. The assert for a missing registry install path fired there on every start of the game and of WorldBuilder, before -ignoreAsserts takes effect. Co-Authored-By: Claude Opus 5.5 --- .../Win32Device/Common/Win32BIGFileSystem.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp index 64401c47eff..2f38c5000b5 100644 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp +++ b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp @@ -63,8 +63,19 @@ void Win32BIGFileSystem::init() { // load original Generals assets AsciiString installPath; GetStringFromGeneralsRegistry("", "InstallPath", installPath ); + // Self-contained folders (mods, repacks) carry the Generals archives in a subfolder, which the + // scan above already loaded. A missing registry entry is no problem then. INI.big only exists + // in original Generals; Zero Hour's own is INIZH.big. + Bool hasGeneralsArchives = false; + for (ArchiveFileMap::const_iterator it = m_archiveFileMap.begin(); it != m_archiveFileMap.end(); ++it) { + const AsciiString& name = it->first; + if (name.compareNoCase("INI.big") == 0 || name.endsWithNoCase("\\INI.big") || name.endsWithNoCase("/INI.big")) { + hasGeneralsArchives = true; + break; + } + } //@todo this will need to be ramped up to a crash for release - DEBUG_ASSERTCRASH(!installPath.isEmpty(), ("Be 1337! Go install Generals!")); + DEBUG_ASSERTCRASH(!installPath.isEmpty() || hasGeneralsArchives, ("Be 1337! Go install Generals!")); if (!installPath.isEmpty()) loadBigFilesFromDirectory(installPath, "*.big"); #endif From 9108405836d1eb931a6d222f87546928968946a0 Mon Sep 17 00:00:00 2001 From: pWn3d Date: Fri, 9 Oct 2026 21:32:08 +0200 Subject: [PATCH 15/30] feat(worldbuilder): Adds MCP script, team and build list editing Lets the MCP bridge do what the script editor and the Teams dialog do, without their windows. Edits work on a copy of the sides list and commit it as one undo step, like the script editor's OK. They are refused while the script editor window is open, since its OK would overwrite them. Scripts: list, get, add, update, delete, move, copy, groups, the catalog of actions and conditions, the values a parameter accepts, search, rename with references, bulk value edits, batches as one undo step, clone a player's set, compare two sets, JSON dump and load, .scb import and export with optional waypoints, areas and units. Teams: typed properties checked against what the game reads, list, get, set, copy, bulk changes, summary, verification. Checks and views: the script editor's reference checks, missing references grouped by cause with suggestions, dead logic, build order, trace of what a team or script sets in motion, scripts and teams per difficulty, object type stats, player editing and build lists. The Python server adds script templates and a Markdown report. Co-Authored-By: Claude Opus 5.5 --- .../Code/Tools/WorldBuilder/CMakeLists.txt | 5 + .../Tools/WorldBuilder/include/ScriptDialog.h | 3 + .../WorldBuilder/include/mcp/McpCommands.h | 7 + .../include/mcp/McpScriptsShared.h | 116 + .../Tools/WorldBuilder/src/ScriptDialog.cpp | 5 + .../src/mcp/McpCmdScriptTools.cpp | 1868 +++++++++++++ .../src/mcp/McpCmdScriptViews.cpp | 694 +++++ .../WorldBuilder/src/mcp/McpCmdScripts.cpp | 2359 +++++++++++++++++ .../WorldBuilder/src/mcp/McpCmdTeams.cpp | 946 +++++++ .../WorldBuilder/src/mcp/McpCommands.cpp | 16 + scripts/worldbuilder-mcp/README.md | 28 +- .../presets/script_templates.json | 131 + scripts/worldbuilder-mcp/server.py | 714 +++++ scripts/worldbuilder-mcp/smoke_test.py | 198 ++ 14 files changed, 7089 insertions(+), 1 deletion(-) create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpScriptsShared.h create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptTools.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptViews.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScripts.cpp create mode 100644 GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTeams.cpp create mode 100644 scripts/worldbuilder-mcp/presets/script_templates.json diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index 34bf2610625..b27642c37e0 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -198,6 +198,7 @@ set(WORLDBUILDER_SRC "include/mcp/McpCommands.h" "include/mcp/McpJson.h" "include/mcp/McpNoise.h" + "include/mcp/McpScriptsShared.h" "include/mcp/McpShape.h" "include/mcp/McpUndoables.h" "include/mcp/McpScreenshot.h" @@ -210,8 +211,12 @@ set(WORLDBUILDER_SRC "src/mcp/McpCmdResize.cpp" "src/mcp/McpCmdRoute.cpp" "src/mcp/McpCmdScatter.cpp" + "src/mcp/McpCmdScripts.cpp" + "src/mcp/McpCmdScriptTools.cpp" + "src/mcp/McpCmdScriptViews.cpp" "src/mcp/McpCmdSkirmish.cpp" "src/mcp/McpCmdSymmetry.cpp" + "src/mcp/McpCmdTeams.cpp" "src/mcp/McpCmdTerrain.cpp" "src/mcp/McpCmdWaypoints.cpp" "src/mcp/McpCommands.cpp" diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/ScriptDialog.h b/GeneralsMD/Code/Tools/WorldBuilder/include/ScriptDialog.h index d7f51f2d782..8b0fa3ce364 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/ScriptDialog.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/ScriptDialog.h @@ -86,6 +86,9 @@ class ScriptDialog : public CDialog static void patchScriptParametersForGC(Script *pScript); static void checkParametersForGC(); + /// True while the script editor window is shown. It then edits its own copy of the sides list. + static Bool isOpen(); + /// To allow CSDTreeCtrl access to these member functions of ScriptDialog Script *friend_getCurScript(); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h index df395fe58e5..9798c8fc120 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -43,6 +43,9 @@ typedef McpJson (*McpHandler)(const McpJson &args); void mcpRegisterCommand(const char *name, McpHandler handler, const char *help); +/// Runs a registered command from inside another one. Fails like a handler does. +McpJson mcpRunCommand(const std::string &cmd, const McpJson &args); + /// Parses one request line, runs the command and returns the reply line (without newline). std::string mcpDispatch(const std::string &requestLine); @@ -59,6 +62,10 @@ void mcpRegisterScatterCommands(); void mcpRegisterRouteCommands(); void mcpRegisterReplaceCommands(); void mcpRegisterResizeCommands(); +void mcpRegisterScriptCommands(); +void mcpRegisterTeamCommands(); +void mcpRegisterScriptToolCommands(); +void mcpRegisterScriptViewCommands(); //------------------------------------------------------------------------------------------------- // Helpers diff --git a/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpScriptsShared.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpScriptsShared.h new file mode 100644 index 00000000000..a100ec1a5b5 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpScriptsShared.h @@ -0,0 +1,116 @@ +/* +** 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 . +*/ + +// McpScriptsShared.h +// Helpers shared by the MCP bridge commands that edit the sides list: scripts, script groups, +// teams and players. Defined in McpCmdScripts.cpp unless noted. + +#pragma once + +#include "mcp/McpCommands.h" + +#include "Common/Dict.h" +#include "GameLogic/Scripts.h" +#include "GameLogic/SidesList.h" + +#include +#include + +namespace McpScripts +{ + +struct ScriptLoc +{ + Int side; + ScriptGroup *group; ///< Null for a script at the top level of the player. + Script *script; + Int index; ///< Position in its group or in the player's top level list. +}; + +struct GroupLoc +{ + Int side; + ScriptGroup *group; + Int index; +}; + +/// Points the parameter checks of the script editor at a sides list for the lifetime of the +/// object, so script, team and player references are checked against it. +class ParameterCheckScope +{ +public: + ParameterCheckScope(SidesList *sides); + ~ParameterCheckScope(); +}; + +/// Fails while the script editor window is open: it works on its own copy of the sides list +/// and writes it back on OK, which would silently undo an edit made here in the meantime. +void requireScriptEditorClosed(); + +AsciiString sideName(SidesList &sides, Int side); +Int findSide(SidesList &sides, const AsciiString &name); ///< -1 if there is no such player. +Int requireSide(SidesList &sides, const std::string &name); ///< The neutral player is "" or "(neutral)". +ScriptList *requireScriptList(SidesList &sides, Int side); + +std::vector allScripts(SidesList &sides); +GroupLoc requireGroup(SidesList &sides, const std::string &name, Int side); +/// The script named by args.name, narrowed by the optional args.player, args.group and args.script_index. +ScriptLoc requireScript(SidesList &sides, const McpJson &args); + +/// Commits an edited copy of the sides list as one undo step, like the script editor's OK. +void commitSides(SidesList &sides); +/// While a batch runs, commitSides applies the edit without an undo step; the batch records one at its end. +void setSidesBatch(bool active); + +Int findActionType(const char *name); ///< ScriptAction type by internal name, -1 if unknown. +Int findConditionType(const char *name); ///< Condition type by internal name, -1 if unknown. + +const char *parameterTypeName(Parameter::ParameterType type); +Int parameterTypeFromName(const char *name); ///< -1 if unknown. +/// The names of a parameter type that stores a number with a meaning (COMPARISON, KIND_OF_PARAM, ...). +void namedIntNameList(Parameter::ParameterType type, std::vector &names); +McpJson paramValueToJson(const Parameter *parm); +std::string paramValueText(const Parameter *parm); +/// Sets a parameter from JSON; fails with a message that starts with `where`. +void setParamValue(Parameter *parm, const McpJson &in, const char *where, Int ndx); + +/// One parameter of a script and where it sits. +struct ParamUse +{ + Parameter *parm; + bool inCondition; ///< Otherwise in an action or a false action. +}; +void collectParamUses(Script *script, std::vector &out); +/// True for the parameter types whose value is a name or text rather than a number. +bool isStringParamType(Parameter::ParameterType type); +bool scriptNameExists(SidesList &sides, Int side, const std::string &name); + +McpJson describeScript(Script *script); +/// Applies the keys present in a script JSON object; other parts of the script are kept. +void applyScriptJson(Script *script, const McpJson &def); +/// Runs the script editor's parameter checks on one script. Needs a ParameterCheckScope. +void collectScriptWarnings(SidesList &sides, Int side, Script *script, McpJson &out); + +/// Stores one team property with the type the game reads it as (McpCmdTeams.cpp). +void setTeamPropertyChecked(Dict &dict, const std::string &key, const McpJson &value, bool allowUnknown); +/// What a team property names: "script", "object_type", "waypoint", "player", or null (McpCmdTeams.cpp). +const char *teamPropertyRef(const char *key); +/// Checks team properties: types, unit types, scripts and waypoints they name (McpCmdTeams.cpp). +McpJson verifyTeams(SidesList &sides, Int onlySide, const std::string &onlyName, Int *checked); + +} // namespace McpScripts diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/ScriptDialog.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/ScriptDialog.cpp index 3458caf1ab7..8c2b821af6e 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/ScriptDialog.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/ScriptDialog.cpp @@ -307,6 +307,11 @@ ScriptGroup *ScriptDialog::getCurGroup() return nullptr; } +Bool ScriptDialog::isOpen() +{ + return m_staticThis != nullptr && m_staticThis->GetSafeHwnd() != nullptr && m_staticThis->IsWindowVisible(); +} + /** Updates the warning flags in a script, & script conditions & actions. */ void ScriptDialog::updateScriptWarning(Script *pScript) { diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptTools.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptTools.cpp new file mode 100644 index 00000000000..71440e7dcb6 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptTools.cpp @@ -0,0 +1,1868 @@ +/* +** 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 . +*/ + +// McpCmdScriptTools.cpp +// MCP bridge commands that help writing whole script sets: the values a parameter accepts, +// copying scripts and teams, cloning a player's set, renaming things with their references, +// batches, a JSON dump of everything, player editing and a search for dead logic. + +#include "StdAfx.h" +#include "resource.h" +#include "WorldBuilder.h" + +#include "mcp/McpCommands.h" +#include "mcp/McpScriptsShared.h" + +#include "EditParameter.h" +#include "WorldBuilderDoc.h" +#include "Common/NameKeyGenerator.h" +#include "Common/PlayerTemplate.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/ScriptEngine.h" +#include "GameLogic/Scripts.h" +#include "GameLogic/SidesList.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace McpScripts; + +namespace +{ + +//------------------------------------------------------------------------------------------------- +// scripts.list_values +//------------------------------------------------------------------------------------------------- + +/// Fills a combo box the way the parameter dialog of the script editor does for a type. +/// Returns false for types the dialog does not offer a list for. +/// The lists are protected helpers of the dialog, hence the derived class; it is never created. +class EditorChoices : public EditParameter +{ +public: + static bool load(Parameter::ParameterType type, CComboBox *combo) + { + switch (type) { + case Parameter::SCRIPT: loadScripts(combo, false); return true; + case Parameter::SCRIPT_SUBROUTINE: loadScripts(combo, true); return true; + case Parameter::ATTACK_PRIORITY_SET: loadAttackPrioritySets(combo); return true; + case Parameter::WAYPOINT: loadWaypoints(combo); return true; + case Parameter::WAYPOINT_PATH: loadWaypointPaths(combo); return true; + case Parameter::TRIGGER_AREA: loadTriggerAreas(combo); return true; + case Parameter::COMMAND_BUTTON: loadCommandButtons(combo); return true; + case Parameter::FONT_NAME: loadFontNames(combo); return true; + case Parameter::TEAM: loadTeams(combo); return true; + case Parameter::UNIT: loadUnits(combo); return true; + case Parameter::BRIDGE: loadBridges(combo); return true; + case Parameter::OBJECT_TYPE: loadObjectType(combo); return true; + case Parameter::OBJECT_TYPE_LIST: loadObjectTypeList(combo); return true; + case Parameter::SIDE: loadSides(combo); return true; + case Parameter::COUNTER: loadCounters(combo); return true; + case Parameter::FLAG: loadFlags(combo); return true; + case Parameter::SOUND: + case Parameter::DIALOG: + case Parameter::MUSIC: loadAudioType(type, combo); return true; + case Parameter::MOVIE: loadMovies(combo); return true; + case Parameter::SPECIAL_POWER: loadSpecialPowers(combo); return true; + case Parameter::SCIENCE: loadSciences(combo); return true; + case Parameter::SCIENCE_AVAILABILITY: loadScienceAvailabilities(combo); return true; + case Parameter::UPGRADE: loadUpgrades(combo); return true; + // The dialog narrows abilities to one unit; without a unit all of them are valid. + case Parameter::COMMANDBUTTON_ABILITY: + case Parameter::COMMANDBUTTON_ALL_ABILITIES: loadAllAbilities(combo); return true; + case Parameter::EMOTICON: loadEmoticons(combo); return true; + case Parameter::REVEALNAME: loadRevealNames(combo); return true; + case Parameter::OBJECT_PANEL_FLAG: loadObjectFlags(combo); return true; + case Parameter::LOCALIZED_TEXT: loadLocalizedText(combo); return true; + default: return false; + } + } +}; + +/// How to write a value of a type that has no list of choices. +const char *freeFormHelp(Parameter::ParameterType type) +{ + switch (type) { + case Parameter::INT: return "whole number"; + case Parameter::REAL: return "number"; + case Parameter::ANGLE: return "number, degrees"; + case Parameter::PERCENT: return "number, percent (0..100)"; + case Parameter::BOOLEAN: return "true or false"; + case Parameter::COORD3D: return "[x, y, z] in world units"; + case Parameter::COLOR: return "\"#RRGGBB\" or \"#AARRGGBB\""; + case Parameter::BOUNDARY: return "map boundary index, 0 = first"; + case Parameter::TEXT_STRING: return "any text"; + case Parameter::TEAM_STATE: return "any text; a state name the scripts agree on"; + default: return nullptr; + } +} + +/// The values the script editor accepts for a parameter type. Returns false for a type +/// without a list. +bool collectEditorValues(Parameter::ParameterType type, std::vector &values) +{ + namedIntNameList(type, values); + if (values.empty()) { + switch (type) { + case Parameter::OBJECT_STATUS: + for (Int i = 0; i < OBJECT_STATUS_COUNT && ObjectStatusMaskType::getBitNames()[i]; i++) { + values.push_back(ObjectStatusMaskType::getBitNames()[i]); + } + break; + case Parameter::SKIRMISH_WAYPOINT_PATH: { + static const char *const PATHS[] = { "Center", "Backdoor", "Flank", "Special", "Naval", "NavalFlank" }; + values.assign(PATHS, PATHS + ARRAY_SIZE(PATHS)); + break; + } + case Parameter::FACTION_NAME: { + AsciiStringList sideList; + ThePlayerTemplateStore->getAllSideStrings(&sideList); + for (AsciiStringListIterator it = sideList.begin(); it != sideList.end(); ++it) { + values.push_back(it->str()); + } + break; + } + default: { + // The editor's own lists go through a combo box, so borrow a hidden one. + requireScriptEditorClosed(); + CComboBox combo; + if (!combo.Create(WS_CHILD | CBS_DROPDOWNLIST | WS_VSCROLL, CRect(0, 0, 10, 200), AfxGetMainWnd(), 0xE7F0)) { + mcpFail("could not create the list for %s", parameterTypeName(type)); + } + bool loaded; + { + ParameterCheckScope scope(TheSidesList); + loaded = EditorChoices::load(type, &combo); + } + for (Int i = 0; loaded && i < combo.GetCount(); i++) { + CString text; + combo.GetLBText(i, text); + values.push_back((LPCTSTR)text); + } + combo.DestroyWindow(); + return loaded; + } + } + } + return true; +} + +/// The values the script editor offers for a parameter type, e.g. every upgrade or science. +McpJson cmdListValues(const McpJson &args) +{ + mcpDoc(); + const std::string typeName = mcpArgString(args, "param_type"); + const Int typeIndex = parameterTypeFromName(typeName.c_str()); + if (typeIndex < 0) { + mcpFail("unknown param_type '%s' (the types are listed by scripts.list_actions / scripts.list_conditions)", typeName.c_str()); + } + const Parameter::ParameterType type = (Parameter::ParameterType)typeIndex; + const std::string filter = mcpArgString(args, "filter", ""); + const Int limit = mcpArgInt(args, "limit", 100); + const Int offset = mcpArgInt(args, "offset", 0); + + McpJson j = McpJson::makeObject(); + j.set("param_type", parameterTypeName(type)); + if (const char *help = freeFormHelp(type)) { + return j.set("free_form", help); + } + + std::vector values; + if (!collectEditorValues(type, values)) { + mcpFail("no value list for %s", parameterTypeName(type)); + } + + McpJson list = McpJson::makeArray(); + Int matched = 0; + for (size_t i = 0; i < values.size(); i++) { + if (!mcpContainsNoCase(values[i].c_str(), filter.c_str())) continue; + matched++; + if (matched <= offset || (Int)list.size() >= limit) continue; + list.push(McpJson(values[i])); + } + return j.set("total_matches", matched).set("values", list); +} + +//------------------------------------------------------------------------------------------------- +// Copying +//------------------------------------------------------------------------------------------------- + +Int argIndexOrEnd(const McpJson &args) +{ + const Int index = mcpArgInt(args, "index", INT_MAX); + if (index < 0) { + mcpFail("index must be >= 0"); + } + return index; +} + +ScriptGroup *findGroupInSide(SidesList &sides, Int side, const std::string &name) +{ + ScriptList *list = sides.getSideInfo(side)->getScriptList(); + for (ScriptGroup *group = list ? list->getScriptGroup() : nullptr; group; group = group->getNext()) { + if (name == group->getName().str()) return group; + } + return nullptr; +} + +McpJson cmdCopyScript(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const std::string newName = mcpArgString(args, "new_name"); + if (newName.empty()) { + mcpFail("new_name must not be empty"); + } + const Int index = argIndexOrEnd(args); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const ScriptLoc loc = requireScript(sides, args); + + // The copy goes next to the original unless a destination is given. + Int side = args.has("to_player") ? requireSide(sides, mcpArgString(args, "to_player")) : loc.side; + ScriptGroup *group = (side == loc.side) ? loc.group : nullptr; + if (args.has("to_group")) { + const std::string groupName = mcpArgString(args, "to_group"); + group = nullptr; + if (!groupName.empty()) { + group = findGroupInSide(sides, side, groupName); + if (group == nullptr) { + mcpFail("player '%s' has no script group named '%s'", sideName(sides, side).str(), groupName.c_str()); + } + } + } + if (scriptNameExists(sides, side, newName)) { + mcpFail("that player already has a script named '%s'", newName.c_str()); + } + + Script *copy = loc.script->duplicate(); + copy->setName(AsciiString(newName.c_str())); + if (group) { + group->addScript(copy, index); + } else { + requireScriptList(sides, side)->addScript(copy, index); + } + McpJson j = McpJson::makeObject(); + j.set("player", sideName(sides, side).str()); + if (group) j.set("group", group->getName().str()); + j.set("name", newName); + commitSides(sides); + return j; +} + +McpJson cmdCopyTeam(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const std::string name = mcpArgString(args, "name"); + const std::string newName = mcpArgString(args, "new_name"); + if (newName.empty()) { + mcpFail("new_name must not be empty"); + } + SidesList sides = *TheSidesList; + TeamsInfo *team = sides.findTeamInfo(AsciiString(name.c_str())); + if (team == nullptr) { + mcpFail("no team named '%s' (see teams.list)", name.c_str()); + } + if (sides.findTeamInfo(AsciiString(newName.c_str()))) { + mcpFail("a team named '%s' already exists", newName.c_str()); + } + Dict d = *team->getDict(); + d.setAsciiString(TheKey_teamName, AsciiString(newName.c_str())); + d.setBool(TheKey_teamIsSingleton, false); + if (args.has("owner")) { + d.setAsciiString(TheKey_teamOwner, sideName(sides, requireSide(sides, mcpArgString(args, "owner")))); + } + sides.addTeam(&d); + McpJson j = McpJson::makeObject(); + j.set("name", newName).set("owner", d.getAsciiString(TheKey_teamOwner).str()); + commitSides(sides); + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.clone_player +//------------------------------------------------------------------------------------------------- + +/// Ordered substring replacements, applied one after the other. +struct Replacements +{ + std::vector > pairs; + std::vector counts; + + void read(const McpJson &json) + { + if (json.isNull()) return; + if (!json.isObject()) { + mcpFail("'replace' must be an object of {\"old text\": \"new text\"}"); + } + for (size_t i = 0; i < json.numKeys(); i++) { + if (json.keyAt(i).empty() || !json.valueAt(i).isString()) { + mcpFail("'replace' entries need a non-empty key and a string value"); + } + pairs.push_back(std::make_pair(json.keyAt(i), json.valueAt(i).asString())); + counts.push_back(0); + } + } + + AsciiString apply(const AsciiString &in) + { + std::string text = in.str(); + for (size_t i = 0; i < pairs.size(); i++) { + const std::string &from = pairs[i].first; + const std::string &to = pairs[i].second; + size_t pos = 0; + while ((pos = text.find(from, pos)) != std::string::npos) { + text.replace(pos, from.size(), to); + pos += to.size(); + counts[i]++; + } + } + return AsciiString(text.c_str()); + } +}; + +void transformScript(Script *script, Replacements &replacements, const AsciiString &fromPlayer, const AsciiString &toPlayer) +{ + script->setName(replacements.apply(script->getName())); + std::vector uses; + collectParamUses(script, uses); + for (size_t i = 0; i < uses.size(); i++) { + Parameter *parm = uses[i].parm; + if (!isStringParamType(parm->getParameterType())) continue; + if (parm->getParameterType() == Parameter::SIDE && !fromPlayer.isEmpty() && parm->getString() == fromPlayer) { + parm->friend_setString(toPlayer); + continue; + } + parm->friend_setString(replacements.apply(parm->getString())); + } +} + +/// Copies all scripts, groups and teams of one player to another, rewriting names on the way. +/// This is how a faction's AI is derived from an existing one. +McpJson cmdClonePlayer(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const Int from = requireSide(sides, mcpArgString(args, "from")); + const Int to = requireSide(sides, mcpArgString(args, "to")); + if (from == to) { + mcpFail("'from' and 'to' are the same player"); + } + Replacements replacements; + replacements.read(args.get("replace")); + const std::string existing = mcpArgString(args, "existing", "fail"); + if (existing != "fail" && existing != "replace" && existing != "append") { + mcpFail("existing must be fail, replace or append"); + } + const bool includeTeams = mcpArgBool(args, "include_teams", true); + const bool dryRun = mcpArgBool(args, "dry_run", false); + const AsciiString fromName = sideName(sides, from); + const AsciiString toName = sideName(sides, to); + + ScriptList *source = requireScriptList(sides, from); + ScriptList *target = requireScriptList(sides, to); + if (target->getScript() || target->getScriptGroup()) { + if (existing == "fail") { + mcpFail("player '%s' already has scripts; pass existing=\"replace\" to delete them first or \"append\" to keep them", toName.str()); + } + if (existing == "replace") { + while (target->getScript()) target->deleteScript(target->getScript()); + while (target->getScriptGroup()) target->deleteGroup(target->getScriptGroup()); + } + } + + Int numScripts = 0, numGroups = 0, numTeams = 0; + for (Script *script = source->getScript(); script; script = script->getNext()) { + Script *copy = script->duplicate(); + transformScript(copy, replacements, fromName, toName); + target->addScript(copy, INT_MAX); + numScripts++; + } + for (ScriptGroup *group = source->getScriptGroup(); group; group = group->getNext()) { + ScriptGroup *copy = group->duplicate(); + copy->setName(replacements.apply(copy->getName())); + for (Script *script = copy->getScript(); script; script = script->getNext()) { + transformScript(script, replacements, fromName, toName); + numScripts++; + } + target->addGroup(copy, INT_MAX); + numGroups++; + } + + std::vector collisions; + if (includeTeams) { + if (existing == "replace") { + for (Int i = sides.getNumTeams() - 1; i >= 0; i--) { + TeamsInfo *team = sides.getTeamInfo(i); + if (team->getDict()->getAsciiString(TheKey_teamOwner) == toName && !sides.isPlayerDefaultTeam(team)) { + sides.removeTeam(i); + } + } + } + // Adding teams can move the team storage, so copy the dicts first. + std::vector newTeams; + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + if (team->getDict()->getAsciiString(TheKey_teamOwner) != fromName || sides.isPlayerDefaultTeam(team)) continue; + Dict d = *team->getDict(); + for (Int k = 0; k < d.getPairCount(); k++) { + if (d.getNthType(k) != Dict::DICT_ASCIISTRING) continue; + const NameKeyType key = d.getNthKey(k); + if (key == TheKey_teamOwner) continue; + d.setAsciiString(key, replacements.apply(d.getNthAsciiString(k))); + } + d.setAsciiString(TheKey_teamOwner, toName); + newTeams.push_back(d); + } + std::set newNames; + for (size_t i = 0; i < newTeams.size(); i++) { + const AsciiString name = newTeams[i].getAsciiString(TheKey_teamName); + if (sides.findTeamInfo(name) || !newNames.insert(name.str()).second) { + collisions.push_back(name.str()); + } + } + if (!collisions.empty()) { + std::string sample; + for (size_t i = 0; i < collisions.size() && i < 3; i++) { + sample += (i ? ", '" : "'") + collisions[i] + "'"; + } + mcpFail("%d team name(s) would exist twice (%s). Team names are unique in a map, so 'replace' has to change them, e.g. {\"SCM \": \"XYZ \"}", + (int)collisions.size(), sample.c_str()); + } + for (size_t i = 0; i < newTeams.size(); i++) { + sides.addTeam(&newTeams[i]); + numTeams++; + } + } + + // What still points at something that does not exist is the list of things left to adapt. + McpJson scriptWarnings = McpJson::makeArray(); + { + ParameterCheckScope scope(&sides); + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side == to) collectScriptWarnings(sides, to, all[i].script, scriptWarnings); + } + } + Int teamsChecked = 0; + const McpJson teamWarnings = includeTeams ? verifyTeams(sides, to, "", &teamsChecked) : McpJson::makeArray(); + + McpJson applied = McpJson::makeObject(); + for (size_t i = 0; i < replacements.pairs.size(); i++) { + applied.set(replacements.pairs[i].first.c_str(), replacements.counts[i]); + } + McpJson j = McpJson::makeObject(); + j.set("from", fromName.str()).set("to", toName.str()); + j.set("scripts", numScripts).set("groups", numGroups).set("teams", numTeams); + j.set("replacements_applied", applied); + j.set("script_warnings", (int)scriptWarnings.size()).set("team_warnings", (int)teamWarnings.size()); + j.set("dry_run", dryRun); + if (!dryRun) { + commitSides(sides); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.rename +//------------------------------------------------------------------------------------------------- + +/// Replaces an exact value in the team properties of one kind ("script", "object_type", ...). +Int renameInTeams(SidesList &sides, Int onlySide, const char *refKind, const AsciiString &from, const AsciiString &to) +{ + Int changed = 0; + for (Int i = 0; i < sides.getNumTeams(); i++) { + Dict *d = sides.getTeamInfo(i)->getDict(); + if (onlySide >= 0 && d->getAsciiString(TheKey_teamOwner) != sideName(sides, onlySide)) continue; + // Collect first: setting a value re-sorts the pairs. + std::vector keys; + for (Int k = 0; k < d->getPairCount(); k++) { + if (d->getNthType(k) != Dict::DICT_ASCIISTRING || d->getNthAsciiString(k) != from) continue; + const char *ref = teamPropertyRef(TheNameKeyGenerator->keyToName(d->getNthKey(k)).str()); + if (ref && strcmp(ref, refKind) == 0) keys.push_back(d->getNthKey(k)); + } + for (size_t k = 0; k < keys.size(); k++) { + d->setAsciiString(keys[k], to); + changed++; + } + } + return changed; +} + +Int renameInParams(SidesList &sides, Int onlySide, Parameter::ParameterType typeA, Parameter::ParameterType typeB, + const AsciiString &from, const AsciiString &to) +{ + Int changed = 0; + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + if (onlySide >= 0 && all[i].side != onlySide) continue; + std::vector uses; + collectParamUses(all[i].script, uses); + for (size_t k = 0; k < uses.size(); k++) { + Parameter *parm = uses[k].parm; + if (parm->getParameterType() != typeA && parm->getParameterType() != typeB) continue; + if (parm->getString() != from) continue; + parm->friend_setString(to); + changed++; + } + } + return changed; +} + +/// Renames a script, group, team or any named value (flag, counter, object type, ...) together +/// with everything that refers to it. +McpJson cmdRename(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const std::string kind = mcpArgString(args, "kind"); + const std::string fromText = mcpArgString(args, "from"); + const std::string toText = mcpArgString(args, "to"); + if (fromText.empty() || toText.empty() || fromText == toText) { + mcpFail("'from' and 'to' must be two different, non-empty names"); + } + const AsciiString from(fromText.c_str()); + const AsciiString to(toText.c_str()); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + + Int renamed = 0, inScripts = 0, inTeams = 0; + if (kind == "script" || kind == "group") { + // Scripts and groups share one name space: a group can be called like a subroutine. + for (Int side = 0; side < sides.getNumSides(); side++) { + if (onlySide >= 0 && side != onlySide) continue; + Bool hasFrom = false; + const std::vector all = allScripts(sides); + if (kind == "script") { + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side == side && all[i].script->getName() == from) hasFrom = true; + } + } else { + hasFrom = findGroupInSide(sides, side, fromText) != nullptr; + } + if (!hasFrom) continue; + if (scriptNameExists(sides, side, toText) || findGroupInSide(sides, side, toText)) { + mcpFail("player '%s' already has a script or group named '%s'", sideName(sides, side).str(), toText.c_str()); + } + if (kind == "script") { + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side == side && all[i].script->getName() == from) { + all[i].script->setName(to); + renamed++; + } + } + } else { + findGroupInSide(sides, side, fromText)->setName(to); + renamed++; + } + inScripts += renameInParams(sides, side, Parameter::SCRIPT, Parameter::SCRIPT_SUBROUTINE, from, to); + inTeams += renameInTeams(sides, side, "script", from, to); + } + if (renamed == 0) { + mcpFail("no %s named '%s'%s", kind.c_str(), fromText.c_str(), onlySide >= 0 ? " for that player" : ""); + } + } else if (kind == "team") { + TeamsInfo *team = sides.findTeamInfo(from); + if (team == nullptr) { + mcpFail("no team named '%s'", fromText.c_str()); + } + if (sides.isPlayerDefaultTeam(team)) { + mcpFail("'%s' is the default team of its player; it is named after the player", fromText.c_str()); + } + if (sides.findTeamInfo(to)) { + mcpFail("a team named '%s' already exists", toText.c_str()); + } + const Int owned = MapObject::countMapObjectsWithOwner(from); + if (owned > 0) { + mcpFail("%d map object(s) belong to team '%s'; give them another owner first (objects.set_properties)", owned, fromText.c_str()); + } + team->getDict()->setAsciiString(TheKey_teamName, to); + renamed = 1; + inScripts = renameInParams(sides, -1, Parameter::TEAM, Parameter::TEAM, from, to); + } else if (kind == "script_reference" || kind == "team_reference") { + // Points references somewhere else without renaming anything: for names that do not exist. + if (kind == "script_reference") { + inScripts = renameInParams(sides, onlySide, Parameter::SCRIPT, Parameter::SCRIPT_SUBROUTINE, from, to); + inTeams = renameInTeams(sides, onlySide, "script", from, to); + } else { + inScripts = renameInParams(sides, onlySide, Parameter::TEAM, Parameter::TEAM, from, to); + } + if (inScripts + inTeams == 0) { + mcpFail("nothing refers to '%s'%s", fromText.c_str(), onlySide >= 0 ? " for that player" : ""); + } + } else { + const Int typeIndex = parameterTypeFromName(kind.c_str()); + if (typeIndex < 0 || !isStringParamType((Parameter::ParameterType)typeIndex)) { + mcpFail("kind must be script, group, team, script_reference, team_reference or a parameter type that holds a name (FLAG, COUNTER, OBJECT_TYPE, WAYPOINT, UPGRADE, ...)"); + } + const Parameter::ParameterType type = (Parameter::ParameterType)typeIndex; + if (type == Parameter::SCRIPT || type == Parameter::SCRIPT_SUBROUTINE || type == Parameter::TEAM) { + mcpFail("use kind script, group or team to rename those, or script_reference / team_reference to only retarget references"); + } + inScripts = renameInParams(sides, onlySide, type, type, from, to); + // Teams name unit types and waypoints too. + if (type == Parameter::OBJECT_TYPE) inTeams = renameInTeams(sides, onlySide, "object_type", from, to); + if (type == Parameter::WAYPOINT) inTeams = renameInTeams(sides, onlySide, "waypoint", from, to); + if (inScripts + inTeams == 0) { + mcpFail("nothing uses %s '%s'%s", parameterTypeName(type), fromText.c_str(), onlySide >= 0 ? " for that player" : ""); + } + } + + McpJson j = McpJson::makeObject(); + j.set("kind", kind).set("from", fromText).set("to", toText); + j.set("renamed", renamed).set("references_in_scripts", inScripts).set("references_in_teams", inTeams); + commitSides(sides); + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.batch +//------------------------------------------------------------------------------------------------- + +/// Runs several script and team commands as one undo step. If one fails, none is applied. +McpJson cmdBatch(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + (void)doc; + requireScriptEditorClosed(); + const McpJson &commands = mcpArgArray(args, "commands"); + const bool full = mcpArgString(args, "results", "summary") == "full"; + for (size_t i = 0; i < commands.size(); i++) { + const McpJson &entry = commands.at(i); + if (!entry.isObject() || !entry.get("cmd").isString()) { + mcpFail("commands[%d] must be {\"cmd\": \"scripts.add\", \"args\": {...}}", (int)i); + } + const std::string &cmd = entry.get("cmd").asString(); + // Only commands that go through the sides list can be rolled back together. + const bool allowed = (cmd.compare(0, 8, "scripts.") == 0 || cmd.compare(0, 6, "teams.") == 0 + || cmd == "sides.set" || cmd == "sides.delete") && cmd != "scripts.batch"; + if (!allowed) { + mcpFail("commands[%d]: '%s' cannot run in a batch (only scripts.*, teams.*, sides.set and sides.delete)", (int)i, cmd.c_str()); + } + } + + ScriptList::updateDefaults(); + const SidesList before = *TheSidesList; + McpJson results = McpJson::makeArray(); + McpJson warnings = McpJson::makeArray(); + setSidesBatch(true); + size_t i = 0; + try { + for (; i < commands.size(); i++) { + const McpJson &entry = commands.at(i); + McpJson commandArgs = entry.get("args"); + if (commandArgs.isNull()) commandArgs = McpJson::makeObject(); + const McpJson result = mcpRunCommand(entry.get("cmd").asString(), commandArgs); + if (full) { + results.push(result); + } + const McpJson &commandWarnings = result.get("warnings"); + for (size_t w = 0; w < commandWarnings.size(); w++) { + McpJson warning = commandWarnings.at(w); + if (warning.isObject()) warning.set("command", (int)i); + warnings.push(warning); + } + } + } catch (const McpError &err) { + setSidesBatch(false); + *TheSidesList = before; + mcpFail("commands[%d] (%s) failed, nothing was changed: %s", (int)i, commands.at(i).get("cmd").asString().c_str(), + err.message.c_str()); + } + setSidesBatch(false); + + // Hand the result to the undo system as a single step. + SidesList after = *TheSidesList; + *TheSidesList = before; + commitSides(after); + + McpJson j = McpJson::makeObject(); + j.set("commands_run", (int)commands.size()).set("warnings", warnings); + if (full) { + j.set("results", results); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.dump / scripts.load +//------------------------------------------------------------------------------------------------- + +McpJson dumpScripts(Script *first) +{ + McpJson list = McpJson::makeArray(); + for (Script *script = first; script; script = script->getNext()) { + list.push(describeScript(script)); + } + return list; +} + +/// Everything about the scripts and teams of the map (or one player) as one JSON document, +/// in the form scripts.load reads. +McpJson cmdDumpScripts(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + const bool includeTeams = mcpArgBool(args, "include_teams", true); + + McpJson players = McpJson::makeArray(); + Int numScripts = 0; + for (Int side = 0; side < sides.getNumSides(); side++) { + if (onlySide >= 0 && side != onlySide) continue; + ScriptList *list = sides.getSideInfo(side)->getScriptList(); + McpJson player = McpJson::makeObject(); + player.set("player", sideName(sides, side).str()); + player.set("properties", mcpDictToJson(*sides.getSideInfo(side)->getDict())); + const McpJson scripts = dumpScripts(list ? list->getScript() : nullptr); + numScripts += (Int)scripts.size(); + player.set("scripts", scripts); + McpJson groups = McpJson::makeArray(); + for (ScriptGroup *group = list ? list->getScriptGroup() : nullptr; group; group = group->getNext()) { + const McpJson groupScripts = dumpScripts(group->getScript()); + numScripts += (Int)groupScripts.size(); + groups.push(McpJson::makeObject() + .set("name", group->getName().str()) + .set("active", (bool)(group->isActive() != 0)) + .set("subroutine", (bool)(group->isSubroutine() != 0)) + .set("scripts", groupScripts)); + } + player.set("groups", groups); + players.push(player); + } + + McpJson j = McpJson::makeObject(); + j.set("format", "worldbuilder-scripts-1").set("script_count", numScripts).set("players", players); + if (includeTeams) { + McpJson teams = McpJson::makeArray(); + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + if (sides.isPlayerDefaultTeam(team)) continue; + if (onlySide >= 0 && team->getDict()->getAsciiString(TheKey_teamOwner) != sideName(sides, onlySide)) continue; + teams.push(mcpDictToJson(*team->getDict())); + } + j.set("teams", teams); + } + return j; +} + +/// Builds the scripts of a JSON list into `add`, naming the script in any error. +template +Int loadScriptsInto(T *owner, const McpJson &list, const char *playerName) +{ + if (!list.isArray()) { + mcpFail("player '%s': 'scripts' must be an array", playerName); + } + for (size_t i = 0; i < list.size(); i++) { + const McpJson &def = list.at(i); + Script *script = newInstance(Script); + OrCondition *orCond = newInstance(OrCondition); + orCond->setFirstAndCondition(newInstance(Condition)(Condition::CONDITION_TRUE)); + script->setOrCondition(orCond); + owner->addScript(script, INT_MAX); + try { + if (!def.isObject()) mcpFail("must be an object"); + applyScriptJson(script, def); + } catch (const McpError &err) { + mcpFail("player '%s', script %d '%s': %s", playerName, (int)i, def.get("name").isString() ? def.get("name").asString().c_str() : "?", + err.message.c_str()); + } + } + return (Int)list.size(); +} + +/// Builds the scripts and groups of one player entry of a dump document. +ScriptList *buildScriptList(const McpJson &entry, const char *playerName, Int *numScripts, Int *numGroups) +{ + ScriptList *list = newInstance(ScriptList); + try { + if (entry.has("scripts")) { + *numScripts += loadScriptsInto(list, entry.get("scripts"), playerName); + } + const McpJson &groups = entry.get("groups"); + if (!groups.isNull() && !groups.isArray()) { + mcpFail("player '%s': 'groups' must be an array", playerName); + } + for (size_t g = 0; g < groups.size(); g++) { + const McpJson &groupDef = groups.at(g); + if (!groupDef.isObject() || !groupDef.get("name").isString()) { + mcpFail("player '%s': groups[%d] needs a 'name'", playerName, (int)g); + } + ScriptGroup *group = newInstance(ScriptGroup); + group->setName(AsciiString(groupDef.get("name").asString().c_str())); + group->setActive(mcpArgBool(groupDef, "active", true)); + group->setSubroutine(mcpArgBool(groupDef, "subroutine", false)); + list->addGroup(group, INT_MAX); + if (groupDef.has("scripts")) { + *numScripts += loadScriptsInto(group, groupDef.get("scripts"), playerName); + } + (*numGroups)++; + } + } catch (...) { + deleteInstance(list); + throw; + } + return list; +} + +/// Replaces the scripts (and teams) of every player in a scripts.dump document with its contents. +McpJson cmdLoadScripts(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const McpJson &data = args.get("data"); + if (!data.isObject() || !data.get("players").isArray()) { + mcpFail("'data' must be a scripts.dump document with a 'players' array"); + } + const bool createPlayers = mcpArgBool(args, "create_players", true); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + + const McpJson &players = data.get("players"); + std::set loadedPlayers; + Int numScripts = 0, numGroups = 0, numTeams = 0, playersAdded = 0; + for (size_t p = 0; p < players.size(); p++) { + const McpJson &entry = players.at(p); + if (!entry.isObject() || !entry.get("player").isString()) { + mcpFail("players[%d] needs a 'player' name", (int)p); + } + const AsciiString playerName(entry.get("player").asString().c_str()); + Int side = findSide(sides, playerName); + if (side < 0) { + if (!createPlayers || !entry.get("properties").isObject()) { + mcpFail("the map has no player '%s' (and the document has no 'properties' to create it from)", playerName.str()); + } + if (sides.getNumSides() >= MAX_PLAYER_COUNT) { + mcpFail("cannot add player '%s': the map has the maximum number of players", playerName.str()); + } + Dict d; + mcpJsonToDict(entry.get("properties"), d); + d.setAsciiString(TheKey_playerName, playerName); + sides.addSide(&d); + side = findSide(sides, playerName); + playersAdded++; + } + if (!loadedPlayers.insert(playerName.str()).second) { + mcpFail("player '%s' is in the document twice", playerName.str()); + } + + // The new list replaces the old one only once it is complete. + ScriptList *list = buildScriptList(entry, playerName.str(), &numScripts, &numGroups); + deleteInstance(sides.getSideInfo(side)->getScriptList()); + sides.getSideInfo(side)->setScriptList(list); + } + + if (data.has("teams")) { + const McpJson &teams = data.get("teams"); + if (!teams.isArray()) { + mcpFail("'teams' must be an array of team property objects"); + } + // The loaded players' teams are replaced like their scripts. + for (Int i = sides.getNumTeams() - 1; i >= 0; i--) { + TeamsInfo *team = sides.getTeamInfo(i); + if (sides.isPlayerDefaultTeam(team)) continue; + if (loadedPlayers.count(team->getDict()->getAsciiString(TheKey_teamOwner).str())) { + sides.removeTeam(i); + } + } + for (size_t t = 0; t < teams.size(); t++) { + const McpJson &props = teams.at(t); + if (!props.isObject() || !props.get("teamName").isString() || !props.get("teamOwner").isString()) { + mcpFail("teams[%d] needs teamName and teamOwner", (int)t); + } + const std::string &teamName = props.get("teamName").asString(); + if (!loadedPlayers.count(props.get("teamOwner").asString())) { + mcpFail("team '%s': its owner '%s' is not one of the players in the document", teamName.c_str(), + props.get("teamOwner").asString().c_str()); + } + if (sides.findTeamInfo(AsciiString(teamName.c_str()))) { + mcpFail("team '%s' exists twice (team names are unique in a map)", teamName.c_str()); + } + Dict d; + try { + for (size_t k = 0; k < props.numKeys(); k++) { + setTeamPropertyChecked(d, props.keyAt(k), props.valueAt(k), true); + } + } catch (const McpError &err) { + mcpFail("team '%s': %s", teamName.c_str(), err.message.c_str()); + } + sides.addTeam(&d); + numTeams++; + } + } + + McpJson j = McpJson::makeObject(); + j.set("players_loaded", (int)players.size()).set("players_added", playersAdded); + j.set("scripts", numScripts).set("groups", numGroups).set("teams", numTeams); + commitSides(sides); + return j; +} + +//------------------------------------------------------------------------------------------------- +// sides.set / sides.delete +//------------------------------------------------------------------------------------------------- + +/// Replaces one name in a space separated list of names (playerAllies, playerEnemies). +AsciiString replaceInNameList(const AsciiString &list, const AsciiString &from, const AsciiString &to) +{ + std::string out; + std::string text = list.str(); + size_t pos = 0; + while (pos < text.size()) { + size_t end = text.find(' ', pos); + if (end == std::string::npos) end = text.size(); + std::string token = text.substr(pos, end - pos); + if (!token.empty()) { + if (token == from.str()) token = to.str(); + if (!token.empty()) { + if (!out.empty()) out += " "; + out += token; + } + } + pos = end + 1; + } + return AsciiString(out.c_str()); +} + +Int objectsOwnedByPlayer(SidesList &sides, const AsciiString &playerName) +{ + Int owned = 0; + for (Int i = 0; i < sides.getNumTeams(); i++) { + Dict *d = sides.getTeamInfo(i)->getDict(); + if (d->getAsciiString(TheKey_teamOwner) == playerName) { + owned += MapObject::countMapObjectsWithOwner(d->getAsciiString(TheKey_teamName)); + } + } + return owned; +} + +/// Changes a player's properties or its name (with the references to the name). +McpJson cmdSetSide(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "name")); + Dict *dict = sides.getSideInfo(side)->getDict(); + const AsciiString oldName = sideName(sides, side); + + if (args.has("properties")) { + const McpJson &props = args.get("properties"); + if (props.has("playerName")) { + mcpFail("use new_name to rename a player; its teams and scripts refer to the name"); + } + mcpJsonToDict(props, *dict); + } + + Int references = 0; + if (args.has("new_name")) { + const AsciiString newName(mcpArgString(args, "new_name").c_str()); + if (oldName.isEmpty()) { + mcpFail("the neutral player cannot be renamed"); + } + if (newName.isEmpty() || findSide(sides, newName) >= 0) { + mcpFail("the new player name must be unique and not empty"); + } + const Int owned = objectsOwnedByPlayer(sides, oldName); + if (owned > 0) { + mcpFail("%d map object(s) belong to teams of '%s'; object owners are team names, so give them another owner first", owned, oldName.str()); + } + // The default team is named after its player. + AsciiString oldTeam, newTeam; + oldTeam.format("team%s", oldName.str()); + newTeam.format("team%s", newName.str()); + if (sides.findTeamInfo(newTeam)) { + mcpFail("a team named '%s' already exists", newTeam.str()); + } + dict->setAsciiString(TheKey_playerName, newName); + for (Int i = 0; i < sides.getNumTeams(); i++) { + Dict *d = sides.getTeamInfo(i)->getDict(); + if (d->getAsciiString(TheKey_teamOwner) == oldName) d->setAsciiString(TheKey_teamOwner, newName); + if (d->getAsciiString(TheKey_teamName) == oldTeam) d->setAsciiString(TheKey_teamName, newTeam); + } + for (Int i = 0; i < sides.getNumSides(); i++) { + Dict *d = sides.getSideInfo(i)->getDict(); + d->setAsciiString(TheKey_playerAllies, replaceInNameList(d->getAsciiString(TheKey_playerAllies), oldName, newName)); + d->setAsciiString(TheKey_playerEnemies, replaceInNameList(d->getAsciiString(TheKey_playerEnemies), oldName, newName)); + } + references += renameInParams(sides, -1, Parameter::SIDE, Parameter::SIDE, oldName, newName); + references += renameInParams(sides, -1, Parameter::TEAM, Parameter::TEAM, oldTeam, newTeam); + } + + McpJson j = McpJson::makeObject(); + j.set("player", sideName(sides, side).str()).set("references_in_scripts", references); + j.set("properties", mcpDictToJson(*sides.getSideInfo(side)->getDict())); + commitSides(sides); + return j; +} + +McpJson cmdDeleteSide(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "name")); + const AsciiString name = sideName(sides, side); + if (name.isEmpty()) { + mcpFail("the neutral player cannot be deleted"); + } + const Int owned = objectsOwnedByPlayer(sides, name); + if (owned > 0) { + mcpFail("%d map object(s) belong to teams of '%s'; give them another owner or delete them first", owned, name.str()); + } + Int numScripts = 0, numTeams = 0; + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side == side) numScripts++; + } + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + if (team->getDict()->getAsciiString(TheKey_teamOwner) == name && !sides.isPlayerDefaultTeam(team)) numTeams++; + } + if ((numScripts > 0 || numTeams > 0) && !mcpArgBool(args, "force", false)) { + mcpFail("player '%s' has %d script(s) and %d team(s); pass force=true to delete them with it", name.str(), numScripts, numTeams); + } + for (Int i = sides.getNumTeams() - 1; i >= 0; i--) { + if (sides.getTeamInfo(i)->getDict()->getAsciiString(TheKey_teamOwner) == name) { + sides.removeTeam(i); + } + } + sides.removeSide(side); + for (Int i = 0; i < sides.getNumSides(); i++) { + Dict *d = sides.getSideInfo(i)->getDict(); + d->setAsciiString(TheKey_playerAllies, replaceInNameList(d->getAsciiString(TheKey_playerAllies), name, AsciiString::TheEmptyString)); + d->setAsciiString(TheKey_playerEnemies, replaceInNameList(d->getAsciiString(TheKey_playerEnemies), name, AsciiString::TheEmptyString)); + } + McpJson j = McpJson::makeObject(); + j.set("deleted", name.str()).set("scripts_deleted", numScripts).set("teams_deleted", numTeams); + commitSides(sides); + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.analyze +//------------------------------------------------------------------------------------------------- + +void pushCapped(McpJson &list, Int limit, const AsciiString &player, const char *name) +{ + if ((Int)list.size() < limit) { + list.push(McpJson::makeObject().set("player", player.str()).set("name", name)); + } +} + +/// Looks for dead logic that the reference checks of scripts.verify cannot see. +McpJson cmdAnalyzeScripts(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + const Int limit = mcpArgInt(args, "limit", 40); + + McpJson unusedSubroutines = McpJson::makeArray(), neverEnabled = McpJson::makeArray(), noActions = McpJson::makeArray(); + McpJson flagsNeverSet = McpJson::makeArray(), flagsNeverRead = McpJson::makeArray(); + McpJson countersNeverSet = McpJson::makeArray(), countersNeverRead = McpJson::makeArray(); + McpJson emptyGroups = McpJson::makeArray(), unusedTeams = McpJson::makeArray(); + Int counts[9] = { 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + + // Teams named by any script of the map. + std::set teamsNamed; + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + std::vector uses; + collectParamUses(all[i].script, uses); + for (size_t k = 0; k < uses.size(); k++) { + if (uses[k].parm->getParameterType() == Parameter::TEAM) teamsNamed.insert(uses[k].parm->getString().str()); + } + } + + for (Int side = 0; side < sides.getNumSides(); side++) { + if (onlySide >= 0 && side != onlySide) continue; + const AsciiString player = sideName(sides, side); + + // What the scripts and teams of this player call, set and read. + std::set called, flagsSet, flagsRead, countersSet, countersRead; + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side != side) continue; + std::vector uses; + collectParamUses(all[i].script, uses); + for (size_t k = 0; k < uses.size(); k++) { + const Parameter *parm = uses[k].parm; + const std::string value = parm->getString().str(); + switch (parm->getParameterType()) { + case Parameter::SCRIPT: + case Parameter::SCRIPT_SUBROUTINE: + called.insert(value); + break; + case Parameter::FLAG: + (uses[k].inCondition ? flagsRead : flagsSet).insert(value); + break; + case Parameter::COUNTER: + (uses[k].inCondition ? countersRead : countersSet).insert(value); + break; + default: + break; + } + } + } + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + Dict *d = team->getDict(); + if (d->getAsciiString(TheKey_teamOwner) != player) continue; + Bool hasCondition = false; + for (Int k = 0; k < d->getPairCount(); k++) { + if (d->getNthType(k) != Dict::DICT_ASCIISTRING || d->getNthAsciiString(k).isEmpty()) continue; + const char *ref = teamPropertyRef(TheNameKeyGenerator->keyToName(d->getNthKey(k)).str()); + if (ref == nullptr || strcmp(ref, "script") != 0) continue; + called.insert(d->getNthAsciiString(k).str()); + if (d->getNthKey(k) == TheKey_teamProductionCondition) hasCondition = true; + } + // A team the AI is never told to build and no script ever names. + const AsciiString teamName = d->getAsciiString(TheKey_teamName); + if (!hasCondition && !sides.isPlayerDefaultTeam(team) && !teamsNamed.count(teamName.str())) { + counts[8]++; + pushCapped(unusedTeams, limit, player, teamName.str()); + } + } + + ScriptList *list = sides.getSideInfo(side)->getScriptList(); + for (ScriptGroup *group = list ? list->getScriptGroup() : nullptr; group; group = group->getNext()) { + // Separator groups ("===== X =====") are empty on purpose. + if (group->getScript() == nullptr && !group->getName().startsWith("=")) { + counts[7]++; + pushCapped(emptyGroups, limit, player, group->getName().str()); + } + } + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side != side) continue; + Script *script = all[i].script; + const char *name = script->getName().str(); + // Calling a subroutine group runs its scripts. + const bool isCalled = called.count(name) || (all[i].group && called.count(all[i].group->getName().str())); + if (script->isSubroutine() && !isCalled) { + counts[0]++; + pushCapped(unusedSubroutines, limit, player, name); + } else if (!script->isActive() && !script->isSubroutine() && !isCalled) { + counts[1]++; + pushCapped(neverEnabled, limit, player, name); + } + if (script->getAction() == nullptr && script->getFalseAction() == nullptr) { + counts[2]++; + pushCapped(noActions, limit, player, name); + } + } + for (std::set::const_iterator it = flagsRead.begin(); it != flagsRead.end(); ++it) { + if (!flagsSet.count(*it)) { counts[3]++; pushCapped(flagsNeverSet, limit, player, it->c_str()); } + } + for (std::set::const_iterator it = flagsSet.begin(); it != flagsSet.end(); ++it) { + if (!flagsRead.count(*it)) { counts[4]++; pushCapped(flagsNeverRead, limit, player, it->c_str()); } + } + for (std::set::const_iterator it = countersRead.begin(); it != countersRead.end(); ++it) { + if (!countersSet.count(*it)) { counts[5]++; pushCapped(countersNeverSet, limit, player, it->c_str()); } + } + for (std::set::const_iterator it = countersSet.begin(); it != countersSet.end(); ++it) { + if (!countersRead.count(*it)) { counts[6]++; pushCapped(countersNeverRead, limit, player, it->c_str()); } + } + } + + McpJson j = McpJson::makeObject(); + j.set("unused_subroutines", McpJson::makeObject().set("count", counts[0]).set("items", unusedSubroutines)); + j.set("inactive_never_enabled", McpJson::makeObject().set("count", counts[1]).set("items", neverEnabled)); + j.set("scripts_without_actions", McpJson::makeObject().set("count", counts[2]).set("items", noActions)); + j.set("flags_read_never_set", McpJson::makeObject().set("count", counts[3]).set("items", flagsNeverSet)); + j.set("flags_set_never_read", McpJson::makeObject().set("count", counts[4]).set("items", flagsNeverRead)); + j.set("counters_read_never_set", McpJson::makeObject().set("count", counts[5]).set("items", countersNeverSet)); + j.set("counters_set_never_read", McpJson::makeObject().set("count", counts[6]).set("items", countersNeverRead)); + j.set("empty_groups", McpJson::makeObject().set("count", counts[7]).set("items", emptyGroups)); + j.set("teams_never_built_or_named", McpJson::makeObject().set("count", counts[8]).set("items", unusedTeams)); + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.missing +//------------------------------------------------------------------------------------------------- + +std::string lowerCase(const std::string &text) +{ + std::string out = text; + for (size_t i = 0; i < out.size(); i++) { + out[i] = (char)tolower((unsigned char)out[i]); + } + return out; +} + +/// Length of the longest common subsequence; names that differ by a dropped or added part +/// ("Tank_Battlemaster_FAST" / "Tank_ChinaTankBattleMaster") still share most of it. +Int commonSubsequence(const std::string &a, const std::string &b) +{ + std::vector row(b.size() + 1, 0); + for (size_t i = 1; i <= a.size(); i++) { + Int diagonal = 0; + for (size_t k = 1; k <= b.size(); k++) { + const Int above = row[k]; + row[k] = a[i - 1] == b[k - 1] ? diagonal + 1 : (row[k] > row[k - 1] ? row[k] : row[k - 1]); + diagonal = above; + } + } + return row[b.size()]; +} + +/// The existing names closest to a missing one, best first. +std::vector suggestNames(const std::string &missing, const std::vector &candidates, const std::vector &lowered) +{ + const std::string wanted = lowerCase(missing); + std::vector > scored; + for (size_t i = 0; i < candidates.size(); i++) { + const std::string &name = lowered[i]; + if (name.empty() || name == wanted) continue; + // Cheap bound first: the score cannot exceed what the lengths allow. + const double shorter = (double)(name.size() < wanted.size() ? name.size() : wanted.size()); + if (2.0 * shorter / (name.size() + wanted.size()) < 0.72) continue; + const double score = 2.0 * commonSubsequence(wanted, name) / (name.size() + wanted.size()); + if (score >= 0.72) scored.push_back(std::make_pair(-score, candidates[i])); + } + std::sort(scored.begin(), scored.end()); + std::vector best; + for (size_t i = 0; i < scored.size() && i < 3; i++) { + best.push_back(scored[i].second); + } + return best; +} + +struct MissingGroup +{ + std::string kind, value; + Int count; + std::set players; + MissingGroup() : count(0) {} + std::vector samples; +}; + +bool moreMissing(const MissingGroup *a, const MissingGroup *b) +{ + return a->count != b->count ? a->count > b->count : a->value < b->value; +} + +/// Everything scripts and teams refer to that does not exist, grouped by the missing name with +/// the closest existing names. One typo usually accounts for many warnings. +McpJson cmdMissingReferences(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + const Int limit = mcpArgInt(args, "limit", 50); + const bool suggest = mcpArgBool(args, "suggest", true); + + std::map, MissingGroup> groups; + Int total = 0; + { + ParameterCheckScope scope(TheSidesList); + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + if (onlySide >= 0 && all[i].side != onlySide) continue; + std::vector uses; + collectParamUses(all[i].script, uses); + for (size_t k = 0; k < uses.size(); k++) { + Parameter *parm = uses[k].parm; + if (!isStringParamType(parm->getParameterType())) continue; + if (EditParameter::getWarningText(parm, !uses[k].inCondition).isEmpty()) continue; + const std::string kind = parameterTypeName(parm->getParameterType()); + MissingGroup &group = groups[std::make_pair(kind, std::string(parm->getString().str()))]; + group.kind = kind; + group.value = parm->getString().str(); + group.count++; + group.players.insert(sideName(sides, all[i].side).str()); + if (group.samples.size() < 3) group.samples.push_back(std::string("script '") + all[i].script->getName().str() + "'"); + total++; + } + } + } + Int teamsChecked = 0; + const McpJson teamWarnings = verifyTeams(sides, onlySide, "", &teamsChecked); + for (size_t i = 0; i < teamWarnings.size(); i++) { + const McpJson &warning = teamWarnings.at(i); + if (!warning.get("missing_kind").isString()) continue; + const std::string kind = warning.get("missing_kind").asString(); + const std::string value = warning.get("missing").asString(); + MissingGroup &group = groups[std::make_pair(kind, value)]; + group.kind = kind; + group.value = value; + group.count++; + group.players.insert(warning.get("owner").asString()); + if (group.samples.size() < 3) { + group.samples.push_back("team '" + warning.get("team").asString() + "' " + warning.get("property").asString()); + } + total++; + } + + std::vector order; + for (std::map, MissingGroup>::iterator it = groups.begin(); it != groups.end(); ++it) { + order.push_back(&it->second); + } + std::sort(order.begin(), order.end(), moreMissing); + + // The names that do exist, per kind, as the script editor lists them. + std::map > candidates, lowered; + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < order.size() && (Int)i < limit; i++) { + const MissingGroup &group = *order[i]; + McpJson entry = McpJson::makeObject(); + entry.set("kind", group.kind).set("missing", group.value).set("uses", group.count); + McpJson players = McpJson::makeArray(); + for (std::set::const_iterator it = group.players.begin(); it != group.players.end(); ++it) { + players.push(McpJson(*it)); + } + McpJson samples = McpJson::makeArray(); + for (size_t k = 0; k < group.samples.size(); k++) { + samples.push(McpJson(group.samples[k])); + } + entry.set("players", players).set("used_by", samples); + if (suggest && !group.value.empty()) { + if (!candidates.count(group.kind)) { + std::vector &names = candidates[group.kind]; + const Int type = parameterTypeFromName(group.kind.c_str()); + if (type >= 0) collectEditorValues((Parameter::ParameterType)type, names); + std::vector &lower = lowered[group.kind]; + for (size_t k = 0; k < names.size(); k++) { + lower.push_back(lowerCase(names[k])); + } + } + const std::vector &names = candidates[group.kind]; + // A script or team another player has is a different thing than a typo. + if ((group.kind == "SCRIPT" || group.kind == "SCRIPT_SUBROUTINE") && std::find(names.begin(), names.end(), group.value) != names.end()) { + entry.set("exists_for_another_player", true); + } + const std::vector best = suggestNames(group.value, names, lowered[group.kind]); + McpJson suggestions = McpJson::makeArray(); + for (size_t k = 0; k < best.size(); k++) { + suggestions.push(McpJson(best[k])); + } + entry.set("suggestions", suggestions); + } + // What scripts.rename calls the kind that retargets these references. + if (group.kind == "SCRIPT" || group.kind == "SCRIPT_SUBROUTINE") { + entry.set("rename_kind", "script_reference"); + } else if (group.kind == "TEAM") { + entry.set("rename_kind", "team_reference"); + } else { + entry.set("rename_kind", group.kind); + } + list.push(entry); + } + return McpJson::makeObject().set("total_uses", total).set("distinct_missing", (int)order.size()).set("missing", list); +} + +//------------------------------------------------------------------------------------------------- +// scripts.set_values +//------------------------------------------------------------------------------------------------- + +struct BulkEdit +{ + // Which parameters. + Int onlySide; + bool hasGroup; + std::string group, scriptFilter, entryType, current, text, in; + Parameter::ParameterType paramType; + // What to do with them. + std::string op; + McpJson value; + bool hasMin, hasMax; + double minValue, maxValue; + // Results. + Int matched, changed; + McpJson samples; +}; + +bool isNumericParamType(Parameter::ParameterType type) +{ + return type == Parameter::INT || type == Parameter::REAL || type == Parameter::ANGLE || type == Parameter::PERCENT; +} + +/// Applies the edit to the parameters of one condition or action. +template +void bulkEditEntry(BulkEdit &edit, SidesList &sides, const ScriptLoc &loc, T *item, const char *typeName, const char *where) +{ + if (!edit.entryType.empty() && _stricmp(edit.entryType.c_str(), typeName) != 0) return; + if (!edit.text.empty() && !mcpContainsNoCase(item->getUiText().str(), edit.text.c_str())) return; + const AsciiString before = item->getUiText(); + Bool any = false; + for (Int i = 0; i < item->getNumParameters(); i++) { + Parameter *parm = item->getParameter(i); + if (parm->getParameterType() != edit.paramType) continue; + const std::string old = paramValueText(parm); + if (!edit.current.empty() && _stricmp(edit.current.c_str(), old.c_str()) != 0) continue; + edit.matched++; + if (edit.op == "set") { + setParamValue(parm, edit.value, where, i); + } else { + double number = paramValueToJson(parm).asNumber(); + number = edit.op == "multiply" ? number * edit.value.asNumber() : number + edit.value.asNumber(); + if (edit.hasMin && number < edit.minValue) number = edit.minValue; + if (edit.hasMax && number > edit.maxValue) number = edit.maxValue; + if (edit.paramType == Parameter::INT) number = floor(number + 0.5); + setParamValue(parm, McpJson(number), where, i); + } + if (paramValueText(parm) != old) { + edit.changed++; + any = true; + } + } + if (any && edit.samples.size() < 15) { + McpJson sample = McpJson::makeObject(); + sample.set("player", sideName(sides, loc.side).str()).set("script", loc.script->getName().str()).set("where", where); + sample.set("before", before.str()).set("after", item->getUiText().str()); + edit.samples.push(sample); + } +} + +/// Changes one kind of parameter in many scripts at once, e.g. every credit threshold of the +/// build conditions or every use of one unit type. +McpJson cmdSetValues(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + + BulkEdit edit; + edit.onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + edit.hasGroup = args.has("group"); + edit.group = mcpArgString(args, "group", ""); + edit.scriptFilter = mcpArgString(args, "script_filter", ""); + edit.entryType = mcpArgString(args, "type", ""); + edit.current = mcpArgString(args, "current", ""); + edit.text = mcpArgString(args, "text", ""); + edit.in = mcpArgString(args, "in", "any"); + if (edit.in != "any" && edit.in != "conditions" && edit.in != "actions") { + mcpFail("in must be any, conditions or actions"); + } + const Int typeIndex = parameterTypeFromName(mcpArgString(args, "param_type").c_str()); + if (typeIndex < 0) { + mcpFail("unknown param_type (the types are listed by scripts.list_actions / scripts.list_conditions)"); + } + edit.paramType = (Parameter::ParameterType)typeIndex; + if (!edit.entryType.empty() && findActionType(edit.entryType.c_str()) < 0 && findConditionType(edit.entryType.c_str()) < 0) { + mcpFail("unknown condition or action type '%s'", edit.entryType.c_str()); + } + edit.op = mcpArgString(args, "op", "set"); + edit.value = args.get("value"); + if (edit.value.isNull()) { + mcpFail("pass 'value': the new value for op set, or the number to add / multiply by"); + } + if (edit.op == "add" || edit.op == "multiply") { + if (!isNumericParamType(edit.paramType) || !edit.value.isNumber()) { + mcpFail("op %s needs a number and a numeric param_type (INT, REAL, ANGLE, PERCENT)", edit.op.c_str()); + } + } else if (edit.op != "set") { + mcpFail("op must be set, add or multiply"); + } + edit.hasMin = args.has("min"); + edit.hasMax = args.has("max"); + edit.minValue = mcpArgNumber(args, "min", 0); + edit.maxValue = mcpArgNumber(args, "max", 0); + // Without any narrowing this would rewrite a whole set by accident. + if (edit.onlySide < 0 && !edit.hasGroup && edit.scriptFilter.empty() && edit.entryType.empty() && edit.current.empty() + && edit.text.empty() && !mcpArgBool(args, "all_scripts", false)) { + mcpFail("narrow it down (player, group, script_filter, type, current or text), or pass all_scripts=true"); + } + const bool dryRun = mcpArgBool(args, "dry_run", false); + edit.matched = 0; + edit.changed = 0; + edit.samples = McpJson::makeArray(); + + Int scriptsChanged = 0; + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + const ScriptLoc &loc = all[i]; + if (edit.onlySide >= 0 && loc.side != edit.onlySide) continue; + if (edit.hasGroup && edit.group != (loc.group ? loc.group->getName().str() : "")) continue; + if (!mcpContainsNoCase(loc.script->getName().str(), edit.scriptFilter.c_str())) continue; + const Int changedBefore = edit.changed; + char where[64]; + if (edit.in != "actions") { + Int o = 0; + for (OrCondition *orCond = loc.script->getOrCondition(); orCond; orCond = orCond->getNextOrCondition(), o++) { + Int a = 0; + for (Condition *cond = orCond->getFirstAndCondition(); cond; cond = cond->getNext(), a++) { + const ConditionTemplate *ct = TheScriptEngine->getConditionTemplate(cond->getConditionType()); + sprintf(where, "conditions[%d][%d]", o, a); + bulkEditEntry(edit, sides, loc, cond, ct ? ct->m_internalName.str() : "", where); + } + } + } + if (edit.in != "conditions") { + for (Int pass = 0; pass < 2; pass++) { + Int ndx = 0; + for (ScriptAction *action = pass ? loc.script->getFalseAction() : loc.script->getAction(); action; action = action->getNext(), ndx++) { + const ActionTemplate *at = TheScriptEngine->getActionTemplate(action->getActionType()); + sprintf(where, "%s[%d]", pass ? "false_actions" : "actions", ndx); + bulkEditEntry(edit, sides, loc, action, at ? at->m_internalName.str() : "", where); + } + } + } + if (edit.changed != changedBefore) scriptsChanged++; + } + + McpJson j = McpJson::makeObject(); + j.set("parameters_matched", edit.matched).set("parameters_changed", edit.changed).set("scripts_changed", scriptsChanged); + j.set("samples", edit.samples).set("dry_run", dryRun); + if (!dryRun && edit.changed > 0) { + commitSides(sides); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.compare +//------------------------------------------------------------------------------------------------- + +struct ComparedScript +{ + std::string key, group, name; + std::vector lines; +}; + +std::string cleanText(const AsciiString &text) +{ + // The editor marks entries that have warnings; that is not part of the script. + const char *p = text.str(); + if (strncmp(p, "[???]", 5) == 0) p += 5; + while (*p == ' ') p++; + std::string out = p; + while (!out.empty() && out[out.size() - 1] == ' ') out.erase(out.size() - 1); + return out; +} + +/// A script as lines that can be compared: its flags, then every condition and action as worded +/// by the editor. +void scriptToLines(Script *script, std::vector &lines) +{ + char flags[160]; + sprintf(flags, "flags: active=%d one_shot=%d subroutine=%d easy=%d normal=%d hard=%d delay=%d", script->isActive() ? 1 : 0, + script->isOneShot() ? 1 : 0, script->isSubroutine() ? 1 : 0, script->isEasy() ? 1 : 0, script->isNormal() ? 1 : 0, + script->isHard() ? 1 : 0, (int)script->getDelayEvalSeconds()); + lines.push_back(flags); + Int o = 0; + for (OrCondition *orCond = script->getOrCondition(); orCond; orCond = orCond->getNextOrCondition(), o++) { + for (Condition *cond = orCond->getFirstAndCondition(); cond; cond = cond->getNext()) { + char prefix[24]; + sprintf(prefix, "IF[%d] ", o); + lines.push_back(prefix + cleanText(cond->getUiText())); + } + } + for (ScriptAction *action = script->getAction(); action; action = action->getNext()) { + lines.push_back("THEN " + cleanText(action->getUiText())); + } + for (ScriptAction *action = script->getFalseAction(); action; action = action->getNext()) { + lines.push_back("ELSE " + cleanText(action->getUiText())); + } +} + +/// Collects the scripts of a list. With replacements, names are rewritten first so that two +/// factions' sets line up ("HTC " -> "SCM "). +void collectCompared(ScriptList *list, Replacements *replacements, const AsciiString &fromPlayer, const AsciiString &toPlayer, + std::vector &out, std::vector &groupNames) +{ + std::map seen; + for (Int pass = 0; pass < 2; pass++) { + ScriptGroup *group = pass ? list->getScriptGroup() : nullptr; + do { + std::string groupName; + if (group) { + groupName = replacements ? replacements->apply(group->getName()).str() : group->getName().str(); + groupNames.push_back(groupName); + } + for (Script *script = group ? group->getScript() : (pass ? nullptr : list->getScript()); script; script = script->getNext()) { + ComparedScript entry; + Script *work = script; + if (replacements) { + work = script->duplicate(); + transformScript(work, *replacements, fromPlayer, toPlayer); + } + entry.group = groupName; + entry.name = work->getName().str(); + scriptToLines(work, entry.lines); + if (replacements) deleteInstance(work); + // Same-named scripts in one group are told apart by their order. + char occurrence[16]; + sprintf(occurrence, "#%d", seen[entry.group + "\x1f" + entry.name]++); + entry.key = entry.group + "\x1f" + entry.name + occurrence; + out.push_back(entry); + } + group = group ? group->getNext() : nullptr; + } while (group); + } +} + +/// Lines of `a` that `b` does not have (each line of b cancels one of a). +std::vector linesOnlyIn(const std::vector &a, const std::vector &b) +{ + std::map counts; + for (size_t i = 0; i < b.size(); i++) counts[b[i]]++; + std::vector only; + for (size_t i = 0; i < a.size(); i++) { + if (counts[a[i]] > 0) counts[a[i]]--; else only.push_back(a[i]); + } + return only; +} + +McpJson linesToJson(const std::vector &lines, size_t cap) +{ + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < lines.size() && i < cap; i++) list.push(McpJson(lines[i])); + if (lines.size() > cap) { + char more[48]; + sprintf(more, "... %d more", (int)(lines.size() - cap)); + list.push(McpJson((const char *)more)); + } + return list; +} + +McpJson cappedList(const std::vector &items, Int limit) +{ + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < items.size() && (Int)i < limit; i++) list.push(McpJson(items[i])); + return McpJson::makeObject().set("count", (int)items.size()).set("items", list); +} + +/// The teams of a player as name -> properties, optionally with names rewritten. +void collectComparedTeams(SidesList &sides, const AsciiString &owner, Replacements *replacements, std::map &out) +{ + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + if (team->getDict()->getAsciiString(TheKey_teamOwner) != owner || sides.isPlayerDefaultTeam(team)) continue; + Dict d = *team->getDict(); + if (replacements) { + for (Int k = 0; k < d.getPairCount(); k++) { + if (d.getNthType(k) != Dict::DICT_ASCIISTRING || d.getNthKey(k) == TheKey_teamOwner) continue; + d.setAsciiString(d.getNthKey(k), replacements->apply(d.getNthAsciiString(k))); + } + } + out[d.getAsciiString(TheKey_teamName).str()] = d; + } +} + +/// Compares the scripts and teams of two players: what only one has and what differs, line by +/// line. The other side can be a player of the map or of a scripts.dump document. +McpJson cmdCompareScripts(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const Int sideA = requireSide(sides, mcpArgString(args, "player")); + const AsciiString nameA = sideName(sides, sideA); + const Int limit = mcpArgInt(args, "limit", 30); + const bool includeTeams = mcpArgBool(args, "include_teams", true); + Replacements replacements; + replacements.read(args.get("replace")); + Replacements *rewrite = replacements.pairs.empty() ? nullptr : &replacements; + + std::vector scriptsA, scriptsB; + std::vector groupsA, groupsB; + std::map teamsA, teamsB; + collectCompared(requireScriptList(sides, sideA), nullptr, nameA, nameA, scriptsA, groupsA); + collectComparedTeams(sides, nameA, nullptr, teamsA); + + AsciiString nameB; + const McpJson &data = args.get("with_data"); + if (!data.isNull()) { + // The other side comes from a dump document: build its scripts and teams in a scratch sides list. + if (!data.isObject() || !data.get("players").isArray()) { + mcpFail("'with_data' must be a scripts.dump document"); + } + nameB = mcpArgString(args, "with_player", nameA.str()).c_str(); + const McpJson *entry = nullptr; + for (size_t p = 0; p < data.get("players").size(); p++) { + const McpJson &candidate = data.get("players").at(p); + if (candidate.get("player").isString() && nameB == candidate.get("player").asString().c_str()) entry = &candidate; + } + if (entry == nullptr) { + mcpFail("the document has no player '%s'; pass with_player", nameB.str()); + } + Int numScripts = 0, numGroups = 0; + ScriptList *list = buildScriptList(*entry, nameB.str(), &numScripts, &numGroups); + try { + collectCompared(list, rewrite, nameB, nameA, scriptsB, groupsB); + } catch (...) { + deleteInstance(list); + throw; + } + deleteInstance(list); + const McpJson &teams = data.get("teams"); + for (size_t t = 0; includeTeams && t < teams.size(); t++) { + const McpJson &props = teams.at(t); + if (!props.isObject() || !props.get("teamOwner").isString() || nameB != props.get("teamOwner").asString().c_str()) continue; + Dict d; + for (size_t k = 0; k < props.numKeys(); k++) { + setTeamPropertyChecked(d, props.keyAt(k), props.valueAt(k), true); + } + if (rewrite) { + for (Int k = 0; k < d.getPairCount(); k++) { + if (d.getNthType(k) != Dict::DICT_ASCIISTRING || d.getNthKey(k) == TheKey_teamOwner) continue; + d.setAsciiString(d.getNthKey(k), rewrite->apply(d.getNthAsciiString(k))); + } + } + teamsB[d.getAsciiString(TheKey_teamName).str()] = d; + } + } else { + const Int sideB = requireSide(sides, mcpArgString(args, "with_player")); + if (sideB == sideA) { + mcpFail("pass another player as with_player, or a dump document as with_data"); + } + nameB = sideName(sides, sideB); + collectCompared(requireScriptList(sides, sideB), rewrite, nameB, nameA, scriptsB, groupsB); + collectComparedTeams(sides, nameB, rewrite, teamsB); + } + + // Scripts. + std::map indexB; + for (size_t i = 0; i < scriptsB.size(); i++) indexB[scriptsB[i].key] = &scriptsB[i]; + std::set keysA; + std::vector onlyA, onlyB; + McpJson changed = McpJson::makeArray(); + Int identical = 0, numChanged = 0; + for (size_t i = 0; i < scriptsA.size(); i++) { + const ComparedScript &a = scriptsA[i]; + keysA.insert(a.key); + std::map::const_iterator it = indexB.find(a.key); + if (it == indexB.end()) { + onlyA.push_back(a.group.empty() ? a.name : a.group + " / " + a.name); + continue; + } + const ComparedScript &b = *it->second; + if (a.lines == b.lines) { + identical++; + continue; + } + numChanged++; + if ((Int)changed.size() >= limit) continue; + McpJson entry = McpJson::makeObject(); + entry.set("script", a.name); + if (!a.group.empty()) entry.set("group", a.group); + const std::vector inA = linesOnlyIn(a.lines, b.lines); + const std::vector inB = linesOnlyIn(b.lines, a.lines); + if (inA.empty() && inB.empty()) { + entry.set("note", "same conditions and actions in another order"); + } else { + entry.set("a", linesToJson(inA, 8)).set("b", linesToJson(inB, 8)); + } + changed.push(entry); + } + for (size_t i = 0; i < scriptsB.size(); i++) { + if (!keysA.count(scriptsB[i].key)) { + onlyB.push_back(scriptsB[i].group.empty() ? scriptsB[i].name : scriptsB[i].group + " / " + scriptsB[i].name); + } + } + + std::vector groupsOnlyA = linesOnlyIn(groupsA, groupsB); + std::vector groupsOnlyB = linesOnlyIn(groupsB, groupsA); + + McpJson j = McpJson::makeObject(); + j.set("a", McpJson::makeObject().set("player", nameA.str()).set("scripts", (int)scriptsA.size()).set("teams", (int)teamsA.size())); + j.set("b", McpJson::makeObject().set("player", nameB.str()).set("scripts", (int)scriptsB.size()).set("teams", (int)teamsB.size()) + .set("from_document", !data.isNull())); + j.set("scripts", McpJson::makeObject() + .set("identical", identical) + .set("only_in_a", cappedList(onlyA, limit)) + .set("only_in_b", cappedList(onlyB, limit)) + .set("different", McpJson::makeObject().set("count", numChanged).set("items", changed))); + j.set("groups", McpJson::makeObject().set("only_in_a", cappedList(groupsOnlyA, limit)).set("only_in_b", cappedList(groupsOnlyB, limit))); + + if (includeTeams) { + std::vector teamsOnlyA, teamsOnlyB; + McpJson teamsChanged = McpJson::makeArray(); + Int teamsIdentical = 0, numTeamsChanged = 0; + for (std::map::const_iterator it = teamsA.begin(); it != teamsA.end(); ++it) { + std::map::const_iterator other = teamsB.find(it->first); + if (other == teamsB.end()) { + teamsOnlyA.push_back(it->first); + continue; + } + const McpJson a = mcpDictToJson(it->second); + const McpJson b = mcpDictToJson(other->second); + McpJson changes = McpJson::makeObject(); + for (Int pass = 0; pass < 2; pass++) { + const McpJson &from = pass ? b : a; + const McpJson &to = pass ? a : b; + for (size_t k = 0; k < from.numKeys(); k++) { + const std::string &key = from.keyAt(k); + // The owner differs by definition; the export flag is editor bookkeeping. + if (key == "teamOwner" || key == "exportWithScript" || changes.has(key.c_str())) continue; + if (from.valueAt(k).dump() == to.get(key.c_str()).dump()) continue; + McpJson pair = McpJson::makeArray(); + pair.push(a.get(key.c_str())); + pair.push(b.get(key.c_str())); + changes.set(key.c_str(), pair); + } + } + if (changes.numKeys() == 0) { + teamsIdentical++; + continue; + } + numTeamsChanged++; + if ((Int)teamsChanged.size() < limit) { + teamsChanged.push(McpJson::makeObject().set("team", it->first).set("a_b", changes)); + } + } + for (std::map::const_iterator it = teamsB.begin(); it != teamsB.end(); ++it) { + if (!teamsA.count(it->first)) teamsOnlyB.push_back(it->first); + } + j.set("teams", McpJson::makeObject() + .set("identical", teamsIdentical) + .set("only_in_a", cappedList(teamsOnlyA, limit)) + .set("only_in_b", cappedList(teamsOnlyB, limit)) + .set("different", McpJson::makeObject().set("count", numTeamsChanged).set("items", teamsChanged))); + } + return j; +} + +} // namespace + +void mcpRegisterScriptToolCommands() +{ + mcpRegisterCommand("scripts.compare", cmdCompareScripts, "{player,with_player?,with_data?,replace?:{old:new},include_teams?=true,limit?=30} Differences between two players' scripts and teams, or against a dump document."); + mcpRegisterCommand("scripts.set_values", cmdSetValues, "{param_type,op?=set|add|multiply,value,player?,group?,script_filter?,type?,current?,text?,in?=any|conditions|actions,min?,max?,all_scripts?,dry_run?} Changes one kind of parameter in many scripts."); + mcpRegisterCommand("scripts.missing", cmdMissingReferences, "{player?,limit?=50,suggest?=true} Missing references of scripts and teams grouped by name, with the closest existing names."); + mcpRegisterCommand("scripts.batch", cmdBatch, "{commands:[{cmd,args}],results?=summary|full} Runs scripts.*, teams.* and sides.set/delete commands as one undo step; all or nothing."); + mcpRegisterCommand("scripts.dump", cmdDumpScripts, "{player?,include_teams?=true} All scripts, groups and teams as one JSON document."); + mcpRegisterCommand("scripts.load", cmdLoadScripts, "{data,create_players?=true} Replaces the scripts and teams of the players in a scripts.dump document."); + mcpRegisterCommand("scripts.analyze", cmdAnalyzeScripts, "{player?,limit?=40} Dead logic: unused subroutines, scripts nothing enables, flags and counters used one-sided, teams never built."); + mcpRegisterCommand("sides.set", cmdSetSide, "{name,new_name?,properties?} Changes a player's properties or renames it with its references."); + mcpRegisterCommand("sides.delete", cmdDeleteSide, "{name,force?=false} Deletes a player with its scripts and teams."); + mcpRegisterCommand("scripts.copy", cmdCopyScript, "{name,player?,group?,script_index?,new_name,to_player?,to_group?,index?} Duplicates a script."); + mcpRegisterCommand("scripts.clone_player", cmdClonePlayer, "{from,to,replace?:{old:new},existing?=fail|replace|append,include_teams?=true,dry_run?} Copies a player's scripts, groups and teams to another player, rewriting names."); + mcpRegisterCommand("scripts.rename", cmdRename, "{kind:script|group|team|,from,to,player?} Renames something and every reference to it in scripts and teams."); + mcpRegisterCommand("teams.copy", cmdCopyTeam, "{name,new_name,owner?} Duplicates a team."); + mcpRegisterCommand("scripts.list_values", cmdListValues, "{param_type,filter?,limit?=100,offset?} The values the script editor offers for a parameter type (upgrades, sciences, special powers, command buttons, object types, teams, ...)."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptViews.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptViews.cpp new file mode 100644 index 00000000000..cd7c7043d73 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptViews.cpp @@ -0,0 +1,694 @@ +/* +** 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 . +*/ + +// McpCmdScriptViews.cpp +// MCP bridge commands that read a script set from a particular angle: what a team or script +// sets in motion, a player's build order, its scripts and teams per difficulty, the stats of +// the unit types they use, and the players' build lists (the Build List tool). + +#include "StdAfx.h" +#define DEFINE_BUILDABLE_STATUS_NAMES +#include "mcp/McpCommands.h" +#include "mcp/McpScriptsShared.h" + +#include "WorldBuilderDoc.h" +#include "Common/KindOf.h" +#include "Common/NameKeyGenerator.h" +#include "Common/ProductionPrerequisite.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/ScriptEngine.h" +#include "GameLogic/Scripts.h" +#include "GameLogic/SidesList.h" + +#include +#include +#include +#include +#include +#include + +using namespace McpScripts; + +namespace +{ + +const double VIEW_PI = 3.14159265358979323846; + +std::string trimmed(const AsciiString &text) +{ + // The editor marks entries that have warnings; that is not part of the script. + const char *p = text.str(); + if (strncmp(p, "[???]", 5) == 0) p += 5; + while (*p == ' ') p++; + std::string out = p; + while (!out.empty() && out[out.size() - 1] == ' ') out.erase(out.size() - 1); + return out; +} + +/// The conditions of a script as text, one entry per OR clause. +McpJson conditionClauses(Script *script) +{ + McpJson clauses = McpJson::makeArray(); + for (OrCondition *orCond = script->getOrCondition(); orCond; orCond = orCond->getNextOrCondition()) { + std::string clause; + for (Condition *cond = orCond->getFirstAndCondition(); cond; cond = cond->getNext()) { + if (!clause.empty()) clause += " AND "; + clause += trimmed(cond->getUiText()); + } + clauses.push(McpJson(clause)); + } + return clauses; +} + +/// "ENH" for a script that runs on easy, normal and hard. +std::string difficultyFlags(Script *script) +{ + std::string flags; + if (script->isEasy()) flags += "E"; + if (script->isNormal()) flags += "N"; + if (script->isHard()) flags += "H"; + return flags; +} + +Script *findScriptInSide(const std::vector &all, Int side, const std::string &name) +{ + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side == side && name == all[i].script->getName().str()) return all[i].script; + } + return nullptr; +} + +//------------------------------------------------------------------------------------------------- +// scripts.trace +//------------------------------------------------------------------------------------------------- + +struct TraceContext +{ + SidesList *sides; + Int side; + std::vector all; + std::set visited; + Int nodes, maxNodes, maxDepth; +}; + +McpJson traceScript(TraceContext &ctx, const std::string &name, const std::string &via, Int depth); + +/// The scripts of the player whose conditions read a flag or a counter/timer. +void traceReaders(TraceContext &ctx, Parameter::ParameterType type, const std::string &value, const std::string &via, Int depth, + const std::string &self, McpJson &children) +{ + for (size_t i = 0; i < ctx.all.size(); i++) { + if (ctx.all[i].side != ctx.side) continue; + Script *script = ctx.all[i].script; + if (self == script->getName().str()) continue; + std::vector uses; + collectParamUses(script, uses); + bool reads = false; + for (size_t k = 0; k < uses.size() && !reads; k++) { + reads = uses[k].inCondition && uses[k].parm->getParameterType() == type && value == uses[k].parm->getString().str(); + } + if (reads) { + children.push(traceScript(ctx, script->getName().str(), via, depth + 1)); + } + } +} + +McpJson traceScript(TraceContext &ctx, const std::string &name, const std::string &via, Int depth) +{ + McpJson node = McpJson::makeObject(); + node.set("script", name); + if (!via.empty()) node.set("via", via); + Script *script = findScriptInSide(ctx.all, ctx.side, name); + if (script == nullptr) { + return node.set("missing", true); + } + if (!ctx.visited.insert(name).second) { + return node.set("shown_above", true); + } + ctx.nodes++; + if (!script->isActive()) node.set("active", false); + if (script->isSubroutine()) node.set("subroutine", true); + if (difficultyFlags(script) != "ENH") node.set("difficulty", difficultyFlags(script)); + node.set("if", conditionClauses(script)); + McpJson then = McpJson::makeArray(); + for (ScriptAction *action = script->getAction(); action; action = action->getNext()) { + then.push(McpJson(trimmed(action->getUiText()))); + } + node.set("then", then); + if (script->getFalseAction()) { + McpJson otherwise = McpJson::makeArray(); + for (ScriptAction *action = script->getFalseAction(); action; action = action->getNext()) { + otherwise.push(McpJson(trimmed(action->getUiText()))); + } + node.set("else", otherwise); + } + if (depth >= ctx.maxDepth || ctx.nodes >= ctx.maxNodes) { + return node.set("not_expanded", true); + } + + // What its actions set in motion: scripts they run or enable, and scripts waiting for the + // flags, counters and timers they set. + McpJson children = McpJson::makeArray(); + std::set done; + for (Int pass = 0; pass < 2; pass++) { + for (ScriptAction *action = pass ? script->getFalseAction() : script->getAction(); action; action = action->getNext()) { + for (Int i = 0; i < action->getNumParameters(); i++) { + Parameter *parm = action->getParameter(i); + const std::string value = parm->getString().str(); + const Parameter::ParameterType type = parm->getParameterType(); + if (value.empty() || !done.insert(std::string(parameterTypeName(type)) + "\x1f" + value).second) continue; + if (ctx.nodes >= ctx.maxNodes) break; + if (type == Parameter::SCRIPT || type == Parameter::SCRIPT_SUBROUTINE) { + children.push(traceScript(ctx, value, "runs or enables", depth + 1)); + } else if (type == Parameter::FLAG) { + traceReaders(ctx, type, value, "waits for flag '" + value + "'", depth, name, children); + } else if (type == Parameter::COUNTER) { + traceReaders(ctx, type, value, "waits for counter/timer '" + value + "'", depth, name, children); + } + } + } + } + if (children.size() > 0) { + node.set("leads_to", children); + } + return node; +} + +/// What a team or a script sets in motion: the scripts a team names, and from each script the +/// scripts it runs and those waiting for the flags and timers it sets. +McpJson cmdTrace(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const std::string name = mcpArgString(args, "name"); + TraceContext ctx; + ctx.sides = &sides; + ctx.all = allScripts(sides); + ctx.nodes = 0; + ctx.maxDepth = mcpArgInt(args, "depth", 4); + ctx.maxNodes = mcpArgInt(args, "max_scripts", 60); + + McpJson j = McpJson::makeObject(); + TeamsInfo *team = args.has("player") ? nullptr : sides.findTeamInfo(AsciiString(name.c_str())); + if (team && mcpArgString(args, "kind", "auto") != "script") { + Dict *d = team->getDict(); + ctx.side = findSide(sides, d->getAsciiString(TheKey_teamOwner)); + if (ctx.side < 0) { + mcpFail("team '%s' has no valid owner", name.c_str()); + } + j.set("team", name).set("owner", sideName(sides, ctx.side).str()); + McpJson hooks = McpJson::makeArray(); + for (Int k = 0; k < d->getPairCount(); k++) { + if (d->getNthType(k) != Dict::DICT_ASCIISTRING || d->getNthAsciiString(k).isEmpty()) continue; + const AsciiString key = TheNameKeyGenerator->keyToName(d->getNthKey(k)); + const char *ref = teamPropertyRef(key.str()); + if (ref == nullptr || strcmp(ref, "script") != 0) continue; + hooks.push(traceScript(ctx, d->getNthAsciiString(k).str(), key.str() + 4, 1)); + } + j.set("scripts", hooks); + } else { + // A script: the name may exist for several players. + std::vector owners; + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + for (Int side = 0; side < sides.getNumSides(); side++) { + if ((onlySide < 0 || side == onlySide) && findScriptInSide(ctx.all, side, name)) owners.push_back(side); + } + if (owners.empty()) { + mcpFail("no team or script named '%s'%s", name.c_str(), onlySide >= 0 ? " for that player" : ""); + } + if (owners.size() > 1) { + mcpFail("%d players have a script named '%s'; pass player", (int)owners.size(), name.c_str()); + } + ctx.side = owners[0]; + j.set("player", sideName(sides, ctx.side).str()); + j.set("trace", traceScript(ctx, name, "", 1)); + + // And the other direction, one step: who starts it. + McpJson callers = McpJson::makeArray(); + for (size_t i = 0; i < ctx.all.size() && callers.size() < 30; i++) { + if (ctx.all[i].side != ctx.side) continue; + std::vector uses; + collectParamUses(ctx.all[i].script, uses); + for (size_t k = 0; k < uses.size(); k++) { + const Parameter::ParameterType type = uses[k].parm->getParameterType(); + if ((type == Parameter::SCRIPT || type == Parameter::SCRIPT_SUBROUTINE) && name == uses[k].parm->getString().str()) { + callers.push(McpJson(std::string("script '") + ctx.all[i].script->getName().str() + "'")); + break; + } + } + } + for (Int i = 0; i < sides.getNumTeams() && callers.size() < 30; i++) { + Dict *d = sides.getTeamInfo(i)->getDict(); + if (d->getAsciiString(TheKey_teamOwner) != sideName(sides, ctx.side)) continue; + for (Int k = 0; k < d->getPairCount(); k++) { + if (d->getNthType(k) != Dict::DICT_ASCIISTRING || name != d->getNthAsciiString(k).str()) continue; + const AsciiString key = TheNameKeyGenerator->keyToName(d->getNthKey(k)); + const char *ref = teamPropertyRef(key.str()); + if (ref && strcmp(ref, "script") == 0) { + callers.push(McpJson(std::string("team '") + d->getAsciiString(TheKey_teamName).str() + "' " + (key.str() + 4))); + } + } + } + j.set("started_by", callers); + } + j.set("scripts_shown", ctx.nodes); + if (ctx.nodes >= ctx.maxNodes) { + j.set("note", "stopped at max_scripts; trace one of the not_expanded scripts for more"); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.build_order +//------------------------------------------------------------------------------------------------- + +/// True for the actions that make the AI build a structure or an upgrade. +bool isBuildAction(const char *typeName) +{ + static const char *const BUILD_ACTIONS[] = { "SKIRMISH_BUILD_BUILDING", "AI_PLAYER_BUILD_SUPPLY_CENTER", + "AI_PLAYER_BUILD_TYPE_NEAREST_TEAM", "AI_PLAYER_BUILD_UPGRADE", "SKIRMISH_BUILD_BASE_DEFENSE_FRONT", + "SKIRMISH_BUILD_BASE_DEFENSE_FLANK", "SKIRMISH_BUILD_STRUCTURE_FRONT", "SKIRMISH_BUILD_STRUCTURE_FLANK", + "SKIRMISH_BUILD_SHIPYARD", nullptr }; + for (Int i = 0; BUILD_ACTIONS[i]; i++) { + if (strcmp(typeName, BUILD_ACTIONS[i]) == 0) return true; + } + return false; +} + +/// Every script of a player that builds something, in the order the scripts are evaluated. +McpJson cmdBuildOrder(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "player")); + const std::string onlyDifficulty = mcpArgString(args, "difficulty", ""); + const bool includeInactive = mcpArgBool(args, "include_inactive", true); + + McpJson steps = McpJson::makeArray(); + std::map totals; + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side != side) continue; + Script *script = all[i].script; + if (!includeInactive && !script->isActive()) continue; + const std::string difficulty = difficultyFlags(script); + if (!onlyDifficulty.empty() && difficulty.find(onlyDifficulty) == std::string::npos) continue; + McpJson builds = McpJson::makeArray(); + for (Int pass = 0; pass < 2; pass++) { + for (ScriptAction *action = pass ? script->getFalseAction() : script->getAction(); action; action = action->getNext()) { + const ActionTemplate *at = TheScriptEngine->getActionTemplate(action->getActionType()); + if (at == nullptr || !isBuildAction(at->m_internalName.str())) continue; + McpJson build = McpJson::makeObject(); + build.set("action", at->m_internalName.str()); + for (Int k = 0; k < action->getNumParameters(); k++) { + if (action->getParameter(k)->getParameterType() == Parameter::OBJECT_TYPE + || action->getParameter(k)->getParameterType() == Parameter::UPGRADE) { + build.set("what", action->getParameter(k)->getString().str()); + totals[action->getParameter(k)->getString().str()]++; + } + } + build.set("text", trimmed(action->getUiText())); + if (pass) build.set("when_false", true); + builds.push(build); + } + } + if (builds.size() == 0) continue; + McpJson step = McpJson::makeObject(); + step.set("step", (int)steps.size() + 1); + if (all[i].group) step.set("group", all[i].group->getName().str()); + step.set("script", script->getName().str()); + if (!script->isActive()) step.set("active", false); + if (all[i].group && !all[i].group->isActive()) step.set("group_active", false); + if (difficulty != "ENH") step.set("difficulty", difficulty); + if (!script->isOneShot()) step.set("repeats", true); + step.set("if", conditionClauses(script)); + step.set("builds", builds); + steps.push(step); + } + McpJson totalList = McpJson::makeArray(); + for (std::map::const_iterator it = totals.begin(); it != totals.end(); ++it) { + totalList.push(McpJson::makeObject().set("what", it->first).set("build_actions", it->second)); + } + return McpJson::makeObject().set("player", sideName(sides, side).str()).set("steps", steps).set("totals", totalList); +} + +//------------------------------------------------------------------------------------------------- +// scripts.by_difficulty +//------------------------------------------------------------------------------------------------- + +/// The difficulty letter in a name such as "SCM E Attack Team 1" or "CT Def H": a word that is +/// just E, N or H. +char difficultyTag(const char *name) +{ + for (const char *p = name; *p; p++) { + if ((*p == 'E' || *p == 'N' || *p == 'H') && (p == name || p[-1] == ' ') && (p[1] == ' ' || p[1] == 0)) return *p; + } + return 0; +} + +/// A player's scripts and teams by difficulty: the easy/normal/hard switches of the scripts, and +/// the E / N / H words this kind of script set puts into names. +McpJson cmdByDifficulty(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "player")); + const Int limit = mcpArgInt(args, "limit", 40); + + std::map byFlags; + std::map flagCounts; + std::map scriptsByTag, teamsByTag; + std::map scriptTagCounts, teamTagCounts; + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side != side) continue; + Script *script = all[i].script; + const std::string flags = difficultyFlags(script).empty() ? "none" : difficultyFlags(script); + flagCounts[flags]++; + // Scripts for every difficulty are the normal case and not worth listing. + if (flags != "ENH") { + if (!byFlags.count(flags)) byFlags[flags] = McpJson::makeArray(); + if ((Int)byFlags[flags].size() < limit) byFlags[flags].push(McpJson(script->getName().str())); + } + const char tag = difficultyTag(script->getName().str()); + if (tag) { + scriptTagCounts[tag]++; + if (!scriptsByTag.count(tag)) scriptsByTag[tag] = McpJson::makeArray(); + if ((Int)scriptsByTag[tag].size() < limit) scriptsByTag[tag].push(McpJson(script->getName().str())); + } + } + Int untaggedTeams = 0; + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + if (team->getDict()->getAsciiString(TheKey_teamOwner) != sideName(sides, side) || sides.isPlayerDefaultTeam(team)) continue; + const AsciiString name = team->getDict()->getAsciiString(TheKey_teamName); + const char tag = difficultyTag(name.str()); + if (!tag) { + untaggedTeams++; + continue; + } + teamTagCounts[tag]++; + if (!teamsByTag.count(tag)) teamsByTag[tag] = McpJson::makeArray(); + if ((Int)teamsByTag[tag].size() < limit) teamsByTag[tag].push(McpJson(name.str())); + } + + McpJson switches = McpJson::makeArray(); + for (std::map::const_iterator it = flagCounts.begin(); it != flagCounts.end(); ++it) { + McpJson entry = McpJson::makeObject(); + entry.set("runs_on", it->first).set("scripts", it->second); + if (byFlags.count(it->first)) entry.set("names", byFlags[it->first]); + switches.push(entry); + } + McpJson scriptTags = McpJson::makeArray(), teamTags = McpJson::makeArray(); + static const char TAGS[] = { 'E', 'N', 'H' }; + for (Int t = 0; t < 3; t++) { + const char text[2] = { TAGS[t], 0 }; + if (scriptTagCounts.count(TAGS[t])) { + scriptTags.push(McpJson::makeObject().set("tag", text).set("scripts", scriptTagCounts[TAGS[t]]).set("names", scriptsByTag[TAGS[t]])); + } + if (teamTagCounts.count(TAGS[t])) { + teamTags.push(McpJson::makeObject().set("tag", text).set("teams", teamTagCounts[TAGS[t]]).set("names", teamsByTag[TAGS[t]])); + } + } + McpJson j = McpJson::makeObject(); + j.set("player", sideName(sides, side).str()); + j.set("script_switches", switches); + j.set("scripts_named", scriptTags).set("teams_named", teamTags).set("teams_without_tag", untaggedTeams); + return j; +} + +//------------------------------------------------------------------------------------------------- +// objects.info +//------------------------------------------------------------------------------------------------- + +McpJson describeTemplateStats(const ThingTemplate *tt) +{ + McpJson j = McpJson::makeObject(); + j.set("template", tt->getName().str()); + AsciiString displayName; + displayName.translate(tt->getDisplayName()); + if (!displayName.isEmpty()) j.set("display_name", displayName.str()); + j.set("side", tt->getDefaultOwningSide().str()); + j.set("cost", (int)tt->friend_getBuildCost()); + const Int buildable = tt->getBuildable(); + if (buildable >= BSTATUS_YES && buildable < BSTATUS_NUM_TYPES) { + j.set("buildable", BuildableStatusNames[buildable - BSTATUS_YES]); + } + if (tt->getEnergyProduction() != 0) j.set("energy", (int)tt->getEnergyProduction()); + j.set("vision_range", (double)tt->friend_calcVisionRange()); + if (!tt->friend_getCommandSetString().isEmpty()) j.set("command_set", tt->friend_getCommandSetString().str()); + + McpJson kinds = McpJson::makeArray(); + const char *const *kindNames = KindOfMaskType::getBitNames(); + for (Int k = KINDOF_FIRST; k < KINDOF_COUNT && kindNames[k]; k++) { + if (tt->isKindOf((KindOfType)k)) kinds.push(McpJson(kindNames[k])); + } + j.set("kind_of", kinds); + + // Where it is produced, across all of its prerequisite sets. + McpJson producers = McpJson::makeArray(); + std::set seen; + for (Int p = 0; p < tt->getPrereqCount(); p++) { + const ThingTemplate *facilities[32]; + const Int count = tt->getNthPrereq(p)->getAllPossibleBuildFacilityTemplates(facilities, 32); + for (Int f = 0; f < count; f++) { + if (facilities[f] && seen.insert(facilities[f]->getName().str()).second) { + producers.push(McpJson(facilities[f]->getName().str())); + } + } + } + j.set("prerequisite_sets", (int)tt->getPrereqCount()).set("built_at", producers); + return j; +} + +/// Stats of unit and structure types, to judge teams and build orders by. +McpJson cmdObjectInfo(const McpJson &args) +{ + McpJson list = McpJson::makeArray(); + McpJson unknown = McpJson::makeArray(); + if (args.has("templates")) { + const McpJson &names = mcpArgArray(args, "templates"); + if (names.size() > 60) { + mcpFail("at most 60 templates per call"); + } + for (size_t i = 0; i < names.size(); i++) { + if (!names.at(i).isString()) mcpFail("'templates' must be an array of template names"); + const ThingTemplate *tt = TheThingFactory->findTemplate(AsciiString(names.at(i).asString().c_str()), FALSE); + if (tt) list.push(describeTemplateStats(tt)); else unknown.push(names.at(i)); + } + } else { + const std::string filter = mcpArgString(args, "filter"); + const Int limit = mcpArgInt(args, "limit", 20); + Int matched = 0; + for (const ThingTemplate *tt = TheThingFactory->firstTemplate(); tt; tt = tt->friend_getNextTemplate()) { + if (!mcpContainsNoCase(tt->getName().str(), filter.c_str())) continue; + matched++; + if ((Int)list.size() < limit) list.push(describeTemplateStats(tt)); + } + return McpJson::makeObject().set("total_matches", matched).set("templates", list); + } + return McpJson::makeObject().set("templates", list).set("unknown", unknown); +} + +//------------------------------------------------------------------------------------------------- +// Build lists +//------------------------------------------------------------------------------------------------- + +BuildListInfo *nthBuildListEntry(SidesInfo *side, Int index) +{ + BuildListInfo *entry = side->getBuildList(); + for (Int i = 0; entry && i < index; i++) { + entry = entry->getNext(); + } + return entry; +} + +McpJson describeBuildListEntry(BuildListInfo *entry, Int index) +{ + McpJson j = McpJson::makeObject(); + j.set("index", index).set("template", entry->getTemplateName().str()); + if (!entry->getBuildingName().isEmpty()) j.set("name", entry->getBuildingName().str()); + j.set("x", (double)entry->getLocation()->x).set("y", (double)entry->getLocation()->y).set("z", (double)entry->getLocation()->z); + j.set("angle_deg", entry->getAngle() * 180.0 / VIEW_PI); + j.set("initially_built", (bool)(entry->isInitiallyBuilt() != 0)); + if ((UnsignedInt)entry->getNumRebuilds() == (UnsignedInt)BuildListInfo::UNLIMITED_REBUILDS) { + j.set("rebuilds", "unlimited"); + } else { + j.set("rebuilds", (int)entry->getNumRebuilds()); + } + j.set("health", (int)entry->getHealth()); + if (!entry->getScript().isEmpty()) j.set("script", entry->getScript().str()); + j.set("whiner", (bool)(entry->getWhiner() != 0)).set("unsellable", (bool)(entry->getUnsellable() != 0)); + j.set("repairable", (bool)(entry->getRepairable() != 0)); + return j; +} + +/// Applies the fields given in args to a build list entry. +void applyBuildListArgs(BuildListInfo *entry, const McpJson &args) +{ + if (args.has("template")) { + const AsciiString name(mcpArgString(args, "template").c_str()); + if (TheThingFactory->findTemplate(name, FALSE) == nullptr) { + mcpFail("unknown template '%s' (see objects.list_templates)", name.str()); + } + entry->setTemplateName(name); + } + if (args.has("x") || args.has("y") || args.has("z")) { + Coord3D loc = *entry->getLocation(); + loc.x = (Real)mcpArgNumber(args, "x", loc.x); + loc.y = (Real)mcpArgNumber(args, "y", loc.y); + loc.z = (Real)mcpArgNumber(args, "z", loc.z); + entry->setLocation(loc); + } + if (args.has("angle_deg")) entry->setAngle((Real)(mcpArgNumber(args, "angle_deg") * VIEW_PI / 180.0)); + if (args.has("name")) entry->setBuildingName(AsciiString(mcpArgString(args, "name").c_str())); + if (args.has("script")) entry->setScript(AsciiString(mcpArgString(args, "script").c_str())); + if (args.has("initially_built")) entry->setInitiallyBuilt(mcpArgBool(args, "initially_built", false)); + if (args.has("health")) entry->setHealth(mcpArgInt(args, "health")); + if (args.has("whiner")) entry->setWhiner(mcpArgBool(args, "whiner", true)); + if (args.has("unsellable")) entry->setUnsellable(mcpArgBool(args, "unsellable", false)); + if (args.has("repairable")) entry->setRepairable(mcpArgBool(args, "repairable", true)); + if (args.has("rebuilds")) { + const McpJson &rebuilds = args.get("rebuilds"); + if (rebuilds.isString() && rebuilds.asString() == "unlimited") { + entry->setNumRebuilds(BuildListInfo::UNLIMITED_REBUILDS); + } else if (rebuilds.isNumber() && rebuilds.asNumber() >= 0) { + entry->setNumRebuilds((UnsignedInt)rebuilds.asNumber()); + } else { + mcpFail("rebuilds must be a number >= 0 or \"unlimited\""); + } + } +} + +McpJson cmdListBuildList(const McpJson &args) +{ + mcpDoc(); + SidesList &sides = *TheSidesList; + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + McpJson players = McpJson::makeArray(); + for (Int side = 0; side < sides.getNumSides(); side++) { + if (onlySide >= 0 && side != onlySide) continue; + McpJson entries = McpJson::makeArray(); + Int index = 0; + for (BuildListInfo *entry = sides.getSideInfo(side)->getBuildList(); entry; entry = entry->getNext(), index++) { + entries.push(describeBuildListEntry(entry, index)); + } + if (onlySide < 0 && entries.size() == 0) continue; + players.push(McpJson::makeObject().set("player", sideName(sides, side).str()).set("build_list", entries)); + } + return McpJson::makeObject().set("players", players); +} + +McpJson cmdAddBuildList(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + SidesList sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "player")); + mcpArgString(args, "template"); + mcpArgNumber(args, "x"); + mcpArgNumber(args, "y"); + SidesInfo *info = sides.getSideInfo(side); + Int count = 0; + for (BuildListInfo *entry = info->getBuildList(); entry; entry = entry->getNext()) count++; + const Int index = mcpArgInt(args, "index", count); + if (index < 0 || index > count) { + mcpFail("index must be 0..%d", count); + } + + BuildListInfo *entry = newInstance(BuildListInfo); + Coord3D loc; + loc.x = loc.y = loc.z = 0; + entry->setLocation(loc); + info->addToBuildList(entry, index); + applyBuildListArgs(entry, args); + const McpJson j = describeBuildListEntry(entry, index); + commitSides(sides); + return j; +} + +McpJson cmdUpdateBuildList(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + SidesList sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "player")); + SidesInfo *info = sides.getSideInfo(side); + Int index = mcpArgInt(args, "index"); + BuildListInfo *entry = index >= 0 ? nthBuildListEntry(info, index) : nullptr; + if (entry == nullptr) { + mcpFail("player '%s' has no build list entry %d (see buildlist.list)", sideName(sides, side).str(), index); + } + applyBuildListArgs(entry, args); + if (args.has("new_index")) { + Int count = 0; + for (BuildListInfo *e = info->getBuildList(); e; e = e->getNext()) count++; + const Int newIndex = mcpArgInt(args, "new_index"); + if (newIndex < 0 || newIndex >= count) { + mcpFail("new_index must be 0..%d", count - 1); + } + info->removeFromBuildList(entry); + entry->setNextBuildList(nullptr); + info->addToBuildList(entry, newIndex); + index = newIndex; + } + const McpJson j = describeBuildListEntry(entry, index); + commitSides(sides); + return j; +} + +McpJson cmdDeleteBuildList(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + SidesList sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "player")); + SidesInfo *info = sides.getSideInfo(side); + const Int index = mcpArgInt(args, "index"); + BuildListInfo *entry = index >= 0 ? nthBuildListEntry(info, index) : nullptr; + if (entry == nullptr) { + mcpFail("player '%s' has no build list entry %d (see buildlist.list)", sideName(sides, side).str(), index); + } + McpJson j = McpJson::makeObject(); + j.set("deleted", entry->getTemplateName().str()).set("index", index); + info->removeFromBuildList(entry); + // Deleting an entry deletes what is linked behind it. + entry->setNextBuildList(nullptr); + deleteInstance(entry); + commitSides(sides); + return j; +} + +} // namespace + +void mcpRegisterScriptViewCommands() +{ + mcpRegisterCommand("scripts.trace", cmdTrace, "{name,player?,kind?=auto|script,depth?=4,max_scripts?=60} What a team or script sets in motion: the scripts it runs and those waiting for its flags and timers."); + mcpRegisterCommand("scripts.build_order", cmdBuildOrder, "{player,difficulty?:E|N|H,include_inactive?=true} The scripts that build structures or upgrades, in evaluation order, with their conditions."); + mcpRegisterCommand("scripts.by_difficulty", cmdByDifficulty, "{player,limit?=40} Scripts by their easy/normal/hard switches, and scripts and teams by the E/N/H word in their names."); + mcpRegisterCommand("objects.info", cmdObjectInfo, "{templates:[names] | filter,limit?=20} Cost, side, KindOf, energy, vision, command set and producing structures of object types."); + mcpRegisterCommand("buildlist.list", cmdListBuildList, "{player?} The players' build lists (structures the AI builds or starts with)."); + mcpRegisterCommand("buildlist.add", cmdAddBuildList, "{player,template,x,y,z?,angle_deg?,name?,script?,initially_built?,rebuilds?,health?,whiner?,unsellable?,repairable?,index?} Adds a build list entry."); + mcpRegisterCommand("buildlist.update", cmdUpdateBuildList, "{player,index,new_index?,...fields of buildlist.add} Changes or reorders a build list entry."); + mcpRegisterCommand("buildlist.delete", cmdDeleteBuildList, "{player,index} Deletes a build list entry."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScripts.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScripts.cpp new file mode 100644 index 00000000000..89faa622e53 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScripts.cpp @@ -0,0 +1,2359 @@ +/* +** 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 . +*/ + +// McpCmdScripts.cpp +// MCP bridge commands for what the script editor (Edit > Scripts) and the teams dialog edit: +// scripts, script groups, their conditions and actions, script file (.scb) import/export and +// team templates. Like the script editor, every edit works on a copy of the sides list and +// commits it as one undo step. + +#include "StdAfx.h" +#include "resource.h" +#include "WorldBuilder.h" + +#define DEFINE_BUILDABLE_STATUS_NAMES + +#include "mcp/McpCommands.h" +#include "mcp/McpScriptsShared.h" +#include "mcp/McpUndoables.h" + +#include "CUndoable.h" +#include "EditParameter.h" +#include "ScriptDialog.h" +#include "WorldBuilderDoc.h" +#include "Common/DataChunk.h" +#include "Common/KindOf.h" +#include "Common/MapReaderWriterInfo.h" +#include "Common/NameKeyGenerator.h" +#include "Common/Radar.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "Common/WellKnownKeys.h" +#include "GameClient/View.h" +#include "GameLogic/AI.h" +#include "GameLogic/PolygonTrigger.h" +#include "GameLogic/ScriptEngine.h" +#include "GameLogic/Scripts.h" +#include "GameLogic/SidesList.h" +#include "GameLogic/Module/ContainModule.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace McpScripts +{ + +const double MCP_PI = 3.14159265358979323846; +const char *const NEUTRAL_PLAYER_LABEL = "(neutral)"; + +// Chunk versions of the script file format, as written by ScriptDialog::OnSave. +const Int K_SCRIPT_FILE_PLAYERS_VERSION_2 = 2; +const Int K_SCRIPT_FILE_TEAMS_VERSION_1 = 1; + +//------------------------------------------------------------------------------------------------- +// Parameter types and values +//------------------------------------------------------------------------------------------------- + +// In the order of Parameter::ParameterType. +const char *const PARAMETER_TYPE_NAMES[] = +{ + "INT", "REAL", "SCRIPT", "TEAM", "COUNTER", "FLAG", "COMPARISON", "WAYPOINT", "BOOLEAN", "TRIGGER_AREA", + "TEXT_STRING", "SIDE", "SOUND", "SCRIPT_SUBROUTINE", "UNIT", "OBJECT_TYPE", "COORD3D", "ANGLE", "TEAM_STATE", + "RELATION", "AI_MOOD", "DIALOG", "MUSIC", "MOVIE", "WAYPOINT_PATH", "LOCALIZED_TEXT", "BRIDGE", "KIND_OF_PARAM", + "ATTACK_PRIORITY_SET", "RADAR_EVENT_TYPE", "SPECIAL_POWER", "SCIENCE", "UPGRADE", "COMMANDBUTTON_ABILITY", + "BOUNDARY", "BUILDABLE", "SURFACES_ALLOWED", "SHAKE_INTENSITY", "COMMAND_BUTTON", "FONT_NAME", "OBJECT_STATUS", + "COMMANDBUTTON_ALL_ABILITIES", "SKIRMISH_WAYPOINT_PATH", "COLOR", "EMOTICON", "OBJECT_PANEL_FLAG", "FACTION_NAME", + "OBJECT_TYPE_LIST", "REVEALNAME", "SCIENCE_AVAILABILITY", "LEFT_OR_RIGHT", "PERCENT" +}; +static_assert(ARRAY_SIZE(PARAMETER_TYPE_NAMES) == Parameter::NUM_ITEMS, "Incorrect array size"); + +const char *parameterTypeName(Parameter::ParameterType type) +{ + return (type >= 0 && type < Parameter::NUM_ITEMS) ? PARAMETER_TYPE_NAMES[type] : "UNKNOWN"; +} + +Int parameterTypeFromName(const char *name) +{ + for (Int i = 0; i < Parameter::NUM_ITEMS; i++) { + if (_stricmp(name, PARAMETER_TYPE_NAMES[i]) == 0) return i; + } + return -1; +} + +struct EnumName +{ + const char *name; + Int value; +}; + +// The first name of a value is the one reported; later ones are only accepted on input. +const EnumName COMPARISON_NAMES[] = +{ + { "LESS_THAN", Parameter::LESS_THAN }, { "LESS_EQUAL", Parameter::LESS_EQUAL }, { "EQUAL", Parameter::EQUAL }, + { "GREATER_EQUAL", Parameter::GREATER_EQUAL }, { "GREATER", Parameter::GREATER }, { "NOT_EQUAL", Parameter::NOT_EQUAL }, + { "<", Parameter::LESS_THAN }, { "<=", Parameter::LESS_EQUAL }, { "==", Parameter::EQUAL }, + { ">=", Parameter::GREATER_EQUAL }, { ">", Parameter::GREATER }, { "!=", Parameter::NOT_EQUAL }, + { nullptr, 0 } +}; +const EnumName RELATION_NAMES[] = +{ + { "ENEMY", Parameter::REL_ENEMY }, { "NEUTRAL", Parameter::REL_NEUTRAL }, { "FRIEND", Parameter::REL_FRIEND }, + { nullptr, 0 } +}; +const EnumName AI_MOOD_NAMES[] = +{ + { "SLEEP", ATTITUDE_SLEEP }, { "PASSIVE", ATTITUDE_PASSIVE }, { "NORMAL", ATTITUDE_NORMAL }, + { "ALERT", ATTITUDE_ALERT }, { "AGGRESSIVE", ATTITUDE_AGGRESSIVE }, + { nullptr, 0 } +}; +const EnumName RADAR_EVENT_NAMES[] = +{ + { "CONSTRUCTION", RADAR_EVENT_CONSTRUCTION }, { "UPGRADE", RADAR_EVENT_UPGRADE }, + { "UNDER_ATTACK", RADAR_EVENT_UNDER_ATTACK }, { "INFORMATION", RADAR_EVENT_INFORMATION }, + { "INFILTRATION", RADAR_EVENT_INFILTRATION }, + { nullptr, 0 } +}; +const EnumName LEFT_OR_RIGHT_NAMES[] = +{ + { "LEFT", EVAC_TO_LEFT }, { "RIGHT", EVAC_TO_RIGHT }, { "CENTER", EVAC_BURST_FROM_CENTER }, + { nullptr, 0 } +}; + +const EnumName *enumTable(Parameter::ParameterType type) +{ + switch (type) { + case Parameter::COMPARISON: return COMPARISON_NAMES; + case Parameter::RELATION: return RELATION_NAMES; + case Parameter::AI_MOOD: return AI_MOOD_NAMES; + case Parameter::RADAR_EVENT_TYPE: return RADAR_EVENT_NAMES; + case Parameter::LEFT_OR_RIGHT: return LEFT_OR_RIGHT_NAMES; + default: return nullptr; + } +} + +/// Engine name lists for parameters that store an index: names[i] belongs to the value base + i. +struct NameList +{ + const char *const *names; + Int base; + Int count; +}; + +bool getNameList(Parameter::ParameterType type, NameList *out) +{ + switch (type) { + case Parameter::KIND_OF_PARAM: + out->names = KindOfMaskType::getBitNames(); + out->base = KINDOF_FIRST; + out->count = KINDOF_COUNT; + return true; + case Parameter::BUILDABLE: + out->names = BuildableStatusNames; + out->base = BSTATUS_YES; + out->count = BSTATUS_NUM_TYPES; + return true; + case Parameter::SURFACES_ALLOWED: + out->names = Surfaces; + out->base = 1; + out->count = 3; + return true; + case Parameter::SHAKE_INTENSITY: + out->names = ShakeIntensities; + out->base = 0; + out->count = View::SHAKE_COUNT; + return true; + default: + return false; + } +} + +bool isNamedIntType(Parameter::ParameterType type) +{ + NameList list; + return enumTable(type) != nullptr || getNameList(type, &list); +} + +const char *namedIntName(Parameter::ParameterType type, Int value) +{ + if (const EnumName *table = enumTable(type)) { + for (; table->name; table++) { + if (table->value == value) return table->name; + } + return nullptr; + } + NameList list; + if (getNameList(type, &list) && value >= list.base && value < list.base + list.count) { + return list.names[value - list.base]; + } + return nullptr; +} + +bool namedIntValue(Parameter::ParameterType type, const char *name, Int *value) +{ + if (const EnumName *table = enumTable(type)) { + for (; table->name; table++) { + if (_stricmp(table->name, name) == 0) { + *value = table->value; + return true; + } + } + return false; + } + NameList list; + if (getNameList(type, &list)) { + for (Int i = 0; i < list.count && list.names[i]; i++) { + if (_stricmp(list.names[i], name) == 0) { + *value = list.base + i; + return true; + } + } + } + return false; +} + +void namedIntNameList(Parameter::ParameterType type, std::vector &names) +{ + if (const EnumName *table = enumTable(type)) { + // Later entries of a value are input aliases. + for (; table->name; table++) { + if (namedIntName(type, table->value) == table->name) names.push_back(table->name); + } + return; + } + NameList list; + if (getNameList(type, &list)) { + for (Int i = 0; i < list.count && list.names[i]; i++) { + names.push_back(list.names[i]); + } + } +} + +/// The names a named-int parameter accepts, for error messages. +std::string namedIntChoices(Parameter::ParameterType type) +{ + if (type == Parameter::KIND_OF_PARAM) { + return "a KindOf name such as STRUCTURE or INFANTRY"; + } + std::string choices; + if (const EnumName *table = enumTable(type)) { + for (; table->name; table++) { + if (!choices.empty()) choices += ", "; + choices += table->name; + } + return choices; + } + NameList list; + if (getNameList(type, &list)) { + for (Int i = 0; i < list.count && list.names[i]; i++) { + if (!choices.empty()) choices += ", "; + choices += list.names[i]; + } + } + return choices; +} + +bool isStringParamType(Parameter::ParameterType type) +{ + switch (type) { + case Parameter::INT: + case Parameter::BOUNDARY: + case Parameter::BOOLEAN: + case Parameter::REAL: + case Parameter::ANGLE: + case Parameter::PERCENT: + case Parameter::COORD3D: + case Parameter::COLOR: + return false; + default: + return !isNamedIntType(type); + } +} + +McpJson paramValueToJson(const Parameter *parm) +{ + const Parameter::ParameterType type = parm->getParameterType(); + switch (type) { + case Parameter::INT: + case Parameter::BOUNDARY: + return McpJson((int)parm->getInt()); + case Parameter::BOOLEAN: + return McpJson(parm->getInt() != 0); + case Parameter::REAL: + return McpJson((double)parm->getReal()); + case Parameter::ANGLE: + return McpJson(parm->getReal() * 180.0 / MCP_PI); + case Parameter::PERCENT: + return McpJson(parm->getReal() * 100.0); + case Parameter::COORD3D: { + Coord3D pos; + parm->getCoord3D(&pos); + McpJson arr = McpJson::makeArray(); + arr.push(McpJson((double)pos.x)); + arr.push(McpJson((double)pos.y)); + arr.push(McpJson((double)pos.z)); + return arr; + } + case Parameter::COLOR: { + char buf[16]; + sprintf(buf, "#%08X", (unsigned int)parm->getInt()); + return McpJson((const char *)buf); + } + default: + break; + } + if (isNamedIntType(type)) { + const char *name = namedIntName(type, parm->getInt()); + if (name != nullptr) { + return McpJson(name); + } + return McpJson((int)parm->getInt()); + } + return McpJson(parm->getString().str()); +} + +/// Sets a parameter from JSON: either the plain value or an object with a "value" key, as +/// scripts.get reports it. Stores what the parameter dialog would store for the type. +void setParamValue(Parameter *parm, const McpJson &in, const char *where, Int ndx) +{ + const McpJson &v = in.isObject() ? in.get("value") : in; + const Parameter::ParameterType type = parm->getParameterType(); + const char *typeName = parameterTypeName(type); + switch (type) { + case Parameter::INT: + case Parameter::BOUNDARY: + if (!v.isNumber()) mcpFail("%s: parameter %d (%s) must be a number", where, ndx + 1, typeName); + parm->friend_setInt((Int)floor(v.asNumber() + 0.5)); + return; + case Parameter::BOOLEAN: + if (v.isBool()) { + parm->friend_setInt(v.asBool() ? 1 : 0); + } else if (v.isNumber()) { + parm->friend_setInt(v.asNumber() != 0 ? 1 : 0); + } else { + mcpFail("%s: parameter %d (%s) must be true or false", where, ndx + 1, typeName); + } + return; + case Parameter::REAL: + case Parameter::ANGLE: + case Parameter::PERCENT: { + if (!v.isNumber()) { + mcpFail("%s: parameter %d (%s) must be a number%s", where, ndx + 1, typeName, + type == Parameter::ANGLE ? " (degrees)" : (type == Parameter::PERCENT ? " (percent, 0..100)" : "")); + } + double value = v.asNumber(); + if (type == Parameter::ANGLE) value = value * MCP_PI / 180.0; + if (type == Parameter::PERCENT) value = value / 100.0; + parm->friend_setReal((Real)value); + return; + } + case Parameter::COORD3D: { + if (!v.isArray() || v.size() < 2 || v.size() > 3 || !v.at(0).isNumber() || !v.at(1).isNumber() + || (v.size() == 3 && !v.at(2).isNumber())) { + mcpFail("%s: parameter %d (%s) must be [x, y, z]", where, ndx + 1, typeName); + } + Coord3D pos; + pos.x = (Real)v.at(0).asNumber(); + pos.y = (Real)v.at(1).asNumber(); + pos.z = v.size() == 3 ? (Real)v.at(2).asNumber() : 0; + parm->friend_setCoord3D(&pos); + return; + } + case Parameter::COLOR: { + if (v.isNumber()) { + parm->friend_setInt((Int)(Int64)v.asNumber()); + return; + } + // "#RRGGBB" (opaque) or "#AARRGGBB". + const char *text = v.isString() ? v.asString().c_str() : ""; + if (*text == '#') text++; + const size_t len = strlen(text); + unsigned int color = 0; + int consumed = 0; + if ((len != 6 && len != 8) || sscanf(text, "%x%n", &color, &consumed) != 1 || consumed != (int)len) { + mcpFail("%s: parameter %d (%s) must be \"#RRGGBB\" or \"#AARRGGBB\"", where, ndx + 1, typeName); + } + if (len == 6) color |= 0xFF000000; + parm->friend_setInt((Int)color); + return; + } + default: + break; + } + + if (isNamedIntType(type)) { + Int value = 0; + if (v.isNumber()) { + value = (Int)floor(v.asNumber() + 0.5); + if (namedIntName(type, value) == nullptr) { + mcpFail("%s: parameter %d (%s) has no value %d; use one of: %s", where, ndx + 1, typeName, value, + namedIntChoices(type).c_str()); + } + } else if (!v.isString() || !namedIntValue(type, v.asString().c_str(), &value)) { + mcpFail("%s: parameter %d (%s) must be one of: %s", where, ndx + 1, typeName, namedIntChoices(type).c_str()); + } + parm->friend_setInt(value); + if (type == Parameter::KIND_OF_PARAM) { + parm->friend_setString(AsciiString(namedIntName(type, value))); + } + return; + } + + if (!v.isString()) { + mcpFail("%s: parameter %d (%s) must be a string", where, ndx + 1, typeName); + } + AsciiString text(v.asString().c_str()); + if (type == Parameter::OBJECT_STATUS && !text.isEmpty()) { + // Stored by name, so use the engine's spelling. + const char *const *statusNames = ObjectStatusMaskType::getBitNames(); + Int i; + for (i = 0; i < OBJECT_STATUS_COUNT && statusNames[i]; i++) { + if (text.compareNoCase(statusNames[i]) == 0) { + text = statusNames[i]; + break; + } + } + if (i >= OBJECT_STATUS_COUNT || statusNames[i] == nullptr) { + mcpFail("%s: parameter %d (%s): unknown object status '%s'", where, ndx + 1, typeName, text.str()); + } + } + parm->friend_setString(text); +} + +template +McpJson describeParams(T *item) +{ + McpJson params = McpJson::makeArray(); + for (Int i = 0; i < item->getNumParameters(); i++) { + Parameter *parm = item->getParameter(i); + params.push(McpJson::makeObject() + .set("type", parameterTypeName(parm->getParameterType())) + .set("value", paramValueToJson(parm))); + } + return params; +} + +/// Fills the parameters of a new condition or action from the "params" array of its JSON entry. +template +void applyParams(T *item, const McpJson &entry, const std::string &where) +{ + const McpJson ¶ms = entry.get("params"); + if (!params.isNull() && !params.isArray()) { + mcpFail("%s: 'params' must be an array", where.c_str()); + } + const Int expected = item->getNumParameters(); + const Int given = (Int)params.size(); + if (given != expected) { + std::string signature; + for (Int i = 0; i < expected; i++) { + if (i) signature += ", "; + signature += parameterTypeName(item->getParameter(i)->getParameterType()); + } + mcpFail("%s takes %d parameter(s) [%s], got %d", where.c_str(), expected, signature.c_str(), given); + } + for (Int i = 0; i < expected; i++) { + setParamValue(item->getParameter(i), params.at(i), where.c_str(), i); + } +} + +//------------------------------------------------------------------------------------------------- +// Condition and action templates +//------------------------------------------------------------------------------------------------- + +Int findActionType(const char *name) +{ + for (Int i = 0; i < ScriptAction::NUM_ITEMS; i++) { + const ActionTemplate *at = TheScriptEngine->getActionTemplate(i); + if (at && !at->m_internalName.isEmpty() && at->m_internalName.compareNoCase(name) == 0) return i; + } + return -1; +} + +Int findConditionType(const char *name) +{ + for (Int i = 0; i < Condition::NUM_ITEMS; i++) { + const ConditionTemplate *ct = TheScriptEngine->getConditionTemplate(i); + if (ct && !ct->m_internalName.isEmpty() && ct->m_internalName.compareNoCase(name) == 0) return i; + } + return -1; +} + +/// The "type" of a condition or action entry; a plain string is an entry without parameters. +std::string entryTypeName(const McpJson &entry, const char *where) +{ + if (entry.isString()) { + return entry.asString(); + } + if (!entry.isObject() || !entry.get("type").isString()) { + mcpFail("%s must be an object with a string 'type' (and 'params')", where); + } + return entry.get("type").asString(); +} + +McpJson describeTemplate(const Template *t) +{ + McpJson j = McpJson::makeObject(); + j.set("type", t->m_internalName.str()).set("ui_name", t->m_uiName.str()); + McpJson params = McpJson::makeArray(); + // The sentence the editor shows, with the parameter types where the values go. + std::string sentence; + for (Int i = 0; i < MAX_PARMS; i++) { + if (i < t->m_numUiStrings) sentence += t->m_uiStrings[i].str(); + if (i < t->getNumParameters()) { + const char *typeName = parameterTypeName(t->getParameterType(i)); + params.push(McpJson(typeName)); + sentence += std::string("<") + typeName + ">"; + } + } + j.set("params", params).set("sentence", sentence); + if (!t->m_helpText.isEmpty()) { + j.set("help", t->m_helpText.str()); + } + return j; +} + +McpJson listTemplates(const McpJson &args, bool actions) +{ + const std::string filter = mcpArgString(args, "filter", ""); + const Int limit = mcpArgInt(args, "limit", 50); + const Int offset = mcpArgInt(args, "offset", 0); + const Int count = actions ? (Int)ScriptAction::NUM_ITEMS : (Int)Condition::NUM_ITEMS; + McpJson list = McpJson::makeArray(); + Int matched = 0; + for (Int i = 0; i < count; i++) { + const Template *t = actions ? (const Template *)TheScriptEngine->getActionTemplate(i) + : (const Template *)TheScriptEngine->getConditionTemplate(i); + if (t == nullptr || t->m_internalName.isEmpty()) continue; + if (!mcpContainsNoCase(t->m_internalName.str(), filter.c_str()) && !mcpContainsNoCase(t->m_uiName.str(), filter.c_str()) + && !mcpContainsNoCase(t->m_helpText.str(), filter.c_str())) continue; + matched++; + if (matched <= offset || (Int)list.size() >= limit) continue; + list.push(describeTemplate(t)); + } + return McpJson::makeObject().set("total_matches", matched).set(actions ? "actions" : "conditions", list); +} + +McpJson cmdListActions(const McpJson &args) +{ + return listTemplates(args, true); +} + +McpJson cmdListConditions(const McpJson &args) +{ + return listTemplates(args, false); +} + +//------------------------------------------------------------------------------------------------- +// Scripts <-> JSON +//------------------------------------------------------------------------------------------------- + +ScriptAction *buildActions(const McpJson &list, const char *key) +{ + if (!list.isArray()) { + mcpFail("'%s' must be an array of actions", key); + } + ScriptAction *head = nullptr; + ScriptAction *tail = nullptr; + try { + for (size_t i = 0; i < list.size(); i++) { + char where[64]; + sprintf(where, "%s[%d]", key, (int)i); + const std::string typeName = entryTypeName(list.at(i), where); + const Int type = findActionType(typeName.c_str()); + if (type < 0) { + mcpFail("%s: unknown action '%s' (see scripts.list_actions)", where, typeName.c_str()); + } + ScriptAction *action = newInstance(ScriptAction)((ScriptAction::ScriptActionType)type); + action->setNextAction(nullptr); + action->setWarnings(false); + if (tail) tail->setNextAction(action); else head = action; + tail = action; + applyParams(action, list.at(i), std::string(where) + " " + typeName); + } + } catch (...) { + deleteInstance(head); + throw; + } + return head; +} + +/// Conditions are a list of OR clauses, each a list of conditions that must all be true. A flat +/// list of conditions is taken as a single clause. +OrCondition *buildConditions(const McpJson &list) +{ + if (!list.isArray() || list.size() == 0) { + mcpFail("'conditions' must be a non-empty array (use CONDITION_TRUE for a script that always runs)"); + } + const bool nested = list.at(0).isArray(); + const size_t numOrs = nested ? list.size() : 1; + OrCondition *head = nullptr; + OrCondition *tail = nullptr; + try { + for (size_t o = 0; o < numOrs; o++) { + const McpJson &clause = nested ? list.at(o) : list; + if (!clause.isArray() || clause.size() == 0) { + mcpFail("conditions[%d] must be a non-empty array of conditions", (int)o); + } + OrCondition *orCond = newInstance(OrCondition); + if (tail) tail->setNextOrCondition(orCond); else head = orCond; + tail = orCond; + Condition *lastAnd = nullptr; + for (size_t a = 0; a < clause.size(); a++) { + char where[64]; + sprintf(where, "conditions[%d][%d]", (int)o, (int)a); + const std::string typeName = entryTypeName(clause.at(a), where); + const Int type = findConditionType(typeName.c_str()); + if (type < 0) { + mcpFail("%s: unknown condition '%s' (see scripts.list_conditions)", where, typeName.c_str()); + } + Condition *cond = newInstance(Condition)((Condition::ConditionType)type); + if (lastAnd) lastAnd->setNextCondition(cond); else orCond->setFirstAndCondition(cond); + lastAnd = cond; + applyParams(cond, clause.at(a), std::string(where) + " " + typeName); + } + } + } catch (...) { + deleteInstance(head); + throw; + } + return head; +} + +void collectParamUses(Script *script, std::vector &out) +{ + ParamUse use; + use.inCondition = true; + for (OrCondition *orCond = script->getOrCondition(); orCond; orCond = orCond->getNextOrCondition()) { + for (Condition *cond = orCond->getFirstAndCondition(); cond; cond = cond->getNext()) { + for (Int i = 0; i < cond->getNumParameters(); i++) { + use.parm = cond->getParameter(i); + out.push_back(use); + } + } + } + use.inCondition = false; + for (Int pass = 0; pass < 2; pass++) { + for (ScriptAction *action = pass ? script->getFalseAction() : script->getAction(); action; action = action->getNext()) { + for (Int i = 0; i < action->getNumParameters(); i++) { + use.parm = action->getParameter(i); + out.push_back(use); + } + } + } +} + +McpJson describeActions(ScriptAction *first) +{ + McpJson list = McpJson::makeArray(); + for (ScriptAction *action = first; action; action = action->getNext()) { + const ActionTemplate *at = TheScriptEngine->getActionTemplate(action->getActionType()); + list.push(McpJson::makeObject() + .set("type", at ? at->m_internalName.str() : "") + .set("params", describeParams(action)) + .set("text", action->getUiText().str())); + } + return list; +} + +McpJson describeConditions(Script *script) +{ + McpJson ors = McpJson::makeArray(); + for (OrCondition *orCond = script->getOrCondition(); orCond; orCond = orCond->getNextOrCondition()) { + McpJson ands = McpJson::makeArray(); + for (Condition *cond = orCond->getFirstAndCondition(); cond; cond = cond->getNext()) { + const ConditionTemplate *ct = TheScriptEngine->getConditionTemplate(cond->getConditionType()); + ands.push(McpJson::makeObject() + .set("type", ct ? ct->m_internalName.str() : "") + .set("params", describeParams(cond)) + .set("text", cond->getUiText().str())); + } + ors.push(ands); + } + return ors; +} + +McpJson describeScriptBrief(Script *script) +{ + McpJson j = McpJson::makeObject(); + j.set("name", script->getName().str()); + j.set("active", (bool)(script->isActive() != 0)); + j.set("subroutine", (bool)(script->isSubroutine() != 0)); + if (!script->getComment().isEmpty()) { + j.set("comment", script->getComment().str()); + } + return j; +} + +McpJson describeScript(Script *script) +{ + McpJson j = McpJson::makeObject(); + j.set("name", script->getName().str()); + j.set("comment", script->getComment().str()); + j.set("condition_comment", script->getConditionComment().str()); + j.set("action_comment", script->getActionComment().str()); + j.set("active", (bool)(script->isActive() != 0)); + j.set("one_shot", (bool)(script->isOneShot() != 0)); + j.set("subroutine", (bool)(script->isSubroutine() != 0)); + j.set("easy", (bool)(script->isEasy() != 0)); + j.set("normal", (bool)(script->isNormal() != 0)); + j.set("hard", (bool)(script->isHard() != 0)); + j.set("delay_seconds", (int)script->getDelayEvalSeconds()); + j.set("conditions", describeConditions(script)); + j.set("actions", describeActions(script->getAction())); + j.set("false_actions", describeActions(script->getFalseAction())); + return j; +} + +/// Applies the keys present in a script JSON object; other parts of the script are kept. The +/// script must already belong to a sides list copy, which cleans up if this fails halfway. +void applyScriptJson(Script *script, const McpJson &def) +{ + static const char *const KNOWN_KEYS[] = { "name", "comment", "condition_comment", "action_comment", "active", + "one_shot", "subroutine", "easy", "normal", "hard", "delay_seconds", "conditions", "actions", "false_actions", + nullptr }; + for (size_t i = 0; i < def.numKeys(); i++) { + Bool known = false; + for (Int k = 0; KNOWN_KEYS[k] && !known; k++) { + known = def.keyAt(i) == KNOWN_KEYS[k]; + } + if (!known) { + mcpFail("unknown script key '%s'", def.keyAt(i).c_str()); + } + } + + if (def.has("name")) { + const std::string name = mcpArgString(def, "name"); + if (name.empty()) mcpFail("the script name must not be empty"); + script->setName(AsciiString(name.c_str())); + } + if (def.has("comment")) script->setComment(AsciiString(mcpArgString(def, "comment").c_str())); + if (def.has("condition_comment")) script->setConditionComment(AsciiString(mcpArgString(def, "condition_comment").c_str())); + if (def.has("action_comment")) script->setActionComment(AsciiString(mcpArgString(def, "action_comment").c_str())); + if (def.has("active")) script->setActive(mcpArgBool(def, "active", true)); + if (def.has("one_shot")) script->setOneShot(mcpArgBool(def, "one_shot", true)); + if (def.has("subroutine")) script->setSubroutine(mcpArgBool(def, "subroutine", false)); + if (def.has("easy")) script->setEasy(mcpArgBool(def, "easy", true)); + if (def.has("normal")) script->setNormal(mcpArgBool(def, "normal", true)); + if (def.has("hard")) script->setHard(mcpArgBool(def, "hard", true)); + if (def.has("delay_seconds")) { + const Int delay = mcpArgInt(def, "delay_seconds"); + if (delay < 0) mcpFail("delay_seconds must be >= 0"); + script->setDelayEvalSeconds(delay); + } + if (def.has("conditions")) { + OrCondition *conditions = buildConditions(def.get("conditions")); + deleteInstance(script->getOrCondition()); + script->setOrCondition(conditions); + } + if (def.has("actions")) { + ScriptAction *actions = buildActions(def.get("actions"), "actions"); + deleteInstance(script->getAction()); + script->setAction(actions); + } + if (def.has("false_actions")) { + ScriptAction *actions = buildActions(def.get("false_actions"), "false_actions"); + deleteInstance(script->getFalseAction()); + script->setFalseAction(actions); + } +} + +//------------------------------------------------------------------------------------------------- +// Finding players, groups and scripts in a sides list +//------------------------------------------------------------------------------------------------- + +/// The script editor works on its own copy of the sides list and writes it back on OK, which +/// would silently undo an edit made here in the meantime. It also owns the list that parameter +/// checks run against. +void requireScriptEditorClosed() +{ + if (ScriptDialog::isOpen()) { + mcpFail("the WorldBuilder script editor window is open; close it (OK or Cancel) and retry"); + } +} + +AsciiString sideName(SidesList &sides, Int side) +{ + return sides.getSideInfo(side)->getDict()->getAsciiString(TheKey_playerName); +} + +Int findSide(SidesList &sides, const AsciiString &name) +{ + for (Int i = 0; i < sides.getNumSides(); i++) { + if (sideName(sides, i) == name) return i; + } + return -1; +} + +/// Player index by name; the neutral player is "" or "(neutral)". +Int requireSide(SidesList &sides, const std::string &name) +{ + AsciiString playerName(name.c_str()); + if (playerName.compareNoCase(NEUTRAL_PLAYER_LABEL) == 0) { + playerName.clear(); + } + const Int side = findSide(sides, playerName); + if (side < 0) { + mcpFail("no player named '%s' (see sides.list; the neutral player is \"\")", name.c_str()); + } + return side; +} + +ScriptList *requireScriptList(SidesList &sides, Int side) +{ + ScriptList *list = sides.getSideInfo(side)->getScriptList(); + if (list == nullptr) { + mcpFail("player '%s' has no script list", sideName(sides, side).str()); + } + return list; +} + +std::vector allScripts(SidesList &sides) +{ + std::vector all; + for (Int side = 0; side < sides.getNumSides(); side++) { + ScriptList *list = sides.getSideInfo(side)->getScriptList(); + if (list == nullptr) continue; + ScriptLoc loc; + loc.side = side; + loc.group = nullptr; + loc.index = 0; + for (loc.script = list->getScript(); loc.script; loc.script = loc.script->getNext(), loc.index++) { + all.push_back(loc); + } + for (loc.group = list->getScriptGroup(); loc.group; loc.group = loc.group->getNext()) { + loc.index = 0; + for (loc.script = loc.group->getScript(); loc.script; loc.script = loc.script->getNext(), loc.index++) { + all.push_back(loc); + } + } + } + return all; +} + +/// Script names only have to be unique within a player: AI script sets repeat the same names +/// for every faction, and the game qualifies them per player when it loads them. +bool scriptNameExists(SidesList &sides, Int side, const std::string &name) +{ + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + if (all[i].side == side && name == all[i].script->getName().str()) return true; + } + return false; +} + +std::string describeLocation(SidesList &sides, Int side, ScriptGroup *group) +{ + AsciiString player = sideName(sides, side); + std::string where = std::string("player '") + (player.isEmpty() ? NEUTRAL_PLAYER_LABEL : player.str()) + "'"; + if (group) { + where += std::string(" group '") + group->getName().str() + "'"; + } + return where; +} + +/// A group by name, in one player (side >= 0) or in the whole map. +GroupLoc requireGroup(SidesList &sides, const std::string &name, Int side) +{ + std::vector matches; + for (Int i = 0; i < sides.getNumSides(); i++) { + if (side >= 0 && i != side) continue; + ScriptList *list = sides.getSideInfo(i)->getScriptList(); + if (list == nullptr) continue; + GroupLoc loc; + loc.side = i; + loc.index = 0; + for (loc.group = list->getScriptGroup(); loc.group; loc.group = loc.group->getNext(), loc.index++) { + if (name == loc.group->getName().str()) matches.push_back(loc); + } + } + if (matches.empty()) { + mcpFail("no script group named '%s'%s (see scripts.list)", name.c_str(), side >= 0 ? " for that player" : ""); + } + if (matches.size() > 1) { + mcpFail("%d script groups are named '%s'; pass player to pick one", (int)matches.size(), name.c_str()); + } + return matches[0]; +} + +/// The script named by args.name, narrowed by the optional args.player, args.group +/// (group "" = top level) and args.script_index (position in its list), since maps do hold +/// several scripts of the same name. +ScriptLoc requireScript(SidesList &sides, const McpJson &args) +{ + const std::string name = mcpArgString(args, "name"); + const Int side = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + const bool hasGroup = args.has("group"); + const std::string group = mcpArgString(args, "group", ""); + const Int scriptIndex = mcpArgInt(args, "script_index", -1); + + const std::vector all = allScripts(sides); + std::vector matches; + for (size_t i = 0; i < all.size(); i++) { + const ScriptLoc &loc = all[i]; + if (name != loc.script->getName().str()) continue; + if (side >= 0 && loc.side != side) continue; + if (hasGroup && group != (loc.group ? loc.group->getName().str() : "")) continue; + if (scriptIndex >= 0 && loc.index != scriptIndex) continue; + matches.push_back(loc); + } + if (matches.empty()) { + mcpFail("no script named '%s'%s (see scripts.list)", name.c_str(), + (side >= 0 || hasGroup || scriptIndex >= 0) ? " in that player/group/position" : ""); + } + if (matches.size() > 1) { + std::string places; + for (size_t i = 0; i < matches.size() && i < 4; i++) { + char position[32]; + sprintf(position, " script_index %d", matches[i].index); + if (i) places += "; "; + places += describeLocation(sides, matches[i].side, matches[i].group) + position; + } + mcpFail("%d scripts are named '%s' (%s); pass player, group (\"\" = not in a group) and script_index to pick one", + (int)matches.size(), name.c_str(), places.c_str()); + } + return matches[0]; +} + +/// Where a new or moved script goes: a group (optionally of a given player) or a player's top level. +void resolveDestination(SidesList &sides, const McpJson &args, const char *playerKey, const char *groupKey, + Int *side, ScriptGroup **group) +{ + const std::string groupName = mcpArgString(args, groupKey, ""); + if (!groupName.empty()) { + const GroupLoc loc = requireGroup(sides, groupName, args.has(playerKey) ? requireSide(sides, mcpArgString(args, playerKey)) : -1); + *side = loc.side; + *group = loc.group; + return; + } + if (!args.has(playerKey)) { + mcpFail("pass '%s' (a player name, \"\" for the neutral player) or '%s'", playerKey, groupKey); + } + *side = requireSide(sides, mcpArgString(args, playerKey)); + *group = nullptr; +} + +Int argIndex(const McpJson &args) +{ + const Int index = mcpArgInt(args, "index", INT_MAX); + if (index < 0) { + mcpFail("index must be >= 0"); + } + return index; +} + +McpJson locationJson(SidesList &sides, Int side, ScriptGroup *group) +{ + McpJson j = McpJson::makeObject(); + j.set("player", sideName(sides, side).str()); + if (group) { + j.set("group", group->getName().str()); + } + return j; +} + +bool s_sidesBatch = false; + +void setSidesBatch(bool active) +{ + s_sidesBatch = active; +} + +void commitSides(SidesList &sides) +{ + if (s_sidesBatch) { + // The next command of the batch has to see this edit. + sides.validateSides(); + *TheSidesList = sides; + return; + } + mcpCommit(new SidesListUndoable(sides, mcpDoc())); +} + +//------------------------------------------------------------------------------------------------- +// Warnings +//------------------------------------------------------------------------------------------------- + +ParameterCheckScope::ParameterCheckScope(SidesList *sides) +{ + EditParameter::setCurSidesList(sides); +} + +ParameterCheckScope::~ParameterCheckScope() +{ + EditParameter::setCurSidesList(nullptr); +} + +template +Bool collectParamWarnings(T *item, Bool isAction, const std::string &where, const McpJson &scriptRef, McpJson &out) +{ + Bool any = false; + for (Int i = 0; i < item->getNumParameters(); i++) { + AsciiString warning = EditParameter::getWarningText(item->getParameter(i), isAction); + warning.trim(); + if (warning.isEmpty()) continue; + any = true; + McpJson entry = scriptRef; + entry.set("where", where).set("parameter", i + 1).set("warning", warning.str()); + out.push(entry); + } + item->setWarnings(any); + return any; +} + +Bool collectActionWarnings(ScriptAction *first, const char *key, const McpJson &scriptRef, McpJson &out) +{ + Bool any = false; + Int ndx = 0; + for (ScriptAction *action = first; action; action = action->getNext(), ndx++) { + char where[64]; + sprintf(where, "%s[%d]", key, ndx); + if (collectParamWarnings(action, TRUE, where, scriptRef, out)) any = true; + } + return any; +} + +/// Runs the script editor's parameter checks on one script and updates its warning flags. +/// Needs a ParameterCheckScope for the sides list the script is in. +void collectScriptWarnings(SidesList &sides, Int side, Script *script, McpJson &out) +{ + McpJson scriptRef = McpJson::makeObject(); + scriptRef.set("player", sideName(sides, side).str()).set("script", script->getName().str()); + Bool any = false; + Int o = 0; + for (OrCondition *orCond = script->getOrCondition(); orCond; orCond = orCond->getNextOrCondition(), o++) { + Int a = 0; + for (Condition *cond = orCond->getFirstAndCondition(); cond; cond = cond->getNext(), a++) { + char where[64]; + sprintf(where, "conditions[%d][%d]", o, a); + if (collectParamWarnings(cond, FALSE, where, scriptRef, out)) any = true; + } + } + if (collectActionWarnings(script->getAction(), "actions", scriptRef, out)) any = true; + if (collectActionWarnings(script->getFalseAction(), "false_actions", scriptRef, out)) any = true; + script->setWarnings(any); +} + +McpJson scriptWarnings(SidesList &sides, Int side, Script *script) +{ + ParameterCheckScope scope(&sides); + McpJson warnings = McpJson::makeArray(); + collectScriptWarnings(sides, side, script, warnings); + return warnings; +} + +//------------------------------------------------------------------------------------------------- +// scripts.list / scripts.get / scripts.verify +//------------------------------------------------------------------------------------------------- + +/// How much scripts.list reports per script. +enum ListDetail +{ + LIST_GROUPS, ///< Groups with their script counts only. + LIST_NAMES, ///< Script names. + LIST_FULL ///< Name, flags and comment per script. +}; + +/// Adds the scripts of one list that pass the filter, in the wanted detail, and counts them. +void listScriptsInto(Script *first, const std::string &filter, ListDetail detail, McpJson &entry, Int *total) +{ + McpJson scripts = McpJson::makeArray(); + Int count = 0, inactive = 0, subroutines = 0; + for (Script *script = first; script; script = script->getNext()) { + if (!mcpContainsNoCase(script->getName().str(), filter.c_str())) continue; + count++; + if (!script->isActive()) inactive++; + if (script->isSubroutine()) subroutines++; + if (detail == LIST_FULL) { + scripts.push(describeScriptBrief(script)); + } else if (detail == LIST_NAMES) { + scripts.push(McpJson(script->getName().str())); + } + } + *total += count; + entry.set("script_count", count); + if (inactive) entry.set("inactive", inactive); + if (subroutines) entry.set("subroutines", subroutines); + if (detail != LIST_GROUPS) { + entry.set("scripts", scripts); + } +} + +McpJson cmdListScripts(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + const std::string filter = mcpArgString(args, "filter", ""); + const bool hasGroup = args.has("group"); + const std::string onlyGroup = mcpArgString(args, "group", ""); + + // AI script sets hold thousands of scripts, so the default detail depends on how many match. + const std::string detailName = mcpArgString(args, "detail", "auto"); + ListDetail detail = LIST_FULL; + bool reduced = false; + if (detailName == "groups") { + detail = LIST_GROUPS; + } else if (detailName == "names") { + detail = LIST_NAMES; + } else if (detailName == "auto") { + Int matching = 0; + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + if (onlySide >= 0 && all[i].side != onlySide) continue; + if (hasGroup && onlyGroup != (all[i].group ? all[i].group->getName().str() : "")) continue; + if (mcpContainsNoCase(all[i].script->getName().str(), filter.c_str())) matching++; + } + if (matching > 400) { + detail = LIST_GROUPS; + reduced = true; + } else if (matching > 120) { + detail = LIST_NAMES; + reduced = true; + } + } else if (detailName != "full") { + mcpFail("detail must be auto, groups, names or full"); + } + + McpJson players = McpJson::makeArray(); + Int total = 0; + for (Int side = 0; side < sides.getNumSides(); side++) { + if (onlySide >= 0 && side != onlySide) continue; + ScriptList *list = sides.getSideInfo(side)->getScriptList(); + if (list == nullptr) continue; + McpJson player = McpJson::makeObject(); + player.set("player", sideName(sides, side).str()); + // The scripts outside of any group are the group "". + if (!hasGroup || onlyGroup.empty()) { + listScriptsInto(list->getScript(), filter, detail, player, &total); + } + McpJson groups = McpJson::makeArray(); + for (ScriptGroup *group = list->getScriptGroup(); group; group = group->getNext()) { + if (hasGroup && onlyGroup != group->getName().str()) continue; + McpJson entry = McpJson::makeObject(); + entry.set("name", group->getName().str()); + if (!group->isActive()) entry.set("active", false); + if (group->isSubroutine()) entry.set("subroutine", true); + Int inGroup = 0; + listScriptsInto(group->getScript(), filter, detail, entry, &inGroup); + // With a filter, only groups that hold a match are listed. + if (!filter.empty() && inGroup == 0) continue; + total += inGroup; + groups.push(entry); + } + player.set("groups", groups); + players.push(player); + } + McpJson j = McpJson::makeObject(); + j.set("players", players).set("script_count", total); + j.set("detail", detail == LIST_GROUPS ? "groups" : (detail == LIST_NAMES ? "names" : "full")); + if (reduced) { + j.set("note", "many scripts: detail was reduced. Narrow with player, group or filter, or pass detail."); + } + return j; +} + +McpJson cmdGetScript(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const ScriptLoc loc = requireScript(sides, args); + McpJson j = locationJson(sides, loc.side, loc.group); + j.set("index", loc.index); + j.set("script", describeScript(loc.script)); + // The whole script as the editor words it. + j.set("text", loc.script->getUiText().str()); + return j; +} + +bool moreWarnings(const std::pair &a, const std::pair &b) +{ + return a.first != b.first ? a.first > b.first : a.second < b.second; +} + +/// Warnings by their text with a count and the players they occur for, most frequent first. +McpJson summarizeWarnings(const McpJson &warnings) +{ + std::map counts; + std::map > players; + for (size_t i = 0; i < warnings.size(); i++) { + const std::string &text = warnings.at(i).get("warning").asString(); + counts[text]++; + const McpJson &owner = warnings.at(i).has("player") ? warnings.at(i).get("player") : warnings.at(i).get("owner"); + players[text].insert(owner.asString()); + } + std::vector > order; + for (std::map::const_iterator it = counts.begin(); it != counts.end(); ++it) { + order.push_back(std::make_pair(it->second, it->first)); + } + std::sort(order.begin(), order.end(), moreWarnings); + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < order.size() && i < 60; i++) { + McpJson who = McpJson::makeArray(); + const std::set &names = players[order[i].second]; + for (std::set::const_iterator it = names.begin(); it != names.end(); ++it) { + who.push(McpJson(*it)); + } + list.push(McpJson::makeObject().set("warning", order[i].second).set("count", order[i].first).set("players", who)); + } + return list; +} + +McpJson cmdVerifyScripts(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + const std::string onlyName = mcpArgString(args, "name", ""); + + // Not all of the editor's checks fall back to the map's sides list when none is set. + ParameterCheckScope scope(TheSidesList); + McpJson warnings = McpJson::makeArray(); + const std::vector all = allScripts(sides); + std::set > names; + McpJson duplicates = McpJson::makeArray(); + Int checked = 0; + for (size_t i = 0; i < all.size(); i++) { + const std::string name = all[i].script->getName().str(); + if (onlySide >= 0 && all[i].side != onlySide) continue; + // A name one player uses twice is ambiguous wherever a script refers to it. + if (!names.insert(std::make_pair(all[i].side, name)).second) { + duplicates.push(McpJson::makeObject().set("player", sideName(sides, all[i].side).str()).set("script", name)); + } + if (!onlyName.empty() && name != onlyName) continue; + collectScriptWarnings(sides, all[i].side, all[i].script, warnings); + checked++; + } + // The editor shows a group as flagged when one of its scripts is. + for (Int side = 0; side < sides.getNumSides(); side++) { + ScriptList *list = sides.getSideInfo(side)->getScriptList(); + if (list == nullptr) continue; + for (ScriptGroup *group = list->getScriptGroup(); group; group = group->getNext()) { + Bool any = false; + for (Script *script = group->getScript(); script; script = script->getNext()) { + if (script->hasWarnings()) any = true; + } + group->setWarnings(any); + } + } + const bool summary = mcpArgBool(args, "summary", false); + McpJson j = McpJson::makeObject(); + j.set("scripts_checked", checked).set("warning_count", (int)warnings.size()); + j.set("warnings", summary ? summarizeWarnings(warnings) : warnings); + j.set("duplicate_names", summary ? McpJson((int)duplicates.size()) : duplicates); + if (onlyName.empty()) { + Int teamsChecked = 0; + const McpJson teamWarnings = verifyTeams(sides, onlySide, "", &teamsChecked); + j.set("team_warning_count", (int)teamWarnings.size()); + j.set("team_warnings", summary ? summarizeWarnings(teamWarnings) : teamWarnings); + j.set("teams_checked", teamsChecked); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.find +//------------------------------------------------------------------------------------------------- + +struct FindQuery +{ + std::string text; ///< Substring of the editor wording of a condition or action. + std::string value; ///< Exact parameter value. + std::string type; ///< Condition or action type. + Int paramType; ///< Parameter::ParameterType, or -1 for any. +}; + +/// A parameter value as the text scripts.get shows for it. +std::string paramValueText(const Parameter *parm) +{ + const McpJson value = paramValueToJson(parm); + return value.isString() ? value.asString() : value.dump(); +} + +/// True if a condition or action matches every part of the query. value and param_type have to +/// hold for the same parameter. +template +bool entryMatches(T *item, const char *typeName, const FindQuery &query) +{ + if (!query.type.empty() && _stricmp(query.type.c_str(), typeName) != 0) { + return false; + } + if (!query.value.empty() || query.paramType >= 0) { + bool found = false; + for (Int i = 0; i < item->getNumParameters() && !found; i++) { + Parameter *parm = item->getParameter(i); + if (query.paramType >= 0 && parm->getParameterType() != query.paramType) continue; + if (!query.value.empty() && _stricmp(query.value.c_str(), paramValueText(parm).c_str()) != 0) continue; + found = true; + } + if (!found) { + return false; + } + } + if (!query.text.empty() && !mcpContainsNoCase(item->getUiText().str(), query.text.c_str())) { + return false; + } + return true; +} + +void findInActions(ScriptAction *first, const char *key, const FindQuery &query, McpJson &hits) +{ + Int ndx = 0; + for (ScriptAction *action = first; action; action = action->getNext(), ndx++) { + const ActionTemplate *at = TheScriptEngine->getActionTemplate(action->getActionType()); + const char *typeName = at ? at->m_internalName.str() : ""; + if (!entryMatches(action, typeName, query)) continue; + char where[64]; + sprintf(where, "%s[%d]", key, ndx); + hits.push(McpJson::makeObject().set("where", where).set("type", typeName).set("text", action->getUiText().str())); + } +} + +/// Searches inside scripts: which conditions and actions use a type, a parameter value (a team, +/// script, waypoint, object type ...) or some wording. Without type and param_type it also looks +/// at script names and comments and at the team properties, which name scripts too. +McpJson cmdFindScripts(const McpJson &args) +{ + mcpDoc(); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + + FindQuery query; + query.text = mcpArgString(args, "text", ""); + query.value = mcpArgString(args, "value", ""); + query.type = mcpArgString(args, "type", ""); + query.paramType = -1; + if (args.has("param_type")) { + const std::string paramType = mcpArgString(args, "param_type"); + for (Int i = 0; i < Parameter::NUM_ITEMS; i++) { + if (_stricmp(paramType.c_str(), PARAMETER_TYPE_NAMES[i]) == 0) query.paramType = i; + } + if (query.paramType < 0) { + mcpFail("unknown param_type '%s' (e.g. TEAM, SCRIPT, WAYPOINT, OBJECT_TYPE, FLAG, COUNTER)", paramType.c_str()); + } + } + if (query.text.empty() && query.value.empty() && query.type.empty() && query.paramType < 0) { + mcpFail("pass at least one of text, value, type, param_type"); + } + if (!query.type.empty() && findActionType(query.type.c_str()) < 0 && findConditionType(query.type.c_str()) < 0) { + mcpFail("unknown condition or action type '%s' (see scripts.list_actions / scripts.list_conditions)", query.type.c_str()); + } + const Int onlySide = args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1; + const Int limit = mcpArgInt(args, "limit", 50); + const Int offset = mcpArgInt(args, "offset", 0); + const Int maxHits = mcpArgInt(args, "max_hits_per_script", 10); + // Names, comments and team properties are plain text, so they only take part in a text or value search. + const bool plainText = query.type.empty() && query.paramType < 0; + + McpJson found = McpJson::makeArray(); + Int matchedScripts = 0, totalHits = 0; + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + const ScriptLoc &loc = all[i]; + if (onlySide >= 0 && loc.side != onlySide) continue; + Script *script = loc.script; + McpJson hits = McpJson::makeArray(); + if (plainText) { + const AsciiString name = script->getName(); + const AsciiString comment = script->getComment(); + if (query.value.empty() ? mcpContainsNoCase(name.str(), query.text.c_str()) : (_stricmp(name.str(), query.value.c_str()) == 0 + && mcpContainsNoCase(name.str(), query.text.c_str()))) { + hits.push(McpJson::makeObject().set("where", "name").set("text", name.str())); + } + if (query.value.empty() && !comment.isEmpty() && mcpContainsNoCase(comment.str(), query.text.c_str())) { + hits.push(McpJson::makeObject().set("where", "comment").set("text", comment.str())); + } + } + Int o = 0; + for (OrCondition *orCond = script->getOrCondition(); orCond; orCond = orCond->getNextOrCondition(), o++) { + Int a = 0; + for (Condition *cond = orCond->getFirstAndCondition(); cond; cond = cond->getNext(), a++) { + const ConditionTemplate *ct = TheScriptEngine->getConditionTemplate(cond->getConditionType()); + const char *typeName = ct ? ct->m_internalName.str() : ""; + if (!entryMatches(cond, typeName, query)) continue; + char where[64]; + sprintf(where, "conditions[%d][%d]", o, a); + hits.push(McpJson::makeObject().set("where", where).set("type", typeName).set("text", cond->getUiText().str())); + } + } + findInActions(script->getAction(), "actions", query, hits); + findInActions(script->getFalseAction(), "false_actions", query, hits); + if (hits.size() == 0) continue; + + matchedScripts++; + totalHits += (Int)hits.size(); + if (matchedScripts <= offset || (Int)found.size() >= limit) continue; + McpJson entry = locationJson(sides, loc.side, loc.group); + entry.set("script", script->getName().str()).set("script_index", loc.index); + entry.set("active", (bool)(script->isActive() != 0)).set("hit_count", (int)hits.size()); + if ((Int)hits.size() > maxHits) { + // Scripts that define object lists can match hundreds of times. + McpJson shown = McpJson::makeArray(); + for (Int h = 0; h < maxHits; h++) { + shown.push(hits.at(h)); + } + entry.set("hits", shown); + } else { + entry.set("hits", hits); + } + found.push(entry); + } + + McpJson teams = McpJson::makeArray(); + Int totalTeamHits = 0; + if (plainText) { + for (Int i = 0; i < sides.getNumTeams(); i++) { + Dict *d = sides.getTeamInfo(i)->getDict(); + const AsciiString owner = d->getAsciiString(TheKey_teamOwner); + if (onlySide >= 0 && owner != sideName(sides, onlySide)) continue; + for (Int k = 0; k < d->getPairCount(); k++) { + if (d->getNthType(k) != Dict::DICT_ASCIISTRING || d->getNthKey(k) == TheKey_teamOwner) continue; + const AsciiString value = d->getNthAsciiString(k); + if (value.isEmpty()) continue; + if (!query.value.empty() && _stricmp(value.str(), query.value.c_str()) != 0) continue; + if (!mcpContainsNoCase(value.str(), query.text.c_str())) continue; + totalTeamHits++; + if ((Int)teams.size() >= limit) continue; + teams.push(McpJson::makeObject() + .set("team", d->getAsciiString(TheKey_teamName).str()) + .set("owner", owner.str()) + .set("key", TheNameKeyGenerator->keyToName(d->getNthKey(k)).str()) + .set("value", value.str())); + } + } + } + + McpJson j = McpJson::makeObject(); + j.set("total_scripts", matchedScripts).set("total_hits", totalHits).set("scripts", found); + if (plainText) { + j.set("total_team_hits", totalTeamHits).set("teams", teams); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.add / update / delete / move +//------------------------------------------------------------------------------------------------- + +McpJson cmdAddScript(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const McpJson &def = args.get("script"); + if (!def.isObject()) { + mcpFail("argument 'script' must be an object"); + } + const std::string name = mcpArgString(def, "name"); + const Int index = argIndex(args); + + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + Int side; + ScriptGroup *group; + resolveDestination(sides, args, "player", "group", &side, &group); + if (!mcpArgBool(args, "allow_duplicate_name", false) && scriptNameExists(sides, side, name)) { + mcpFail("this player already has a script named '%s'; scripts are referenced by name, so pick another name or use scripts.update", + name.c_str()); + } + + // Same starting point as the editor's New Script, minus its placeholder action. + Script *script = newInstance(Script); + OrCondition *orCond = newInstance(OrCondition); + orCond->setFirstAndCondition(newInstance(Condition)(Condition::CONDITION_TRUE)); + script->setOrCondition(orCond); + if (group) { + group->addScript(script, index); + } else { + requireScriptList(sides, side)->addScript(script, index); + } + applyScriptJson(script, def); + + McpJson j = locationJson(sides, side, group); + j.set("name", script->getName().str()); + j.set("warnings", scriptWarnings(sides, side, script)); + commitSides(sides); + return j; +} + +McpJson cmdUpdateScript(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const McpJson &def = args.get("script"); + if (!def.isObject()) { + mcpFail("argument 'script' must be an object with the keys to change"); + } + + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const ScriptLoc loc = requireScript(sides, args); + if (def.has("name")) { + const std::string newName = mcpArgString(def, "name"); + if (newName != loc.script->getName().str() && !mcpArgBool(args, "allow_duplicate_name", false) + && scriptNameExists(sides, loc.side, newName)) { + mcpFail("this player already has a script named '%s'", newName.c_str()); + } + } + applyScriptJson(loc.script, def); + + McpJson j = locationJson(sides, loc.side, loc.group); + j.set("name", loc.script->getName().str()); + j.set("warnings", scriptWarnings(sides, loc.side, loc.script)); + commitSides(sides); + return j; +} + +McpJson cmdDeleteScript(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const ScriptLoc loc = requireScript(sides, args); + McpJson j = locationJson(sides, loc.side, loc.group); + j.set("deleted", loc.script->getName().str()); + if (loc.group) { + loc.group->deleteScript(loc.script); + } else { + requireScriptList(sides, loc.side)->deleteScript(loc.script); + } + commitSides(sides); + return j; +} + +McpJson cmdMoveScript(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const Int index = argIndex(args); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const ScriptLoc loc = requireScript(sides, args); + + // Without a destination the script stays in its list and only changes position. + Int side = loc.side; + ScriptGroup *group = loc.group; + if (args.has("to_group") || args.has("to_player")) { + if (!args.has("to_player") && mcpArgString(args, "to_group", "").empty()) { + group = nullptr; // to_group "": out of its group, to the top level of the same player. + } else { + resolveDestination(sides, args, "to_player", "to_group", &side, &group); + } + } + + Script *moved = loc.script->duplicate(); + if (loc.group) { + loc.group->deleteScript(loc.script); + } else { + requireScriptList(sides, loc.side)->deleteScript(loc.script); + } + if (group) { + group->addScript(moved, index); + } else { + requireScriptList(sides, side)->addScript(moved, index); + } + + McpJson j = locationJson(sides, side, group); + j.set("name", moved->getName().str()); + commitSides(sides); + return j; +} + +//------------------------------------------------------------------------------------------------- +// Script groups +//------------------------------------------------------------------------------------------------- + +McpJson cmdAddGroup(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const std::string name = mcpArgString(args, "name"); + if (name.empty()) { + mcpFail("the group name must not be empty"); + } + const Int index = argIndex(args); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "player")); + ScriptList *list = requireScriptList(sides, side); + for (ScriptGroup *group = list->getScriptGroup(); group; group = group->getNext()) { + if (name == group->getName().str()) { + mcpFail("player '%s' already has a script group named '%s'", sideName(sides, side).str(), name.c_str()); + } + } + + ScriptGroup *group = newInstance(ScriptGroup); + group->setName(AsciiString(name.c_str())); + group->setActive(mcpArgBool(args, "active", true)); + group->setSubroutine(mcpArgBool(args, "subroutine", false)); + list->addGroup(group, index); + McpJson j = locationJson(sides, side, group); + commitSides(sides); + return j; +} + +McpJson cmdUpdateGroup(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + GroupLoc loc = requireGroup(sides, mcpArgString(args, "name"), + args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1); + const Int toSide = args.has("to_player") ? requireSide(sides, mcpArgString(args, "to_player")) : loc.side; + + if (args.has("new_name")) { + const std::string newName = mcpArgString(args, "new_name"); + if (newName.empty()) mcpFail("the group name must not be empty"); + loc.group->setName(AsciiString(newName.c_str())); + } + if (args.has("active")) loc.group->setActive(mcpArgBool(args, "active", true)); + if (args.has("subroutine")) loc.group->setSubroutine(mcpArgBool(args, "subroutine", false)); + + ScriptGroup *group = loc.group; + if (args.has("index") || toSide != loc.side) { + const Int index = argIndex(args); + ScriptList *target = requireScriptList(sides, toSide); + group = loc.group->duplicate(); + requireScriptList(sides, loc.side)->deleteGroup(loc.group); + target->addGroup(group, index); + } + // Two groups of one player with the same name could not be told apart. + Int sameName = 0; + for (ScriptGroup *other = requireScriptList(sides, toSide)->getScriptGroup(); other; other = other->getNext()) { + if (other->getName() == group->getName()) sameName++; + } + if (sameName > 1) { + mcpFail("player '%s' already has a script group named '%s'", sideName(sides, toSide).str(), group->getName().str()); + } + + McpJson j = locationJson(sides, toSide, group); + j.set("active", (bool)(group->isActive() != 0)).set("subroutine", (bool)(group->isSubroutine() != 0)); + commitSides(sides); + return j; +} + +McpJson cmdDeleteGroup(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const GroupLoc loc = requireGroup(sides, mcpArgString(args, "name"), + args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1); + Int numScripts = 0; + for (Script *script = loc.group->getScript(); script; script = script->getNext()) { + numScripts++; + } + if (numScripts > 0 && !mcpArgBool(args, "delete_scripts", false)) { + mcpFail("group '%s' holds %d script(s); pass delete_scripts=true to delete them with it, or move them out first", + loc.group->getName().str(), numScripts); + } + McpJson j = locationJson(sides, loc.side, loc.group); + j.set("scripts_deleted", numScripts); + requireScriptList(sides, loc.side)->deleteGroup(loc.group); + commitSides(sides); + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.export +//------------------------------------------------------------------------------------------------- + +class FileOutputStream : public OutputStream +{ +public: + FileOutputStream(FILE *file) : m_file(file) {} + virtual Int write(const void *pData, Int numBytes) override + { + return (Int)fwrite(pData, 1, numBytes, m_file); + } + +private: + FILE *m_file; +}; + +template +void collectTeamNames(T *item, std::set &teams) +{ + for (Int i = 0; i < item->getNumParameters(); i++) { + Parameter *parm = item->getParameter(i); + if (parm->getParameterType() == Parameter::TEAM) { + teams.insert(parm->getString().str()); + } + } +} + +void collectTeamNames(Script *script, std::set &teams) +{ + for (OrCondition *orCond = script->getOrCondition(); orCond; orCond = orCond->getNextOrCondition()) { + for (Condition *cond = orCond->getFirstAndCondition(); cond; cond = cond->getNext()) { + collectTeamNames(cond, teams); + } + } + for (ScriptAction *action = script->getAction(); action; action = action->getNext()) { + collectTeamNames(action, teams); + } + for (ScriptAction *action = script->getFalseAction(); action; action = action->getNext()) { + collectTeamNames(action, teams); + } +} + +/// Writes a script file the way the script editor's Export does. Without a selection it holds +/// all scripts with their players and teams; a selection (player, group or one script) is written +/// as one list that imports into a player of choice, with the teams its scripts refer to. +/// Waypoints, trigger areas and units go along on request, as in the editor's export options. +McpJson cmdExportScripts(const McpJson &args) +{ + mcpDoc(); + const std::string path = mcpArgString(args, "path"); + ScriptList::updateDefaults(); + SidesList &sides = *TheSidesList; + const bool selection = args.has("name") || args.has("group") || args.has("player"); + const bool includePlayers = mcpArgBool(args, "include_players", !selection); + + ScriptList *lists[MAX_PLAYER_COUNT]; + for (Int i = 0; i < MAX_PLAYER_COUNT; i++) { + lists[i] = nullptr; + } + Int numLists = 1; + ScriptList *ownedList = nullptr; // Holds copies when only a group or a script is exported. + if (!selection) { + numLists = sides.getNumSides(); + for (Int i = 0; i < numLists; i++) { + lists[i] = sides.getSideInfo(i)->getScriptList(); + } + } else if (args.has("name")) { + const ScriptLoc loc = requireScript(sides, args); + ownedList = newInstance(ScriptList); + ownedList->addScript(loc.script->duplicate(), 0); + lists[0] = ownedList; + } else if (!mcpArgString(args, "group", "").empty()) { + const GroupLoc loc = requireGroup(sides, mcpArgString(args, "group"), + args.has("player") ? requireSide(sides, mcpArgString(args, "player")) : -1); + ownedList = newInstance(ScriptList); + ownedList->addGroup(loc.group->duplicate(), 0); + lists[0] = ownedList; + } else { + lists[0] = requireScriptList(sides, requireSide(sides, mcpArgString(args, "player"))); + } + + Int numScripts = 0; + std::set referencedTeams; + for (Int i = 0; i < numLists; i++) { + if (lists[i] == nullptr) continue; + for (Script *script = lists[i]->getScript(); script; script = script->getNext()) { + collectTeamNames(script, referencedTeams); + numScripts++; + } + for (ScriptGroup *group = lists[i]->getScriptGroup(); group; group = group->getNext()) { + for (Script *script = group->getScript(); script; script = script->getNext()) { + collectTeamNames(script, referencedTeams); + numScripts++; + } + } + } + + // A script file is the whole AI of a mod, so it is not replaced by accident or without a copy. + std::string backupPath; + if (::GetFileAttributes(path.c_str()) != INVALID_FILE_ATTRIBUTES) { + if (!mcpArgBool(args, "overwrite", false)) { + deleteInstance(ownedList); + mcpFail("'%s' already exists; pass overwrite=true to replace it (a backup copy is kept)", path.c_str()); + } + if (mcpArgBool(args, "backup", true)) { + SYSTEMTIME now; + ::GetLocalTime(&now); + char stamp[40]; + sprintf(stamp, ".bak-%04d%02d%02d-%02d%02d%02d", now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond); + backupPath = path + stamp; + if (!::CopyFile(path.c_str(), backupPath.c_str(), FALSE)) { + deleteInstance(ownedList); + mcpFail("could not back up '%s' to '%s'", path.c_str(), backupPath.c_str()); + } + } + } + + // What of the map the exported scripts need. A full export takes all waypoints and areas, a + // selection those its scripts name; units are the named ones and those of the named teams. + const bool includeWaypoints = mcpArgBool(args, "include_waypoints", false); + const bool includeAreas = mcpArgBool(args, "include_areas", false); + const bool includeUnits = mcpArgBool(args, "include_units", false); + std::set namedWaypoints, namedPaths, namedAreas, namedUnits; + for (Int i = 0; i < numLists; i++) { + if (lists[i] == nullptr) continue; + for (Int pass = 0; pass < 2; pass++) { + ScriptGroup *group = pass ? lists[i]->getScriptGroup() : nullptr; + do { + for (Script *script = group ? group->getScript() : (pass ? nullptr : lists[i]->getScript()); script; script = script->getNext()) { + std::vector uses; + collectParamUses(script, uses); + for (size_t k = 0; k < uses.size(); k++) { + const std::string value = uses[k].parm->getString().str(); + switch (uses[k].parm->getParameterType()) { + case Parameter::WAYPOINT: namedWaypoints.insert(value); break; + case Parameter::WAYPOINT_PATH: namedPaths.insert(value); break; + case Parameter::TRIGGER_AREA: namedAreas.insert(value); break; + case Parameter::UNIT: namedUnits.insert(value); break; + default: break; + } + } + } + group = group ? group->getNext() : nullptr; + } while (group); + } + } + std::vector exportObjects; + std::set exportWaypointIds; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + Dict *props = obj->getProperties(); + Bool exists = false; + bool take = false; + if (obj->isWaypoint()) { + if (includeWaypoints) { + take = !selection || namedWaypoints.count(obj->getWaypointName().str()) + || namedPaths.count(props->getAsciiString(TheKey_waypointPathLabel1, &exists).str()) + || namedPaths.count(props->getAsciiString(TheKey_waypointPathLabel2, &exists).str()) + || namedPaths.count(props->getAsciiString(TheKey_waypointPathLabel3, &exists).str()); + } + if (take) exportWaypointIds.insert(obj->getWaypointID()); + } else if (includeUnits && !(obj->getFlags() & (FLAG_ROAD_FLAGS | FLAG_BRIDGE_FLAGS))) { + const AsciiString unitName = props->getAsciiString(TheKey_objectName, &exists); + const AsciiString ownerTeam = props->getAsciiString(TheKey_originalOwner, &exists); + if (!unitName.isEmpty() && namedUnits.count(unitName.str())) { + take = true; + referencedTeams.insert(ownerTeam.str()); + } else if (ownerTeam != "team" && referencedTeams.count(ownerTeam.str())) { + take = true; + } + } + if (take) exportObjects.push_back(obj); + } + std::vector exportAreas; + for (PolygonTrigger *trigger = PolygonTrigger::getFirstPolygonTrigger(); includeAreas && trigger; trigger = trigger->getNext()) { + if (trigger->isWaterArea()) continue; + if (!selection || namedAreas.count(trigger->getTriggerName().str())) exportAreas.push_back(trigger); + } + std::vector > exportLinks; + CWorldBuilderDoc *linkDoc = mcpDoc(); + for (Int i = 0; i < linkDoc->getNumWaypointLinks(); i++) { + Int from, to; + linkDoc->getWaypointLink(i, &from, &to); + if (exportWaypointIds.count(from) && exportWaypointIds.count(to)) exportLinks.push_back(std::make_pair(from, to)); + } + + FILE *fp = fopen(path.c_str(), "wb"); + if (fp == nullptr) { + deleteInstance(ownedList); + mcpFail("cannot write '%s'", path.c_str()); + } + Int numTeams = 0; + { + FileOutputStream stream(fp); + DataChunkOutput chunkWriter(&stream); + ScriptList::WriteScriptsDataChunk(chunkWriter, lists, numLists); + + chunkWriter.openDataChunk("ScriptsPlayers", K_SCRIPT_FILE_PLAYERS_VERSION_2); + chunkWriter.writeInt(includePlayers); + if (!selection || includePlayers) { + chunkWriter.writeInt(sides.getNumSides()); + for (Int i = 0; i < sides.getNumSides(); i++) { + chunkWriter.writeAsciiString(sideName(sides, i)); + if (includePlayers) { + chunkWriter.writeDict(*sides.getSideInfo(i)->getDict()); + } + } + } else { + chunkWriter.writeInt(1); + chunkWriter.writeAsciiString("**SELECTION**"); + } + chunkWriter.closeDataChunk(); + + chunkWriter.openDataChunk("ObjectsList", K_OBJECTS_VERSION_3); + for (size_t i = 0; i < exportObjects.size(); i++) { + MapObject *obj = exportObjects[i]; + chunkWriter.openDataChunk("Object", K_OBJECTS_VERSION_3); + chunkWriter.writeReal(obj->getLocation()->x); + chunkWriter.writeReal(obj->getLocation()->y); + chunkWriter.writeReal(obj->getLocation()->z); + chunkWriter.writeReal(obj->getAngle()); + chunkWriter.writeInt(obj->getFlags()); + chunkWriter.writeAsciiString(obj->getName()); + chunkWriter.writeDict(*obj->getProperties()); + chunkWriter.closeDataChunk(); + } + chunkWriter.closeDataChunk(); + + chunkWriter.openDataChunk("PolygonTriggers", K_TRIGGERS_VERSION_3); + chunkWriter.writeInt((Int)exportAreas.size()); + for (size_t i = 0; i < exportAreas.size(); i++) { + PolygonTrigger *trigger = exportAreas[i]; + chunkWriter.writeAsciiString(trigger->getTriggerName()); + chunkWriter.writeInt(trigger->getID()); + chunkWriter.writeByte(trigger->isWaterArea()); + chunkWriter.writeByte(trigger->isRiver()); + chunkWriter.writeInt(trigger->getRiverStart()); + chunkWriter.writeInt(trigger->getNumPoints()); + for (Int k = 0; k < trigger->getNumPoints(); k++) { + chunkWriter.writeInt(trigger->getPoint(k)->x); + chunkWriter.writeInt(trigger->getPoint(k)->y); + chunkWriter.writeInt(trigger->getPoint(k)->z); + } + } + chunkWriter.closeDataChunk(); + + chunkWriter.openDataChunk("ScriptTeams", K_SCRIPT_FILE_TEAMS_VERSION_1); + for (Int i = 0; i < sides.getNumTeams(); i++) { + Dict *d = sides.getTeamInfo(i)->getDict(); + if (selection && !referencedTeams.count(d->getAsciiString(TheKey_teamName).str())) continue; + chunkWriter.writeDict(*d); + numTeams++; + } + chunkWriter.closeDataChunk(); + + chunkWriter.openDataChunk("WaypointsList", K_WAYPOINTS_VERSION_1); + chunkWriter.writeInt((Int)exportLinks.size()); + for (size_t i = 0; i < exportLinks.size(); i++) { + chunkWriter.writeInt(exportLinks[i].first); + chunkWriter.writeInt(exportLinks[i].second); + } + chunkWriter.closeDataChunk(); + } + fclose(fp); + deleteInstance(ownedList); + + McpJson j = McpJson::makeObject(); + j.set("path", path).set("scripts", numScripts).set("teams", numTeams); + j.set("players", (!selection || includePlayers) ? sides.getNumSides() : 0); + if (includeWaypoints || includeAreas || includeUnits) { + j.set("waypoints", (int)exportWaypointIds.size()).set("waypoint_links", (int)exportLinks.size()); + j.set("units", (int)(exportObjects.size() - exportWaypointIds.size())).set("areas", (int)exportAreas.size()); + } + if (!backupPath.empty()) { + j.set("backup", backupPath); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// scripts.import +//------------------------------------------------------------------------------------------------- + +struct ImportState +{ + SidesList *sides; + AsciiString playerNames[MAX_PLAYER_COUNT]; + Int numNames; + Int playersAdded; + Int teamsAdded; + McpJson notes; + + // Map data of the file: objects (units and waypoints), trigger areas and waypoint links. + Bool wantMapData; + MapObject *firstObject, *lastObject; + Int numWaypoints, numUnits; + std::vector triggers; + std::vector > links; + Int waypointBase, maxWaypoint; + std::map existingWaypoint; ///< File waypoint id -> id of the map waypoint it duplicates. +}; + +void addNote(ImportState *state, const char *fmt, const char *a, const char *b = "") +{ + AsciiString note; + note.format(fmt, a, b); + state->notes.push(McpJson(note.str())); +} + +/// Player names of the script lists in the file, in order, and optionally their dicts. Like the +/// editor, players the map does not have yet are added; existing ones are left alone. +Bool parseImportPlayers(DataChunkInput &file, DataChunkInfo *info, void *userData) +{ + ImportState *state = (ImportState *)userData; + Int readDicts = 0; + if (info->version >= K_SCRIPT_FILE_PLAYERS_VERSION_2) { + readDicts = file.readInt(); + } + const Int numNames = file.readInt(); + for (Int i = 0; i < numNames && i < MAX_PLAYER_COUNT; i++) { + state->playerNames[i] = file.readAsciiString(); + state->numNames = i + 1; + if (!readDicts) continue; + Dict sideDict = file.readDict(); + if (findSide(*state->sides, state->playerNames[i]) >= 0) continue; + if (state->sides->getNumSides() >= MAX_PLAYER_COUNT) { + addNote(state, "player '%s' not added: the map has the maximum number of players", state->playerNames[i].str()); + continue; + } + state->sides->addSide(&sideDict); + SidesInfo *side = state->sides->findSideInfo(state->playerNames[i]); + if (side != nullptr) { + side->setScriptList(newInstance(ScriptList)); + state->playersAdded++; + } + } + return true; +} + +/// Teams the map does not have yet are added. The editor asks for a new owner when the owning +/// player is missing; here such a team goes to the neutral player. +Bool parseImportTeams(DataChunkInput &file, DataChunkInfo *info, void *userData) +{ + ImportState *state = (ImportState *)userData; + while (!file.atEndOfChunk()) { + Dict teamDict = file.readDict(); + const AsciiString teamName = teamDict.getAsciiString(TheKey_teamName); + if (state->sides->findTeamInfo(teamName)) continue; + const AsciiString owner = teamDict.getAsciiString(TheKey_teamOwner); + if (findSide(*state->sides, owner) < 0) { + addNote(state, "team '%s': owner '%s' does not exist, given to the neutral player", teamName.str(), owner.str()); + teamDict.setAsciiString(TheKey_teamOwner, AsciiString::TheEmptyString); + } + state->sides->addTeam(&teamDict); + state->teamsAdded++; + } + return true; +} + +/// One unit or waypoint of the file. Like the editor, an object the map already has at the same +/// spot is skipped; unlike it, a waypoint whose name the map already uses is skipped too and +/// the file's links to it go to the map's waypoint. +Bool parseImportObject(DataChunkInput &file, DataChunkInfo *info, void *userData) +{ + ImportState *state = (ImportState *)userData; + Coord3D loc; + loc.x = file.readReal(); + loc.y = file.readReal(); + loc.z = file.readReal(); + const Real angle = file.readReal(); + const Int flags = file.readInt(); + const AsciiString name = file.readAsciiString(); + Dict d = file.readDict(); + + MapObject *obj = newInstance(MapObject)(loc, name, angle, flags, &d, TheThingFactory->findTemplate(name, FALSE)); + Int fileWaypointId = 0; + if (obj->getProperties()->getType(TheKey_waypointID) == Dict::DICT_INT) { + obj->setIsWaypoint(); + fileWaypointId = obj->getWaypointID(); + obj->setWaypointID(fileWaypointId + state->waypointBase); + if (state->maxWaypoint < obj->getWaypointID()) state->maxWaypoint = obj->getWaypointID(); + } + for (MapObject *other = MapObject::getFirstMapObject(); other; other = other->getNext()) { + const Coord3D *otherLoc = other->getLocation(); + const Bool sameSpot = loc.x == otherLoc->x && loc.y == otherLoc->y && other->isWaypoint() == obj->isWaypoint() + && other->getName() == obj->getName(); + const Bool sameWaypointName = obj->isWaypoint() && other->isWaypoint() && obj->getWaypointName() == other->getWaypointName(); + if (!sameSpot && !sameWaypointName) continue; + if (sameWaypointName) { + state->existingWaypoint[fileWaypointId] = other->getWaypointID(); + if (!sameSpot) { + addNote(state, "waypoint '%s' already exists; the map's one is kept", obj->getWaypointName().str()); + } + } + deleteInstance(obj); + return true; + } + if (obj->isWaypoint()) state->numWaypoints++; else state->numUnits++; + if (state->lastObject) state->lastObject->setNextMap(obj); else state->firstObject = obj; + state->lastObject = obj; + return true; +} + +Bool parseImportObjects(DataChunkInput &file, DataChunkInfo *info, void *userData) +{ + file.registerParser("Object", info->label, parseImportObject); + return file.parse(userData); +} + +Bool parseImportTriggers(DataChunkInput &file, DataChunkInfo *info, void *userData) +{ + ImportState *state = (ImportState *)userData; + Int count = file.readInt(); + while (count-- > 0) { + const AsciiString triggerName = file.readAsciiString(); + file.readInt(); // The id; the map gives the area its own. + Bool isWater = false, isRiver = false; + Int riverStart = 0; + if (info->version >= K_TRIGGERS_VERSION_2) { + isWater = file.readByte(); + } + if (info->version >= K_TRIGGERS_VERSION_3) { + isRiver = file.readByte(); + riverStart = file.readInt(); + } + const Int numPoints = file.readInt(); + PolygonTrigger *trigger = newInstance(PolygonTrigger)(numPoints + 1); + trigger->setTriggerName(triggerName); + trigger->setWaterArea(isWater); + trigger->setRiver(isRiver); + trigger->setRiverStart(riverStart); + for (Int i = 0; i < numPoints; i++) { + ICoord3D loc; + loc.x = file.readInt(); + loc.y = file.readInt(); + loc.z = file.readInt(); + trigger->addPoint(loc); + } + Bool duplicate = false; + for (PolygonTrigger *other = PolygonTrigger::getFirstPolygonTrigger(); other && !duplicate; other = other->getNext()) { + duplicate = triggerName == other->getTriggerName(); + } + for (size_t i = 0; i < state->triggers.size() && !duplicate; i++) { + duplicate = triggerName == state->triggers[i]->getTriggerName(); + } + if (duplicate) { + addNote(state, "area '%s' already exists; the map's one is kept", triggerName.str()); + deleteInstance(trigger); + } else { + state->triggers.push_back(trigger); + } + } + return true; +} + +Bool parseImportLinks(DataChunkInput &file, DataChunkInfo *info, void *userData) +{ + ImportState *state = (ImportState *)userData; + const Int count = file.readInt(); + for (Int i = 0; i < count; i++) { + const Int a = file.readInt(); + const Int b = file.readInt(); + state->links.push_back(std::make_pair(a, b)); + } + return true; +} + +/// Frees what the map data parsers created, when the import does not go through. +void discardImportedMapData(ImportState &state) +{ + deleteInstance(state.firstObject); + state.firstObject = state.lastObject = nullptr; + for (size_t i = 0; i < state.triggers.size(); i++) { + deleteInstance(state.triggers[i]); + } + state.triggers.clear(); +} + +/// Reads a script file like the script editor's Import: scripts go to the player of the same +/// name (a single list goes to args.player), missing players and teams are added. With +/// map_data, the waypoints, trigger areas and units of the file are added too. +McpJson cmdImportScripts(const McpJson &args) +{ + CWorldBuilderDoc *doc = mcpDoc(); + requireScriptEditorClosed(); + const std::string path = mcpArgString(args, "path"); + ScriptList::updateDefaults(); + SidesList sides = *TheSidesList; + const bool hasTarget = args.has("player"); + const AsciiString targetName = hasTarget ? sideName(sides, requireSide(sides, mcpArgString(args, "player"))) : AsciiString::TheEmptyString; + + ImportState state; + state.sides = &sides; + state.numNames = 0; + state.playersAdded = 0; + state.teamsAdded = 0; + state.notes = McpJson::makeArray(); + state.wantMapData = mcpArgBool(args, "map_data", false); + state.firstObject = state.lastObject = nullptr; + state.numWaypoints = state.numUnits = 0; + state.waypointBase = doc->getNextWaypointID(); + state.maxWaypoint = state.waypointBase; + if (state.wantMapData && s_sidesBatch) { + mcpFail("scripts.import with map_data cannot run in a batch (waypoints, areas and units are not part of the sides list)"); + } + + CachedFileInputStream stream; + if (!stream.open(AsciiString(path.c_str()))) { + mcpFail("cannot open '%s'", path.c_str()); + } + Bool parsed = false; + try { + DataChunkInput file(&stream); + file.registerParser("PlayerScriptsList", AsciiString::TheEmptyString, ScriptList::ParseScriptsDataChunk); + file.registerParser("ScriptsPlayers", AsciiString::TheEmptyString, parseImportPlayers); + file.registerParser("ScriptTeams", AsciiString::TheEmptyString, parseImportTeams); + if (state.wantMapData) { + file.registerParser("ObjectsList", AsciiString::TheEmptyString, parseImportObjects); + file.registerParser("PolygonTriggers", AsciiString::TheEmptyString, parseImportTriggers); + file.registerParser("WaypointsList", AsciiString::TheEmptyString, parseImportLinks); + } + parsed = file.parse(&state); + } catch (...) { + parsed = false; + } + + // The read lists are ours now, whatever happened. + ScriptList *read[MAX_PLAYER_COUNT]; + const Int count = ScriptList::getReadScripts(read); + + // Decide where every list goes before moving anything. + Int dest[MAX_PLAYER_COUNT]; + std::string failure; + if (!parsed) { + failure = "'" + path + "' is not a readable script file (.scb)"; + } + for (Int i = 0; i < count && failure.empty(); i++) { + dest[i] = -1; + if (read[i]->getScript() == nullptr && read[i]->getScriptGroup() == nullptr) continue; + if (count == 1 && hasTarget) { + dest[i] = findSide(sides, targetName); + } else if (i < state.numNames) { + dest[i] = findSide(sides, state.playerNames[i]); + } + if (dest[i] >= 0 && sides.getSideInfo(dest[i])->getScriptList() == nullptr) { + sides.getSideInfo(dest[i])->setScriptList(newInstance(ScriptList)); + } + if (dest[i] < 0) { + if (count == 1) { + failure = "the file holds a single script list without a player of this map; pass player to say where it goes"; + } else { + addNote(&state, "scripts of player '%s' skipped: the map has no such player", + i < state.numNames ? state.playerNames[i].str() : "?"); + } + } + } + if (!failure.empty()) { + for (Int i = 0; i < count; i++) { + deleteInstance(read[i]); + } + discardImportedMapData(state); + mcpFail("%s", failure.c_str()); + } + + std::set > existingNames; + { + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + existingNames.insert(std::make_pair(all[i].side, std::string(all[i].script->getName().str()))); + } + } + McpJson duplicates = McpJson::makeArray(); + Int numScripts = 0, numGroups = 0; + for (Int i = 0; i < count; i++) { + if (dest[i] < 0) { + deleteInstance(read[i]); + continue; + } + ScriptList *list = sides.getSideInfo(dest[i])->getScriptList(); + // Unlink each script and group and put them in front of the existing ones, in file order. + Int position = 0; + Script *nextScript; + for (Script *script = read[i]->getScript(); script; script = nextScript) { + nextScript = script->getNext(); + script->setNextScript(nullptr); + if (existingNames.count(std::make_pair(dest[i], std::string(script->getName().str())))) duplicates.push(McpJson(script->getName().str())); + list->addScript(script, position++); + numScripts++; + } + position = 0; + ScriptGroup *nextGroup; + for (ScriptGroup *group = read[i]->getScriptGroup(); group; group = nextGroup) { + nextGroup = group->getNext(); + group->setNextGroup(nullptr); + for (Script *script = group->getScript(); script; script = script->getNext()) { + if (existingNames.count(std::make_pair(dest[i], std::string(script->getName().str())))) duplicates.push(McpJson(script->getName().str())); + numScripts++; + } + list->addGroup(group, position++); + numGroups++; + } + read[i]->discard(); // Frees the list but not the scripts and groups that moved. + } + + McpJson j = McpJson::makeObject(); + j.set("scripts_imported", numScripts).set("groups_imported", numGroups); + j.set("players_added", state.playersAdded).set("teams_added", state.teamsAdded); + + // The file's links in map waypoint ids, without those the map already has. + std::set > mapLinks; + for (Int i = 0; i < doc->getNumWaypointLinks(); i++) { + Int from, to; + doc->getWaypointLink(i, &from, &to); + mapLinks.insert(std::make_pair(from, to)); + } + std::vector > newLinks; + for (size_t i = 0; i < state.links.size(); i++) { + Int ends[2] = { state.links[i].first, state.links[i].second }; + for (Int e = 0; e < 2; e++) { + std::map::const_iterator existing = state.existingWaypoint.find(ends[e]); + ends[e] = existing != state.existingWaypoint.end() ? existing->second : ends[e] + state.waypointBase; + } + if (mapLinks.insert(std::make_pair(ends[0], ends[1])).second) { + newLinks.push_back(std::make_pair(ends[0], ends[1])); + } + } + + const bool hasMapData = state.firstObject != nullptr || !state.triggers.empty() || !newLinks.empty(); + if (!hasMapData) { + commitSides(sides); + if (state.wantMapData) { + j.set("waypoints_added", 0).set("units_added", 0).set("areas_added", 0).set("waypoint_links_added", 0); + } + } else { + // Scripts and map data become one undo step. + McpWaypointLinksUndoable *links = new McpWaypointLinksUndoable(doc); + links->added = newLinks; + MultipleUndoable *undo = new MultipleUndoable; + undo->addUndoable(links); + for (size_t i = 0; i < state.triggers.size(); i++) { + undo->addUndoable(new AddPolygonUndoable(state.triggers[i])); + } + if (state.firstObject) { + undo->addUndoable(new AddObjectUndoable(doc, state.firstObject)); + } + undo->addUndoable(new SidesListUndoable(sides, doc)); + doc->setNextWaypointID(state.maxWaypoint + 1); + mcpCommit(undo); + mcpResetObjectHandles(); + j.set("waypoints_added", state.numWaypoints).set("units_added", state.numUnits); + j.set("areas_added", (int)state.triggers.size()).set("waypoint_links_added", (int)newLinks.size()); + } + j.set("duplicate_names", duplicates).set("notes", state.notes); + return j; +} + +} // namespace McpScripts + +using namespace McpScripts; + +void mcpRegisterScriptCommands() +{ + mcpRegisterCommand("scripts.list", cmdListScripts, "{player?,group?,filter?,detail?=auto|groups|names|full} Scripts and script groups per player; auto reduces the detail on large sets."); + mcpRegisterCommand("scripts.get", cmdGetScript, "{name,player?,group?,script_index?} One script: flags, conditions (OR list of AND lists), actions, false_actions, and the editor's wording."); + mcpRegisterCommand("scripts.find", cmdFindScripts, "{text?,value?,type?,param_type?,player?,limit?=50,offset?,max_hits_per_script?=10} Finds conditions and actions by wording, exact parameter value, type or parameter type; also script names, comments and team properties."); + mcpRegisterCommand("scripts.add", cmdAddScript, "{script:{name,...},player?|group?,index?,allow_duplicate_name?} Adds a script to a player's top level or to a group."); + mcpRegisterCommand("scripts.update", cmdUpdateScript, "{name,player?,group?,script_index?,script:{...}} Changes the given keys of a script; conditions/actions lists are replaced whole."); + mcpRegisterCommand("scripts.delete", cmdDeleteScript, "{name,player?,group?,script_index?} Deletes a script."); + mcpRegisterCommand("scripts.move", cmdMoveScript, "{name,player?,group?,script_index?,to_player?,to_group?,index?} Moves a script to another position, group or player."); + mcpRegisterCommand("scripts.add_group", cmdAddGroup, "{player,name,active?=true,subroutine?=false,index?} Adds a script group (folder)."); + mcpRegisterCommand("scripts.update_group", cmdUpdateGroup, "{name,player?,new_name?,active?,subroutine?,index?,to_player?} Renames, flags or moves a script group."); + mcpRegisterCommand("scripts.delete_group", cmdDeleteGroup, "{name,player?,delete_scripts?=false} Deletes a script group."); + mcpRegisterCommand("scripts.list_actions", cmdListActions, "{filter?,limit?=50,offset?} Script action types with their parameter types."); + mcpRegisterCommand("scripts.list_conditions", cmdListConditions, "{filter?,limit?=50,offset?} Script condition types with their parameter types."); + mcpRegisterCommand("scripts.verify", cmdVerifyScripts, "{player?,name?,summary?=false} Runs the script editor's checks: references to missing scripts, teams, waypoints, objects etc."); + mcpRegisterCommand("scripts.export", cmdExportScripts, "{path,player?,group?,name?,include_players?,include_waypoints?,include_areas?,include_units?,overwrite?=false,backup?=true} Writes a script file (.scb) with all scripts or a selection."); + mcpRegisterCommand("scripts.import", cmdImportScripts, "{path,player?,map_data?=false} Reads a script file (.scb): scripts, plus players and teams the map lacks; with map_data also its waypoints, areas and units."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTeams.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTeams.cpp new file mode 100644 index 00000000000..b47a2a6218c --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTeams.cpp @@ -0,0 +1,946 @@ +/* +** 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 . +*/ + +// McpCmdTeams.cpp +// MCP bridge commands for team templates (the Teams dialog). A team is a property dict; the +// table below lists the properties TeamPrototype reads, with the type it reads them as. A +// property stored with another type is silently ignored by the game, so writes are checked +// against the table. + +#include "StdAfx.h" +#include "mcp/McpCommands.h" +#include "mcp/McpScriptsShared.h" + +#include "WorldBuilderDoc.h" +#include "Common/NameKeyGenerator.h" +#include "Common/Team.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/SidesList.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace McpScripts; + +namespace +{ + +enum { NUM_UNIT_SLOTS = 7 }; + +/// What a string property refers to. +enum TeamRef +{ + REF_NONE, + REF_SCRIPT, + REF_OBJECT_TYPE, + REF_WAYPOINT, + REF_PLAYER +}; + +struct TeamKeyInfo +{ + const char *name; ///< Key, or its prefix when it is numbered. + Dict::DataType type; + TeamRef ref; + Int firstNumber, lastNumber; ///< Number range appended to the name; -1 when it has none. + const char *help; +}; + +const TeamKeyInfo TEAM_KEYS[] = +{ + { "teamName", Dict::DICT_ASCIISTRING, REF_NONE, -1, -1, "Team name; objects, scripts and other teams refer to it." }, + { "teamOwner", Dict::DICT_ASCIISTRING, REF_PLAYER, -1, -1, "Owning player (\"\" = neutral)." }, + { "teamIsSingleton", Dict::DICT_BOOL, REF_NONE, -1, -1, "Only one instance ever exists; true for the players' default teams." }, + { "teamDescription", Dict::DICT_ASCIISTRING, REF_NONE, -1, -1, "Free text for the editor." }, + { "teamHome", Dict::DICT_ASCIISTRING, REF_WAYPOINT, -1, -1, "Waypoint the team is built at / returns to." }, + { "teamUnitType", Dict::DICT_ASCIISTRING, REF_OBJECT_TYPE, 1, NUM_UNIT_SLOTS, "Unit type of slot N." }, + { "teamUnitMinCount", Dict::DICT_INT, REF_NONE, 1, NUM_UNIT_SLOTS, "Units of slot N the team needs before it is complete." }, + { "teamUnitMaxCount", Dict::DICT_INT, REF_NONE, 1, NUM_UNIT_SLOTS, "Units of slot N the AI builds at most; 0 disables the slot." }, + { "teamMaxInstances", Dict::DICT_INT, REF_NONE, -1, -1, "How many copies of the team may exist at once." }, + { "teamProductionCondition", Dict::DICT_ASCIISTRING, REF_SCRIPT, -1, -1, "Script whose conditions must be true for the AI to build the team." }, + { "teamProductionPriority", Dict::DICT_INT, REF_NONE, -1, -1, "The AI builds the buildable team with the highest priority first." }, + { "teamProductionPrioritySuccessIncrease", Dict::DICT_INT, REF_NONE, -1, -1, "Added to the priority when the team succeeds." }, + { "teamProductionPriorityFailureDecrease", Dict::DICT_INT, REF_NONE, -1, -1, "Subtracted from the priority when the team is destroyed." }, + { "teamExecutesActionsOnCreate", Dict::DICT_BOOL, REF_NONE, -1, -1, "Run the actions of the production condition script when the team is created." }, + { "teamOnCreateScript", Dict::DICT_ASCIISTRING, REF_SCRIPT, -1, -1, "Script run when the team is created." }, + { "teamOnIdleScript", Dict::DICT_ASCIISTRING, REF_SCRIPT, -1, -1, "Script run when the team is idle." }, + { "teamInitialIdleFrames", Dict::DICT_INT, REF_NONE, -1, -1, "Frames to wait before the team counts as idle." }, + { "teamEnemySightedScript", Dict::DICT_ASCIISTRING, REF_SCRIPT, -1, -1, "Script run when the team sees an enemy." }, + { "teamAllClearScript", Dict::DICT_ASCIISTRING, REF_SCRIPT, -1, -1, "Script run when no enemy is in sight any more." }, + { "teamOnUnitDestroyedScript", Dict::DICT_ASCIISTRING, REF_SCRIPT, -1, -1, "Script run each time a unit of the team is destroyed." }, + { "teamOnDestroyedScript", Dict::DICT_ASCIISTRING, REF_SCRIPT, -1, -1, "Script run when the team counts as destroyed." }, + { "teamDestroyedThreshold", Dict::DICT_REAL, REF_NONE, -1, -1, "Fraction of units lost (0..1) at which the team counts as destroyed." }, + { "teamGenericScriptHook", Dict::DICT_ASCIISTRING, REF_SCRIPT, 0, MAX_GENERIC_SCRIPTS - 1, "Script N the team keeps trying to run." }, + { "teamAggressiveness", Dict::DICT_INT, REF_NONE, -1, -1, "Initial attitude: -2 sleep, -1 passive, 0 normal, 1 alert, 2 aggressive." }, + { "teamIsAIRecruitable", Dict::DICT_BOOL, REF_NONE, -1, -1, "The AI may take units of this team for other teams." }, + { "teamIsBaseDefense", Dict::DICT_BOOL, REF_NONE, -1, -1, "Team defends the base." }, + { "teamIsPerimeterDefense", Dict::DICT_BOOL, REF_NONE, -1, -1, "Team defends the base perimeter." }, + { "teamAutoReinforce", Dict::DICT_BOOL, REF_NONE, -1, -1, "Replace lost units automatically." }, + { "teamAttackCommonTarget", Dict::DICT_BOOL, REF_NONE, -1, -1, "All units attack the same target." }, + { "teamAvoidThreats", Dict::DICT_BOOL, REF_NONE, -1, -1, "Path around enemy defenses." }, + { "teamTransport", Dict::DICT_ASCIISTRING, REF_OBJECT_TYPE, -1, -1, "Reinforcement: transport unit type." }, + { "teamReinforcementOrigin", Dict::DICT_ASCIISTRING, REF_WAYPOINT, -1, -1, "Reinforcement: waypoint the team arrives at." }, + { "teamStartsFull", Dict::DICT_BOOL, REF_NONE, -1, -1, "Reinforcement: the transports start loaded." }, + { "teamTransportsExit", Dict::DICT_BOOL, REF_NONE, -1, -1, "Reinforcement: transports leave the map after unloading." }, + { "teamTransportsReturn", Dict::DICT_BOOL, REF_NONE, -1, -1, "Reinforcement: transports return to their origin." }, + { "teamVeterancy", Dict::DICT_INT, REF_NONE, -1, -1, "Starting veterancy: 0 regular, 1 veteran, 2 elite, 3 heroic." }, + // Written by the script export of the editor; the game does not read it. + { "exportWithScript", Dict::DICT_BOOL, REF_NONE, -1, -1, nullptr }, +}; + +/// Looks up a team property, with or without its number. +const TeamKeyInfo *findTeamKey(const char *name) +{ + for (size_t i = 0; i < ARRAY_SIZE(TEAM_KEYS); i++) { + const TeamKeyInfo &info = TEAM_KEYS[i]; + const size_t len = strlen(info.name); + if (info.firstNumber < 0) { + if (strcmp(name, info.name) == 0) return &info; + continue; + } + if (strncmp(name, info.name, len) != 0 || name[len] == 0) continue; + const char *digits = name + len; + Bool allDigits = true; + for (const char *p = digits; *p; p++) { + if (*p < '0' || *p > '9') allDigits = false; + } + // "teamUnitType01" would be another key than the one the game reads. + if (!allDigits || (digits[0] == '0' && digits[1] != 0)) continue; + const Int number = atoi(digits); + if (number >= info.firstNumber && number <= info.lastNumber) return &info; + } + return nullptr; +} + +const char *dictTypeName(Dict::DataType type) +{ + switch (type) { + case Dict::DICT_BOOL: return "bool"; + case Dict::DICT_INT: return "int"; + case Dict::DICT_REAL: return "real"; + case Dict::DICT_ASCIISTRING: return "string"; + case Dict::DICT_UNICODESTRING: return "unicode string"; + default: return "none"; + } +} + +const char *teamRefName(TeamRef ref) +{ + switch (ref) { + case REF_SCRIPT: return "script"; + case REF_OBJECT_TYPE: return "object_type"; + case REF_WAYPOINT: return "waypoint"; + case REF_PLAYER: return "player"; + default: return nullptr; + } +} + +/// The Teams dialog stores "" for an unused unit slot. +bool isNoUnit(const AsciiString &type) +{ + return type.isEmpty() || type == ""; +} + +NameKeyType numberedKey(const char *prefix, Int number) +{ + AsciiString name; + name.format("%s%d", prefix, number); + return TheNameKeyGenerator->nameToKey(name); +} + +/// Stores one team property with the type the game reads it as. +void setTeamProperty(Dict &dict, const std::string &keyName, const McpJson &value, bool allowUnknown) +{ + const NameKeyType key = TheNameKeyGenerator->nameToKey(keyName.c_str()); + if (value.isNull()) { + dict.remove(key); + return; + } + const TeamKeyInfo *info = findTeamKey(keyName.c_str()); + if (info == nullptr) { + if (!allowUnknown) { + mcpFail("unknown team property '%s' (see teams.list_properties; pass allow_unknown_properties=true to store it anyway)", + keyName.c_str()); + } + McpJson single = McpJson::makeObject(); + single.set(keyName.c_str(), value); + mcpJsonToDict(single, dict); + return; + } + switch (info->type) { + case Dict::DICT_BOOL: + if (!value.isBool()) mcpFail("team property '%s' must be true or false", keyName.c_str()); + dict.setBool(key, value.asBool()); + break; + case Dict::DICT_INT: + if (!value.isNumber() || value.asNumber() != floor(value.asNumber())) { + mcpFail("team property '%s' must be a whole number", keyName.c_str()); + } + dict.setInt(key, (Int)value.asNumber()); + break; + case Dict::DICT_REAL: + if (!value.isNumber()) mcpFail("team property '%s' must be a number", keyName.c_str()); + dict.setReal(key, (Real)value.asNumber()); + break; + default: + if (!value.isString()) mcpFail("team property '%s' must be a string", keyName.c_str()); + dict.setAsciiString(key, AsciiString(value.asString().c_str())); + break; + } +} + +/// The unit slots as "units": [{"type", "min", "max"}], which replaces all seven slots. +void setTeamUnits(Dict &dict, const McpJson &units) +{ + if (!units.isArray() || units.size() > NUM_UNIT_SLOTS) { + mcpFail("'units' must be an array of at most %d {type, min, max} entries", NUM_UNIT_SLOTS); + } + for (Int slot = 1; slot <= NUM_UNIT_SLOTS; slot++) { + AsciiString type; + Int minCount = 0, maxCount = 0; + if ((size_t)(slot - 1) < units.size()) { + const McpJson &unit = units.at(slot - 1); + if (!unit.isObject() || !unit.get("type").isString() || unit.get("type").asString().empty()) { + mcpFail("units[%d] must be an object with a 'type' (unit type name), and 'min' / 'max' counts", slot - 1); + } + type = unit.get("type").asString().c_str(); + maxCount = mcpArgInt(unit, "max", 1); + minCount = mcpArgInt(unit, "min", maxCount); + if (minCount < 0 || maxCount < minCount) { + mcpFail("units[%d]: need 0 <= min <= max", slot - 1); + } + } + dict.setAsciiString(numberedKey("teamUnitType", slot), type); + dict.setInt(numberedKey("teamUnitMinCount", slot), minCount); + dict.setInt(numberedKey("teamUnitMaxCount", slot), maxCount); + } +} + +McpJson describeUnits(Dict *d, bool asText, const std::string &unitFilter, bool *unitMatch) +{ + McpJson units = McpJson::makeArray(); + for (Int slot = 1; slot <= NUM_UNIT_SLOTS; slot++) { + Bool exists = false; + const AsciiString type = d->getAsciiString(numberedKey("teamUnitType", slot), &exists); + if (!exists || isNoUnit(type)) continue; + const Int minCount = d->getInt(numberedKey("teamUnitMinCount", slot), &exists); + const Int maxCount = d->getInt(numberedKey("teamUnitMaxCount", slot), &exists); + if (maxCount <= 0) continue; + if (unitMatch && mcpContainsNoCase(type.str(), unitFilter.c_str())) *unitMatch = true; + if (asText) { + // "3-6 AmericaTankCrusader" + AsciiString text; + if (minCount == maxCount) { + text.format("%d %s", maxCount, type.str()); + } else { + text.format("%d-%d %s", minCount, maxCount, type.str()); + } + units.push(McpJson(text.str())); + } else { + units.push(McpJson::makeObject().set("type", type.str()).set("min", minCount).set("max", maxCount)); + } + } + return units; +} + +//------------------------------------------------------------------------------------------------- +// Verification +//------------------------------------------------------------------------------------------------- + +/// missingKind (a parameter type name) and missing say what does not exist, for scripts.missing. +void addTeamWarning(McpJson &out, const AsciiString &team, const AsciiString &owner, const char *property, const AsciiString &warning, + const char *missingKind = nullptr, const AsciiString &missing = AsciiString::TheEmptyString) +{ + McpJson entry = McpJson::makeObject(); + entry.set("team", team.str()).set("owner", owner.str()).set("property", property).set("warning", warning.str()); + if (missingKind) { + entry.set("missing_kind", missingKind).set("missing", missing.str()); + } + out.push(entry); +} + +bool waypointExists(const AsciiString &name) +{ + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (obj->isWaypoint() && obj->getWaypointName() == name) return true; + } + return false; +} + +/// Script and group names per player; teams name scripts of their own player. +std::vector > scriptNamesPerSide(SidesList &sides) +{ + std::vector > names(sides.getNumSides()); + const std::vector all = allScripts(sides); + for (size_t i = 0; i < all.size(); i++) { + names[all[i].side].insert(all[i].script->getName().str()); + } + for (Int side = 0; side < sides.getNumSides(); side++) { + ScriptList *list = sides.getSideInfo(side)->getScriptList(); + if (list == nullptr) continue; + for (ScriptGroup *group = list->getScriptGroup(); group; group = group->getNext()) { + names[side].insert(group->getName().str()); + } + } + return names; +} + +void collectTeamWarnings(SidesList &sides, TeamsInfo *team, const std::vector > &scriptNames, McpJson &out) +{ + Dict *d = team->getDict(); + const AsciiString name = d->getAsciiString(TheKey_teamName); + const AsciiString owner = d->getAsciiString(TheKey_teamOwner); + const Int side = findSide(sides, owner); + AsciiString warning; + if (side < 0) { + warning.format("Player '%s' does not exist.", owner.str()); + addTeamWarning(out, name, owner, "teamOwner", warning); + } + + for (Int i = 0; i < d->getPairCount(); i++) { + const AsciiString key = TheNameKeyGenerator->keyToName(d->getNthKey(i)); + const TeamKeyInfo *info = findTeamKey(key.str()); + if (info == nullptr) { + addTeamWarning(out, name, owner, key.str(), "Unknown team property; the game does not read it."); + continue; + } + if (d->getNthType(i) != info->type) { + warning.format("Stored as %s, but the game reads it as %s and ignores it.", dictTypeName(d->getNthType(i)), dictTypeName(info->type)); + addTeamWarning(out, name, owner, key.str(), warning); + continue; + } + if (info->type != Dict::DICT_ASCIISTRING) continue; + const AsciiString value = d->getNthAsciiString(i); + if (value.isEmpty()) continue; + switch (info->ref) { + case REF_SCRIPT: { + if (side >= 0 && scriptNames[side].count(value.str())) break; + Int elsewhere = -1; + for (Int other = 0; other < sides.getNumSides() && elsewhere < 0; other++) { + if (other != side && scriptNames[other].count(value.str())) elsewhere = other; + } + if (elsewhere < 0) { + warning.format("Script '%s' does not exist.", value.str()); + } else { + warning.format("Script '%s' does not exist for player '%s' (player '%s' has one).", value.str(), owner.str(), + sideName(sides, elsewhere).str()); + } + addTeamWarning(out, name, owner, key.str(), warning, "SCRIPT", value); + break; + } + case REF_OBJECT_TYPE: + if (!isNoUnit(value) && TheThingFactory->findTemplate(value, FALSE) == nullptr) { + warning.format("Object type '%s' does not exist.", value.str()); + addTeamWarning(out, name, owner, key.str(), warning, "OBJECT_TYPE", value); + } + break; + case REF_WAYPOINT: + if (!waypointExists(value)) { + warning.format("Waypoint '%s' does not exist.", value.str()); + addTeamWarning(out, name, owner, key.str(), warning, "WAYPOINT", value); + } + break; + default: + break; + } + } + + for (Int slot = 1; slot <= NUM_UNIT_SLOTS; slot++) { + Bool exists = false; + const AsciiString type = d->getAsciiString(numberedKey("teamUnitType", slot), &exists); + const Int minCount = d->getInt(numberedKey("teamUnitMinCount", slot), &exists); + const Int maxCount = d->getInt(numberedKey("teamUnitMaxCount", slot), &exists); + AsciiString key; + key.format("teamUnitMinCount%d", slot); + if (minCount > maxCount) { + warning.format("Minimum %d is above the maximum %d.", minCount, maxCount); + addTeamWarning(out, name, owner, key.str(), warning); + } + if (maxCount > 0 && isNoUnit(type)) { + key.format("teamUnitType%d", slot); + addTeamWarning(out, name, owner, key.str(), "The slot has a count but no unit type."); + } + } +} + +//------------------------------------------------------------------------------------------------- +// Commands +//------------------------------------------------------------------------------------------------- + +McpJson cmdListTeamProperties(const McpJson &) +{ + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < ARRAY_SIZE(TEAM_KEYS); i++) { + const TeamKeyInfo &info = TEAM_KEYS[i]; + if (info.help == nullptr) continue; + McpJson entry = McpJson::makeObject(); + if (info.firstNumber >= 0) { + AsciiString name; + name.format("%s", info.name); + entry.set("key", name.str()).set("first", info.firstNumber).set("last", info.lastNumber); + } else { + entry.set("key", info.name); + } + entry.set("type", dictTypeName(info.type)); + if (teamRefName(info.ref)) entry.set("names", teamRefName(info.ref)); + entry.set("help", info.help); + list.push(entry); + } + return McpJson::makeObject().set("properties", list); +} + +/// The team filters shared by the commands that work on many teams. +struct TeamFilter +{ + bool hasOwner, includeDefault; + AsciiString owner; + std::string name, unit, script; + + void read(SidesList &sides, const McpJson &args) + { + hasOwner = args.has("owner"); + owner = hasOwner ? sideName(sides, requireSide(sides, mcpArgString(args, "owner"))) : AsciiString::TheEmptyString; + name = mcpArgString(args, "filter", ""); + unit = mcpArgString(args, "unit", ""); + script = mcpArgString(args, "script", ""); + includeDefault = mcpArgBool(args, "include_default_teams", false); + } + + bool hasAny() const + { + return hasOwner || !name.empty() || !unit.empty() || !script.empty(); + } + + bool matches(SidesList &sides, TeamsInfo *team) const + { + Dict *d = team->getDict(); + if (hasOwner && d->getAsciiString(TheKey_teamOwner) != owner) return false; + if (!includeDefault && sides.isPlayerDefaultTeam(team)) return false; + if (!mcpContainsNoCase(d->getAsciiString(TheKey_teamName).str(), name.c_str())) return false; + if (!unit.empty()) { + bool unitMatch = false; + describeUnits(d, true, unit, &unitMatch); + if (!unitMatch) return false; + } + if (!script.empty()) { + bool scriptMatch = false; + for (Int k = 0; k < d->getPairCount() && !scriptMatch; k++) { + if (d->getNthType(k) != Dict::DICT_ASCIISTRING) continue; + const char *ref = McpScripts::teamPropertyRef(TheNameKeyGenerator->keyToName(d->getNthKey(k)).str()); + scriptMatch = ref && strcmp(ref, "script") == 0 && _stricmp(d->getNthAsciiString(k).str(), script.c_str()) == 0; + } + if (!scriptMatch) return false; + } + return true; + } +}; + +/// Teams in short form for reviewing many at once: unit composition and the scripts they name. +McpJson cmdListTeams(const McpJson &args) +{ + mcpDoc(); + SidesList &sides = *TheSidesList; + const bool hasOwner = args.has("owner"); + const AsciiString owner = hasOwner ? sideName(sides, requireSide(sides, mcpArgString(args, "owner"))) : AsciiString::TheEmptyString; + const std::string filter = mcpArgString(args, "filter", ""); + const std::string unit = mcpArgString(args, "unit", ""); + const std::string script = mcpArgString(args, "script", ""); + const bool includeDefault = mcpArgBool(args, "include_default_teams", false); + const Int limit = mcpArgInt(args, "limit", 100); + const Int offset = mcpArgInt(args, "offset", 0); + + McpJson list = McpJson::makeArray(); + Int matched = 0; + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + Dict *d = team->getDict(); + const AsciiString name = d->getAsciiString(TheKey_teamName); + const AsciiString teamOwner = d->getAsciiString(TheKey_teamOwner); + if (hasOwner && teamOwner != owner) continue; + if (!includeDefault && sides.isPlayerDefaultTeam(team)) continue; + if (!mcpContainsNoCase(name.str(), filter.c_str())) continue; + + bool unitMatch = unit.empty(); + const McpJson units = describeUnits(d, true, unit, &unitMatch); + if (!unitMatch) continue; + + // Every property that names a script, without the "team" prefix of the key. + McpJson scripts = McpJson::makeObject(); + bool scriptMatch = script.empty(); + for (Int k = 0; k < d->getPairCount(); k++) { + if (d->getNthType(k) != Dict::DICT_ASCIISTRING) continue; + const AsciiString key = TheNameKeyGenerator->keyToName(d->getNthKey(k)); + const TeamKeyInfo *info = findTeamKey(key.str()); + if (info == nullptr || info->ref != REF_SCRIPT) continue; + const AsciiString value = d->getNthAsciiString(k); + if (value.isEmpty()) continue; + scripts.set(key.str() + 4, value.str()); + if (_stricmp(value.str(), script.c_str()) == 0) scriptMatch = true; + } + if (!scriptMatch) continue; + + matched++; + if (matched <= offset || (Int)list.size() >= limit) continue; + McpJson entry = McpJson::makeObject(); + entry.set("name", name.str()).set("owner", teamOwner.str()); + if (sides.isPlayerDefaultTeam(team)) entry.set("is_default_team", true); + entry.set("units", units); + Bool exists = false; + const Int priority = d->getInt(TheKey_teamProductionPriority, &exists); + if (exists) entry.set("priority", priority); + const Int maxInstances = d->getInt(TheKey_teamMaxInstances, &exists); + if (exists) entry.set("max_instances", maxInstances); + entry.set("scripts", scripts); + list.push(entry); + } + return McpJson::makeObject().set("total_matches", matched).set("teams", list); +} + +McpJson cmdGetTeam(const McpJson &args) +{ + mcpDoc(); + const std::string name = mcpArgString(args, "name"); + TeamsInfo *team = TheSidesList->findTeamInfo(AsciiString(name.c_str())); + if (team == nullptr) { + mcpFail("no team named '%s' (see teams.list)", name.c_str()); + } + McpJson j = McpJson::makeObject(); + j.set("name", name).set("is_default_team", (bool)(TheSidesList->isPlayerDefaultTeam(team) != 0)); + j.set("units", describeUnits(team->getDict(), false, "", nullptr)); + j.set("properties", mcpDictToJson(*team->getDict())); + return j; +} + +/// Creates a team or changes the properties of an existing one (the team template dialog). +McpJson cmdSetTeam(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const std::string name = mcpArgString(args, "name"); + if (name.empty()) { + mcpFail("the team name must not be empty"); + } + const AsciiString teamName(name.c_str()); + const bool allowUnknown = mcpArgBool(args, "allow_unknown_properties", false); + SidesList sides = *TheSidesList; + TeamsInfo *team = sides.findTeamInfo(teamName); + const bool created = team == nullptr; + if (created) { + if (!args.has("owner")) { + mcpFail("no team named '%s'; pass owner (a player name) to create it", name.c_str()); + } + const Int side = requireSide(sides, mcpArgString(args, "owner")); + // The Teams dialog starts a new team with these three. + Dict d; + d.setAsciiString(TheKey_teamName, teamName); + d.setAsciiString(TheKey_teamOwner, sideName(sides, side)); + d.setBool(TheKey_teamIsSingleton, false); + sides.addTeam(&d); + team = sides.findTeamInfo(teamName); + if (team == nullptr) { + mcpFail("could not add team '%s'", name.c_str()); + } + } else if (args.has("owner")) { + const Int side = requireSide(sides, mcpArgString(args, "owner")); + if (sides.isPlayerDefaultTeam(team) && team->getDict()->getAsciiString(TheKey_teamOwner) != sideName(sides, side)) { + mcpFail("'%s' is the default team of its player and cannot change owner", name.c_str()); + } + team->getDict()->setAsciiString(TheKey_teamOwner, sideName(sides, side)); + } + + Dict *dict = team->getDict(); + if (args.has("units")) { + setTeamUnits(*dict, args.get("units")); + } + if (args.has("properties")) { + const McpJson &props = args.get("properties"); + if (!props.isObject()) { + mcpFail("'properties' must be an object"); + } + for (size_t i = 0; i < props.numKeys(); i++) { + setTeamProperty(*dict, props.keyAt(i), props.valueAt(i), allowUnknown); + } + // Objects and scripts refer to the team by name and the owner must be a player. + if (dict->getAsciiString(TheKey_teamName) != teamName) { + mcpFail("properties must not change teamName (use scripts.rename)"); + } + if (findSide(sides, dict->getAsciiString(TheKey_teamOwner)) < 0) { + mcpFail("teamOwner '%s' is not a player", dict->getAsciiString(TheKey_teamOwner).str()); + } + } + + McpJson warnings = McpJson::makeArray(); + collectTeamWarnings(sides, team, scriptNamesPerSide(sides), warnings); + McpJson j = McpJson::makeObject(); + j.set("name", name).set("created", created); + j.set("units", describeUnits(dict, false, "", nullptr)); + j.set("properties", mcpDictToJson(*dict)).set("warnings", warnings); + commitSides(sides); + return j; +} + +McpJson cmdDeleteTeam(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + const std::string name = mcpArgString(args, "name"); + SidesList sides = *TheSidesList; + Int index = -1; + TeamsInfo *team = sides.findTeamInfo(AsciiString(name.c_str()), &index); + if (team == nullptr) { + mcpFail("no team named '%s' (see teams.list)", name.c_str()); + } + if (sides.isPlayerDefaultTeam(team)) { + mcpFail("'%s' is the default team of its player and cannot be deleted", name.c_str()); + } + const Int owned = MapObject::countMapObjectsWithOwner(AsciiString(name.c_str())); + if (owned > 0 && !mcpArgBool(args, "force", false)) { + mcpFail("%d map object(s) belong to team '%s'; give them another owner first or pass force=true", owned, name.c_str()); + } + sides.removeTeam(index); + commitSides(sides); + return McpJson::makeObject().set("deleted", name).set("objects_owned", owned); +} + +//------------------------------------------------------------------------------------------------- +// teams.set_many +//------------------------------------------------------------------------------------------------- + +/// The keys of a team a bulk change names: one key, or with a trailing '*' every numbered key +/// of that prefix the team has ("teamUnitMaxCount*"). +std::vector matchTeamKeys(Dict *d, const std::string &pattern) +{ + std::vector keys; + if (pattern.empty() || pattern[pattern.size() - 1] != '*') { + const NameKeyType key = TheNameKeyGenerator->nameToKey(pattern.c_str()); + if (d->getType(key) != Dict::DICT_NONE) keys.push_back(key); + return keys; + } + const std::string prefix = pattern.substr(0, pattern.size() - 1); + for (Int k = 0; k < d->getPairCount(); k++) { + const AsciiString name = TheNameKeyGenerator->keyToName(d->getNthKey(k)); + if (strncmp(name.str(), prefix.c_str(), prefix.size()) == 0 && findTeamKey(name.str())) keys.push_back(d->getNthKey(k)); + } + return keys; +} + +/// Adds to or scales the numeric properties named in `ops` ({"key or prefix*": number}). +void applyTeamNumbers(Dict *d, const McpJson &ops, bool multiply, const char *argName) +{ + if (ops.isNull()) return; + if (!ops.isObject()) { + mcpFail("'%s' must be an object of {\"property\": number}", argName); + } + for (size_t i = 0; i < ops.numKeys(); i++) { + const std::string &pattern = ops.keyAt(i); + if (!ops.valueAt(i).isNumber()) { + mcpFail("%s.%s must be a number", argName, pattern.c_str()); + } + const double amount = ops.valueAt(i).asNumber(); + std::string probe = pattern; + if (!probe.empty() && probe[probe.size() - 1] == '*') probe = probe.substr(0, probe.size() - 1) + "1"; + const TeamKeyInfo *info = findTeamKey(probe.c_str()); + if (info == nullptr || (info->type != Dict::DICT_INT && info->type != Dict::DICT_REAL)) { + mcpFail("%s: '%s' is not a numeric team property (see teams.list_properties)", argName, pattern.c_str()); + } + const std::vector keys = matchTeamKeys(d, pattern); + for (size_t k = 0; k < keys.size(); k++) { + if (d->getType(keys[k]) == Dict::DICT_INT) { + const double value = multiply ? d->getInt(keys[k]) * amount : d->getInt(keys[k]) + amount; + // Counts and priorities are whole numbers; a scaled 1 should not become 0 by truncation. + d->setInt(keys[k], (Int)floor(value + 0.5)); + } else if (d->getType(keys[k]) == Dict::DICT_REAL) { + d->setReal(keys[k], (Real)(multiply ? d->getReal(keys[k]) * amount : d->getReal(keys[k]) + amount)); + } + } + } +} + +/// What differs between two versions of a team dict, as {key: [old, new]}. +McpJson dictChanges(const Dict &before, const Dict &after) +{ + const McpJson a = mcpDictToJson(before); + const McpJson b = mcpDictToJson(after); + McpJson changes = McpJson::makeObject(); + for (size_t i = 0; i < b.numKeys(); i++) { + const McpJson &old = a.get(b.keyAt(i).c_str()); + if (old.dump() != b.valueAt(i).dump()) { + McpJson pair = McpJson::makeArray(); + pair.push(old); + pair.push(b.valueAt(i)); + changes.set(b.keyAt(i).c_str(), pair); + } + } + for (size_t i = 0; i < a.numKeys(); i++) { + if (!b.has(a.keyAt(i).c_str())) { + McpJson pair = McpJson::makeArray(); + pair.push(a.valueAt(i)); + pair.push(McpJson()); + changes.set(a.keyAt(i).c_str(), pair); + } + } + return changes; +} + +/// Changes many teams at once: the same filters as teams.list pick them. +McpJson cmdSetManyTeams(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + SidesList sides = *TheSidesList; + TeamFilter filter; + filter.read(sides, args); + if (!filter.hasAny() && !mcpArgBool(args, "all_teams", false)) { + mcpFail("pass a filter (owner, filter, unit or script), or all_teams=true to change every team of the map"); + } + const bool allowUnknown = mcpArgBool(args, "allow_unknown_properties", false); + const bool dryRun = mcpArgBool(args, "dry_run", false); + const McpJson &props = args.get("properties"); + if (!props.isNull() && !props.isObject()) { + mcpFail("'properties' must be an object"); + } + if (props.has("teamName") || props.has("teamOwner")) { + mcpFail("teamName and teamOwner cannot be set for many teams at once"); + } + if (props.isNull() && args.get("multiply").isNull() && args.get("add").isNull()) { + mcpFail("pass properties, multiply or add"); + } + + McpJson samples = McpJson::makeArray(); + Int matched = 0, changed = 0; + const std::vector > scriptNames = scriptNamesPerSide(sides); + McpJson warnings = McpJson::makeArray(); + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + if (!filter.matches(sides, team)) continue; + matched++; + Dict *d = team->getDict(); + const Dict before = *d; + for (size_t k = 0; k < props.numKeys(); k++) { + setTeamProperty(*d, props.keyAt(k), props.valueAt(k), allowUnknown); + } + applyTeamNumbers(d, args.get("multiply"), true, "multiply"); + applyTeamNumbers(d, args.get("add"), false, "add"); + const McpJson changes = dictChanges(before, *d); + if (changes.numKeys() == 0) continue; + changed++; + if (samples.size() < 15) { + samples.push(McpJson::makeObject().set("team", d->getAsciiString(TheKey_teamName).str()).set("changes", changes)); + } + collectTeamWarnings(sides, team, scriptNames, warnings); + } + + McpJson j = McpJson::makeObject(); + j.set("teams_matched", matched).set("teams_changed", changed).set("samples", samples); + j.set("warnings_on_changed_teams", (int)warnings.size()).set("dry_run", dryRun); + if (!dryRun && changed > 0) { + commitSides(sides); + } + return j; +} + +//------------------------------------------------------------------------------------------------- +// teams.summary +//------------------------------------------------------------------------------------------------- + +struct UnitUse +{ + Int teams, minTotal, maxTotal, cost; + UnitUse() : teams(0), minTotal(0), maxTotal(0), cost(-1) {} +}; + +bool moreTeams(const std::pair &a, const std::pair &b) +{ + return a.second.teams != b.second.teams ? a.second.teams > b.second.teams : a.first < b.first; +} + +McpJson countsToJson(const std::map &counts, const char *keyName, size_t limit) +{ + std::vector > order; + for (std::map::const_iterator it = counts.begin(); it != counts.end(); ++it) { + order.push_back(std::make_pair(-it->second, it->first)); + } + std::sort(order.begin(), order.end()); + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < order.size() && i < limit; i++) { + list.push(McpJson::makeObject().set(keyName, order[i].second).set("teams", -order[i].first)); + } + return list; +} + +/// One owner's teams in numbers: which units they use and how many, team sizes and build costs, +/// priorities and production conditions. +McpJson summarizeTeams(SidesList &sides, const AsciiString &owner, const TeamFilter &filter, Int limit) +{ + std::map units; + std::map priorities, conditions; + Int numTeams = 0, withoutUnits = 0, withoutCondition = 0; + Int sizeMin = INT_MAX, sizeMax = 0, sizeSum = 0, costMin = INT_MAX, costMax = 0; + double costSum = 0; + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + Dict *d = team->getDict(); + if (d->getAsciiString(TheKey_teamOwner) != owner || !filter.matches(sides, team)) continue; + numTeams++; + Int teamSize = 0, teamCost = 0; + for (Int slot = 1; slot <= NUM_UNIT_SLOTS; slot++) { + Bool exists = false; + const AsciiString type = d->getAsciiString(numberedKey("teamUnitType", slot), &exists); + const Int minCount = d->getInt(numberedKey("teamUnitMinCount", slot), &exists); + const Int maxCount = d->getInt(numberedKey("teamUnitMaxCount", slot), &exists); + if (isNoUnit(type) || maxCount <= 0) continue; + UnitUse &use = units[type.str()]; + if (use.teams == 0) { + const ThingTemplate *tt = TheThingFactory->findTemplate(type, FALSE); + use.cost = tt ? tt->friend_getBuildCost() : -1; + } + use.teams++; + use.minTotal += minCount; + use.maxTotal += maxCount; + teamSize += maxCount; + if (use.cost > 0) teamCost += maxCount * use.cost; + } + if (teamSize == 0) { + withoutUnits++; + } else { + sizeMin = teamSize < sizeMin ? teamSize : sizeMin; + sizeMax = teamSize > sizeMax ? teamSize : sizeMax; + sizeSum += teamSize; + costMin = teamCost < costMin ? teamCost : costMin; + costMax = teamCost > costMax ? teamCost : costMax; + costSum += teamCost; + } + Bool exists = false; + const Int priority = d->getInt(TheKey_teamProductionPriority, &exists); + if (exists) { + char text[16]; + sprintf(text, "%d", priority); + priorities[text]++; + } + const AsciiString condition = d->getAsciiString(TheKey_teamProductionCondition, &exists); + if (exists && !condition.isEmpty()) { + conditions[condition.str()]++; + } else { + withoutCondition++; + } + } + + std::vector > order(units.begin(), units.end()); + std::sort(order.begin(), order.end(), moreTeams); + McpJson unitList = McpJson::makeArray(); + for (size_t i = 0; i < order.size() && (Int)i < limit; i++) { + McpJson entry = McpJson::makeObject(); + entry.set("type", order[i].first).set("teams", order[i].second.teams); + entry.set("min_total", order[i].second.minTotal).set("max_total", order[i].second.maxTotal); + if (order[i].second.cost >= 0) { + entry.set("cost", order[i].second.cost); + } else { + entry.set("unknown_type", true); + } + unitList.push(entry); + } + + const Int withUnits = numTeams - withoutUnits; + McpJson j = McpJson::makeObject(); + j.set("owner", owner.str()).set("teams", numTeams).set("teams_without_units", withoutUnits); + j.set("teams_without_production_condition", withoutCondition); + if (withUnits > 0) { + j.set("team_size", McpJson::makeObject().set("min", sizeMin).set("max", sizeMax).set("average", (double)sizeSum / withUnits)); + j.set("team_cost", McpJson::makeObject().set("min", costMin).set("max", costMax).set("average", floor(costSum / withUnits + 0.5))); + } + j.set("unit_types", (int)order.size()).set("units", unitList); + j.set("priorities", countsToJson(priorities, "priority", 20)); + j.set("production_conditions", countsToJson(conditions, "script", 30)); + return j; +} + +McpJson cmdSummarizeTeams(const McpJson &args) +{ + mcpDoc(); + SidesList &sides = *TheSidesList; + TeamFilter filter; + filter.read(sides, args); + const Int limit = mcpArgInt(args, "limit", 40); + McpJson owners = McpJson::makeArray(); + for (Int side = 0; side < sides.getNumSides(); side++) { + const AsciiString owner = sideName(sides, side); + if (filter.hasOwner && owner != filter.owner) continue; + const McpJson summary = summarizeTeams(sides, owner, filter, limit); + // Without an owner filter, players that have no teams of their own are left out. + if (!filter.hasOwner && summary.get("teams").asNumber() == 0) continue; + owners.push(summary); + } + return McpJson::makeObject().set("owners", owners); +} + +McpJson cmdVerifyTeams(const McpJson &args) +{ + mcpDoc(); + SidesList &sides = *TheSidesList; + const Int onlySide = args.has("owner") ? requireSide(sides, mcpArgString(args, "owner")) : -1; + Int checked = 0; + const McpJson warnings = verifyTeams(sides, onlySide, mcpArgString(args, "name", ""), &checked); + return McpJson::makeObject().set("teams_checked", checked).set("warnings", warnings); +} + +} // namespace + +void McpScripts::setTeamPropertyChecked(Dict &dict, const std::string &key, const McpJson &value, bool allowUnknown) +{ + setTeamProperty(dict, key, value, allowUnknown); +} + +const char *McpScripts::teamPropertyRef(const char *key) +{ + const TeamKeyInfo *info = findTeamKey(key); + return info ? teamRefName(info->ref) : nullptr; +} + +McpJson McpScripts::verifyTeams(SidesList &sides, Int onlySide, const std::string &onlyName, Int *checked) +{ + const std::vector > scriptNames = scriptNamesPerSide(sides); + McpJson warnings = McpJson::makeArray(); + for (Int i = 0; i < sides.getNumTeams(); i++) { + TeamsInfo *team = sides.getTeamInfo(i); + Dict *d = team->getDict(); + if (onlySide >= 0 && d->getAsciiString(TheKey_teamOwner) != sideName(sides, onlySide)) continue; + if (!onlyName.empty() && onlyName != d->getAsciiString(TheKey_teamName).str()) continue; + collectTeamWarnings(sides, team, scriptNames, warnings); + (*checked)++; + } + return warnings; +} + +void mcpRegisterTeamCommands() +{ + mcpRegisterCommand("teams.list", cmdListTeams, "{owner?,filter?,unit?,script?,include_default_teams?=false,limit?=100,offset?} Teams in short form: units with counts, priority and the scripts they name."); + mcpRegisterCommand("teams.list_properties", cmdListTeamProperties, "Team properties the game reads, with type, meaning and what they name."); + mcpRegisterCommand("teams.get", cmdGetTeam, "{name} One team: units and all its properties."); + mcpRegisterCommand("teams.set", cmdSetTeam, "{name,owner?,units?:[{type,min,max}],properties?,allow_unknown_properties?} Creates a team (owner required) or changes it; property types are checked."); + mcpRegisterCommand("teams.delete", cmdDeleteTeam, "{name,force?=false} Deletes a team."); + mcpRegisterCommand("teams.set_many", cmdSetManyTeams, "{owner?,filter?,unit?,script?,all_teams?,properties?,multiply?:{key|prefix*:factor},add?:{...},dry_run?} Changes every matching team."); + mcpRegisterCommand("teams.summary", cmdSummarizeTeams, "{owner?,filter?,unit?,script?,limit?=40} Per owner: unit usage with costs, team sizes and costs, priorities, production conditions."); + mcpRegisterCommand("teams.verify", cmdVerifyTeams, "{owner?,name?} Checks team properties: wrong types, unknown keys, missing unit types, scripts and waypoints."); +} diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp index a04b01067b2..0f07fc02395 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -87,6 +87,10 @@ void registerAllCommands() mcpRegisterRouteCommands(); mcpRegisterReplaceCommands(); mcpRegisterResizeCommands(); + mcpRegisterScriptCommands(); + mcpRegisterTeamCommands(); + mcpRegisterScriptToolCommands(); + mcpRegisterScriptViewCommands(); } std::string makeErrorReply(const McpJson &id, const std::string &message) @@ -122,6 +126,18 @@ void mcpRegisterCommand(const char *name, McpHandler handler, const char *help) commandTable()[name] = entry; } +McpJson mcpRunCommand(const std::string &cmd, const McpJson &args) +{ + CommandTable::const_iterator it = commandTable().find(cmd); + if (it == commandTable().end()) { + mcpFail("unknown command '%s' (use list_commands)", cmd.c_str()); + } + if (!args.isObject()) { + mcpFail("'args' must be an object"); + } + return it->second.handler(args); +} + std::string mcpDispatch(const std::string &requestLine) { registerAllCommands(); diff --git a/scripts/worldbuilder-mcp/README.md b/scripts/worldbuilder-mcp/README.md index 80a48a855fd..0120bcbd5f4 100644 --- a/scripts/worldbuilder-mcp/README.md +++ b/scripts/worldbuilder-mcp/README.md @@ -2,7 +2,7 @@ Lets an MCP client (Claude Code, Claude Desktop, ...) edit the map that is open in Zero Hour WorldBuilder: terrain heights and textures, objects, players, waypoints, water and trigger areas, -roads, map settings, save/load and 3D screenshots. +roads, scripts and teams, map settings, save/load and 3D screenshots. ``` MCP client <-- stdio --> server.py <-- TCP 127.0.0.1:47800, JSON lines --> WorldBuilderZH.exe -mcp @@ -98,6 +98,32 @@ WorldBuilder rejects commands while a modal dialog is open or a mouse drag is in - **Skirmish AI.** `ai_skirmish_setup` creates what the AI looks up by name for every `Player__Start`. `ai_skirmish_check` reports what is missing. - Areas: `InnerPerimeter` and `OuterPerimeter`, plus a `CombatZone`. - Paths: approach paths into each base, labelled `Center`, `Flank` and `Backdoor`. +- **Scripts and teams.** The `scripts_*` tools do what the script editor (Edit > Scripts) does, without its window. + - `scripts_list` and `scripts_get` read; `scripts_add`, `scripts_update`, `scripts_delete` and `scripts_move` write. Groups have their own tools. + - A script is JSON: flags, `conditions` (a list of OR clauses, each a list of ANDed conditions), `actions` and `false_actions`. + - `scripts_list_actions` and `scripts_list_conditions` list the types with their parameter types. Parameters are given in order. + - `scripts_list_values` lists what a parameter accepts (upgrades, sciences, special powers, command buttons, object types, ...); `teams_list_properties` lists the team properties with their types. + - `scripts_copy`, `teams_copy` and `scripts_clone_player` duplicate; the last one derives a whole faction's scripts and teams from another, with name replacements. + - `scripts_rename` renames a script, group, team, flag, counter or any named value together with its references in scripts and teams. + - `scripts_build_order`, `scripts_trace` and `scripts_by_difficulty` read a set from one angle: what is built when, what a team or script sets in motion, what belongs to which difficulty. + - `objects_info` gives unit and structure stats (cost, KindOf, where it is built); `buildlist_*` edits the players' build lists. + - `scripts_list_templates` and `scripts_apply_template` create ready-made script and team combinations from `presets/script_templates.json`. + - `scripts_report` writes a Markdown report of one player's AI. + - `scripts_compare` shows how two players' scripts and teams differ, or how the map differs from a dump file. + - `scripts_set_values` and `teams_set_many` change one kind of value in many scripts or teams (set, add, multiply), with a dry run. + - `teams_summary` gives a faction's army in numbers: unit usage with costs, team sizes, priorities. + - `scripts_missing` groups broken references by the missing name and suggests the closest existing ones. + - `scripts_batch` runs many edits as one undo step, all or nothing. + - `scripts_dump` and `scripts_load` write and read everything as readable JSON, for review, diffs and bulk edits. + - `scripts_analyze` looks for dead logic: unused subroutines, flags and counters used one-sided, teams never built. + - `sides_set` and `sides_delete` change, rename and remove players. + - `scripts_find` searches inside scripts and team properties: by parameter value, wording, type or parameter type. + - `scripts_verify` runs the editor's checks for references to things that do not exist. Edits report the same warnings. + - `scripts_export` and `scripts_import` write and read `.scb` files with scripts, players and teams, and on request the waypoints, areas and units they need. An existing file is only replaced on request, and a dated backup is kept. + - AI script sets such as `SkirmishScripts.scb` are edited by importing them into a map, changing them and exporting again. + - `teams_list` shows teams in short form (units with counts, priority, the scripts they name); `teams_get`, `teams_set` and `teams_delete` edit team templates as property dicts. + - On large sets `scripts_list` reduces its detail by itself; `detail`, `player` and `group` control it. + - Like the editor, each edit replaces the whole sides list as one undo step. Edits are refused while the script editor window is open, because its OK would overwrite them. - **Procedural terrain.** A typical sequence is `terrain_generate`, then `terrain_limit_slope`, then `terrain_auto_texture`. - `terrain_generate` builds heights from layered, seeded Perlin noise. It can protect spots such as base locations. - `terrain_limit_slope` turns spikes into walkable slopes. diff --git a/scripts/worldbuilder-mcp/presets/script_templates.json b/scripts/worldbuilder-mcp/presets/script_templates.json new file mode 100644 index 00000000000..689894611e2 --- /dev/null +++ b/scripts/worldbuilder-mcp/presets/script_templates.json @@ -0,0 +1,131 @@ +{ + "_comment": "Script templates for scripts_apply_template. Each template is a list of bridge commands (as scripts_batch takes them) with ${name} placeholders. A string that is exactly \"${name}\" is replaced by the parameter's value with its type (number, list, ...); inside longer strings the value is inserted as text. Add your own templates here.", + "templates": [ + { + "name": "build_structure", + "description": "A build-order step: build a structure once another structure exists and the player has more than a given amount of credits.", + "params": { + "player": {"description": "Owning player, e.g. SkirmishAmerica"}, + "group": {"description": "Existing script group to add the script to"}, + "name": {"description": "Script name"}, + "structure": {"description": "Object type to build"}, + "requires": {"description": "Object type that must have been built first"}, + "min_credits": {"description": "Credits the player must exceed", "default": 2000} + }, + "commands": [ + { + "cmd": "scripts.add", + "args": { + "player": "${player}", + "group": "${group}", + "script": { + "name": "${name}", + "conditions": [[ + {"type": "BUILT_BY_PLAYER", "params": ["${requires}", ""]}, + {"type": "PLAYER_HAS_CREDITS", "params": ["${min_credits}", "LESS_THAN", ""]} + ]], + "actions": [{"type": "SKIRMISH_BUILD_BUILDING", "params": ["${structure}"]}] + } + } + } + ] + }, + { + "name": "attack_team", + "description": "A team the AI builds when it has enough credits and that hunts once it is complete. Creates the team, its production condition script ' Condition' and its on-create script ' Launch'.", + "params": { + "player": {"description": "Owning player"}, + "group": {"description": "Existing script group for the two scripts"}, + "name": {"description": "Team name; must be unique in the map"}, + "units": {"description": "Unit slots: [{\"type\": ..., \"min\": n, \"max\": n}, ...] (up to 7)"}, + "min_credits": {"description": "Credits the player must exceed before the team is built", "default": 2500}, + "priority": {"description": "Production priority; the highest buildable team is built first", "default": 1000}, + "max_instances": {"description": "How many copies may exist at once", "default": 1} + }, + "commands": [ + { + "cmd": "scripts.add", + "args": { + "player": "${player}", + "group": "${group}", + "script": { + "name": "${name} Condition", + "subroutine": true, + "one_shot": false, + "conditions": [[{"type": "PLAYER_HAS_CREDITS", "params": ["${min_credits}", "LESS_THAN", ""]}]] + } + } + }, + { + "cmd": "scripts.add", + "args": { + "player": "${player}", + "group": "${group}", + "script": { + "name": "${name} Launch", + "subroutine": true, + "conditions": [[{"type": "TEAM_CREATED", "params": [""]}]], + "actions": [{"type": "TEAM_HUNT", "params": [""]}] + } + } + }, + { + "cmd": "teams.set", + "args": { + "name": "${name}", + "owner": "${player}", + "units": "${units}", + "properties": { + "teamProductionCondition": "${name} Condition", + "teamOnCreateScript": "${name} Launch", + "teamProductionPriority": "${priority}", + "teamMaxInstances": "${max_instances}", + "teamExecutesActionsOnCreate": true, + "teamAggressiveness": 2 + } + } + } + ] + }, + { + "name": "timer_loop", + "description": "A repeating timer: ' Start' starts it once, ' Tick' sets a flag every time it expires and restarts it. Other scripts wait for the flag and set it back to false.", + "params": { + "player": {"description": "Owning player"}, + "group": {"description": "Existing script group"}, + "name": {"description": "Base name of the two scripts and of the timer"}, + "seconds": {"description": "Timer length in seconds", "default": 60}, + "flag": {"description": "Flag set to true on every tick"} + }, + "commands": [ + { + "cmd": "scripts.add", + "args": { + "player": "${player}", + "group": "${group}", + "script": { + "name": "${name} Start", + "actions": [{"type": "SET_MILLISECOND_TIMER", "params": ["${name} Timer", "${seconds}"]}] + } + } + }, + { + "cmd": "scripts.add", + "args": { + "player": "${player}", + "group": "${group}", + "script": { + "name": "${name} Tick", + "one_shot": false, + "conditions": [[{"type": "TIMER_EXPIRED", "params": ["${name} Timer"]}]], + "actions": [ + {"type": "SET_FLAG", "params": ["${flag}", true]}, + {"type": "SET_MILLISECOND_TIMER", "params": ["${name} Timer", "${seconds}"]} + ] + } + } + } + ] + } + ] +} diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py index 372589284cd..a76d6caf9a9 100644 --- a/scripts/worldbuilder-mcp/server.py +++ b/scripts/worldbuilder-mcp/server.py @@ -36,6 +36,24 @@ - Skirmish maps: add Player__Start waypoints, then run ai_skirmish_setup (and ai_skirmish_check). The AI needs numbered names: InnerPerimeter1, OuterPerimeter1 and paths labeled Center1, Flank1, Backdoor1 leading into base 1, and so on per player. Plain "Center" labels are ignored. +- Scripts (map scripts and AI scripts): scripts_list / scripts_get to read, scripts_add / + scripts_update to write, scripts_list_actions / scripts_list_conditions to find the types, + scripts_find to search what scripts contain (who uses a team, script, object type ...). + Scripts refer to each other, to teams, waypoints and areas by name. Teams are reviewed with + teams_list and edited with teams_get / teams_set. On AI script sets (thousands of scripts) start + with scripts_list detail="groups" and teams_list per owner. +- Building or reworking a whole AI: look names up with scripts_list_values and + teams_list_properties instead of guessing; derive a faction with scripts_clone_player; rename + with scripts_rename (it updates references); put many edits into scripts_batch (one undo step, + all or nothing); keep a scripts_dump file as a readable backup; finish with scripts_verify and + scripts_analyze. +- Understanding a set: scripts_build_order (what is built when), scripts_trace (what one team or + script sets in motion), scripts_by_difficulty, objects_info (unit stats), scripts_report (all of + it as a Markdown file). +- Rebalancing: teams_summary and scripts_compare show what a faction has and how two differ; + scripts_set_values and teams_set_many change many values at once (try dry_run first); + scripts_missing groups broken references by cause and suggests the intended names. Run scripts_verify after a batch of changes. Script edits fail while + WorldBuilder's own script editor window is open. - Every edit is one WorldBuilder undo step (edit_undo reverts it). WorldBuilder keeps only 15. - Use view_screenshot to look at the result. """ @@ -652,6 +670,75 @@ def sides_list_factions() -> dict: return wb.call("sides.list_factions") +@mcp.tool() +def objects_info(templates: list[str] | None = None, filter: str | None = None, limit: int = 20) -> dict: + """Stats of object types (units, structures): build cost, owning side, display name, KindOf + flags, buildable status, energy, vision range, command set and `built_at`, the structures + that produce it. Pass the names as templates (up to 60; unknown ones come back in `unknown`), + or a name substring as filter. Use it to judge team compositions and build orders.""" + return wb.call("objects.info", templates=templates, filter=filter, limit=limit) + + +@mcp.tool() +def buildlist_list(player: str | None = None) -> dict: + """The players' build lists (WorldBuilder's Build List tool): structures a player's AI + builds at fixed spots, or starts with, in order. Used by campaign and scripted-map AI; + skirmish AI sets usually build through scripts instead (see scripts_build_order).""" + return wb.call("buildlist.list", player=player) + + +@mcp.tool() +def buildlist_add(player: str, template: str, x: float, y: float, z: float = 0, angle_deg: float = 0, + name: str | None = None, script: str | None = None, initially_built: bool | None = None, + rebuilds: int | Literal["unlimited"] | None = None, health: int | None = None, + whiner: bool | None = None, unsellable: bool | None = None, repairable: bool | None = None, + index: int | None = None) -> dict: + """Adds a structure to a player's build list at world position x, y (index = position in the + list, default the end; the AI builds in list order). name lets scripts refer to the building, + script runs when it is built, initially_built makes it exist at game start, rebuilds is how + often the AI rebuilds it when destroyed, health its starting health in percent.""" + return wb.call("buildlist.add", player=player, template=template, x=x, y=y, z=z, angle_deg=angle_deg, name=name, + script=script, initially_built=initially_built, rebuilds=rebuilds, health=health, whiner=whiner, + unsellable=unsellable, repairable=repairable, index=index) + + +@mcp.tool() +def buildlist_update(player: str, index: int, new_index: int | None = None, template: str | None = None, + x: float | None = None, y: float | None = None, z: float | None = None, + angle_deg: float | None = None, name: str | None = None, script: str | None = None, + initially_built: bool | None = None, rebuilds: int | Literal["unlimited"] | None = None, + health: int | None = None, whiner: bool | None = None, unsellable: bool | None = None, + repairable: bool | None = None) -> dict: + """Changes the given fields of a build list entry (index from buildlist_list); new_index moves + it to another position in the list.""" + return wb.call("buildlist.update", player=player, index=index, new_index=new_index, template=template, x=x, y=y, + z=z, angle_deg=angle_deg, name=name, script=script, initially_built=initially_built, + rebuilds=rebuilds, health=health, whiner=whiner, unsellable=unsellable, repairable=repairable) + + +@mcp.tool() +def buildlist_delete(player: str, index: int) -> dict: + """Deletes a build list entry.""" + return wb.call("buildlist.delete", player=player, index=index) + + +@mcp.tool() +def sides_set(name: str, new_name: str | None = None, properties: dict[str, Any] | None = None) -> dict: + """Changes a player. properties are the player dict keys sides_list shows (playerFaction, + playerDisplayName, playerIsHuman, playerAllies, playerEnemies, ...). new_name renames the + player and what refers to the name: its teams' owner, its default team "team", other + players' allies/enemies and player/team parameters in scripts. Renaming fails while map + objects belong to its teams.""" + return wb.call("sides.set", name=name, new_name=new_name, properties=properties) + + +@mcp.tool() +def sides_delete(name: str, force: bool = False) -> dict: + """Deletes a player. With scripts or teams it needs force=true and deletes them too. Fails + while map objects belong to its teams.""" + return wb.call("sides.delete", name=name, force=force) + + @mcp.tool() def sides_add_player(faction: str, name: str | None = None, display_name: str | None = None, is_human: bool = True, allies: str | None = None, enemies: str | None = None) -> dict: @@ -799,6 +886,633 @@ def ai_skirmish_check() -> dict: return wb.call("ai.skirmish_check") +# -------------------------------------------------------------------------------------------------- +# Scripts and teams +# -------------------------------------------------------------------------------------------------- + +@mcp.tool() +def scripts_list(player: str | None = None, group: str | None = None, filter: str | None = None, + detail: Literal["auto", "groups", "names", "full"] = "auto") -> dict: + """Scripts and script groups (folders) per player. Every script belongs to a player ("" is the + neutral player), either at the top level or in a group; they run in the listed order. + player and group ("" = the scripts outside of groups) narrow it; filter is a substring of the + script name. detail: "groups" = groups with script counts only, "names" = script names, + "full" = name, flags and comment per script. "auto" picks full for up to 120 matching scripts, + names up to 400 and groups beyond (AI script sets hold thousands), and says so in `note`. + Start with detail="groups" on a big set, then list one group.""" + return wb.call("scripts.list", player=player, group=group, filter=filter, detail=detail) + + +@mcp.tool() +def scripts_find(text: str | None = None, value: str | None = None, type: str | None = None, + param_type: str | None = None, player: str | None = None, limit: int = 50, offset: int = 0, + max_hits_per_script: int = 10) -> dict: + """Searches inside scripts and returns the matching conditions and actions per script (with + `where`, e.g. actions[2], and the editor's wording). All given filters must hold for the same + condition or action: + - value: exact parameter value (case-insensitive), e.g. a team, script, waypoint, area, object + type, flag or counter name. Add param_type (TEAM, SCRIPT, OBJECT_TYPE, ...) to pin the kind. + - text: substring of the editor's wording, e.g. "begins hunting" or part of a name. + - type: condition or action type, e.g. TEAM_HUNT. param_type alone lists every use of that kind. + With only text/value it also matches script names and comments, and returns `teams`: team + properties holding that text (teams name scripts and unit types too). Use it before renaming + or deleting something to see who refers to it. total_scripts counts all matches; page with + limit/offset. Each script lists at most max_hits_per_script hits (hit_count has the real + number); teams are capped at limit (total_team_hits).""" + return wb.call("scripts.find", text=text, value=value, type=type, param_type=param_type, player=player, + limit=limit, offset=offset, max_hits_per_script=max_hits_per_script) + + +@mcp.tool() +def scripts_get(name: str, player: str | None = None, group: str | None = None, + script_index: int | None = None) -> dict: + """One script in full, in the form scripts_add/scripts_update take, plus `text`, the script as + the editor words it. player, group ("" = not in a group) and script_index (position in its + list, as scripts_list orders them) are only needed when several scripts share the name; the + error then lists the candidates. The same selectors work on update, delete and move.""" + return wb.call("scripts.get", name=name, player=player, group=group, script_index=script_index) + + +@mcp.tool() +def scripts_add(script: dict[str, Any], player: str | None = None, group: str | None = None, + index: int | None = None, allow_duplicate_name: bool = False) -> dict: + """Adds a script to a player's top level (player; "" = neutral) or to a script group (group). + index is the position in that list (default: at the end). script: + {"name": "...", "comment": "", "active": true, "one_shot": true, "subroutine": false, + "easy": true, "normal": true, "hard": true, "delay_seconds": 0, + "conditions": [[{"type": "CONDITION_TRUE"}]], + "actions": [{"type": "SET_FLAG", "params": ["MyFlag", true]}], + "false_actions": []} + Only name is required; the defaults are the ones shown. conditions is a list of OR clauses, + each a list of conditions that must all hold (a flat list is one clause). Types and their + parameters come from scripts_list_conditions / scripts_list_actions. params are given in + order, one value per parameter type: + - names (TEAM, SCRIPT, WAYPOINT, TRIGGER_AREA, SIDE, UNIT, OBJECT_TYPE, FLAG, COUNTER, ...): string. + Special names: "", "", "", "". + - INT, REAL: number. BOOLEAN: true/false. ANGLE: degrees. PERCENT: 0..100. + - COORD3D: [x, y, z]. COLOR: "#RRGGBB" or "#AARRGGBB". + - COMPARISON: LESS_THAN, LESS_EQUAL, EQUAL, GREATER_EQUAL, GREATER, NOT_EQUAL (or <, <=, ==, ...). + - RELATION: ENEMY, NEUTRAL, FRIEND. AI_MOOD: SLEEP, PASSIVE, NORMAL, ALERT, AGGRESSIVE. + - KIND_OF_PARAM, BUILDABLE, SURFACES_ALLOWED, SHAKE_INTENSITY, RADAR_EVENT_TYPE: the name. + Returns `warnings`: references the editor would flag (missing team, script, waypoint, ...). + The script is added anyway. A name must be new for that player unless allow_duplicate_name. + One undo step.""" + return wb.call("scripts.add", script=script, player=player, group=group, index=index, + allow_duplicate_name=allow_duplicate_name) + + +@mcp.tool() +def scripts_update(name: str, script: dict[str, Any], player: str | None = None, group: str | None = None, + script_index: int | None = None, allow_duplicate_name: bool = False) -> dict: + """Changes a script. `script` holds only the keys to change (same keys as scripts_add, e.g. + {"active": false} or {"name": "NewName"}); conditions, actions and false_actions are each + replaced as a whole list, so start from scripts_get to change one entry. Renaming does not + update references in other scripts or teams; scripts_verify finds them. Returns `warnings`.""" + return wb.call("scripts.update", name=name, script=script, player=player, group=group, + script_index=script_index, allow_duplicate_name=allow_duplicate_name) + + +@mcp.tool() +def scripts_batch(commands: list[dict[str, Any]], results: Literal["summary", "full"] = "summary") -> dict: + """Runs many script and team edits as ONE undo step, all or nothing: if a command fails, + nothing is changed and the error names its index. Use it when building or reworking a set + (WorldBuilder keeps only 15 undo steps). commands: [{"cmd": "scripts.add", "args": {...}}, ...] + with the bridge names and arguments of the scripts_* / teams_* tools (dots instead of the + first underscore: scripts.add, scripts.update, scripts.add_group, scripts.rename, + scripts.clone_player with from/to, teams.set, teams.delete, sides.set, sides.delete). + Returns the warnings of all commands, each with its `command` index; results="full" also + returns every command's result.""" + return wb.call("scripts.batch", commands=commands, results=results) + + +def _strip_text(node: Any) -> None: + """Removes the editor wording from a scripts.dump document.""" + if isinstance(node, dict): + if "type" in node and "params" in node: + node.pop("text", None) + for value in node.values(): + _strip_text(value) + elif isinstance(node, list): + for value in node: + _strip_text(value) + + +@mcp.tool() +def scripts_dump(path: str, player: str | None = None, include_teams: bool = True, text: bool = True) -> dict: + """Writes all scripts, groups and teams (or one player's) to a readable JSON file: players + with their properties, scripts in full (as scripts_get shows them) and team property dicts. + Good for review, diffs, version control, backups and bulk edits; scripts_load reads it back. + text=false leaves out the editor's wording of each condition and action (smaller file).""" + doc = wb.call("scripts.dump", player=player, include_teams=include_teams) + if not text: + _strip_text(doc) + full = os.path.abspath(path) + with open(full, "w", encoding="utf-8", newline="\n") as f: + json.dump(doc, f, indent=1, ensure_ascii=False) + return {"path": full, "players": [p["player"] for p in doc["players"]], "scripts": doc["script_count"], + "teams": len(doc.get("teams", [])), "bytes": os.path.getsize(full)} + + +@mcp.tool() +def scripts_load(path: str, create_players: bool = True) -> dict: + """Reads a scripts_dump JSON file and REPLACES, for every player in it, all of that player's + scripts and groups, and (if the file has `teams`) all of its teams, with the file's contents. + Players not in the file are untouched; players the map lacks are created from the file's + properties. The whole file is checked like scripts_add / teams_set and nothing is changed if + any part is invalid; the error names the player and script. One undo step. Run + scripts_verify afterwards.""" + with open(os.path.abspath(path), encoding="utf-8") as f: + doc = json.load(f) + return wb.call("scripts.load", data=doc, create_players=create_players) + + +@mcp.tool() +def scripts_analyze(player: str | None = None, limit: int = 40) -> dict: + """Looks for dead logic that scripts_verify cannot see, per category with a count and up to + `limit` items: subroutines nothing calls, inactive scripts nothing enables, scripts without + actions, flags and counters/timers that are read but never set or set but never read, empty + groups, and teams that have no production condition and are named by no script. Findings are + hints, not errors: a few are intended (e.g. scripts kept switched off).""" + return wb.call("scripts.analyze", player=player, limit=limit) + + +@mcp.tool() +def scripts_trace(name: str, player: str | None = None, kind: Literal["auto", "script"] = "auto", depth: int = 4, + max_scripts: int = 60) -> dict: + """Follows what a team or a script sets in motion, as a tree in the editor's wording. For a + team: every script it names (production condition, on create, on destroyed, hooks ...). For + each script: its conditions (`if`, one entry per OR clause), actions (`then` / `else`) and + `leads_to`: scripts its actions run or enable, and scripts of the same player whose conditions + wait for a flag, counter or timer it sets (`via` says which). A script already shown appears + as shown_above; missing ones as missing. For a script, `started_by` lists the scripts and + teams naming it. name is a team name or, with player (or kind="script"), a script name. AI + behavior is spread over many small scripts; this shows one behavior in one place.""" + return wb.call("scripts.trace", name=name, player=player, kind=kind, depth=depth, max_scripts=max_scripts) + + +@mcp.tool() +def scripts_build_order(player: str, difficulty: Literal["E", "N", "H"] | None = None, + include_inactive: bool = True) -> dict: + """A player's build order: every script that builds a structure or an upgrade (the + SKIRMISH_BUILD_* and AI_PLAYER_BUILD_* actions), in the order the scripts are evaluated, with + group, conditions (`if`, one entry per OR clause), what it builds, and whether it is inactive, + repeats or only runs on some difficulties (difficulty "NH" = normal and hard). `totals` counts + build actions per structure. difficulty keeps only steps that run on E, N or H.""" + return wb.call("scripts.build_order", player=player, difficulty=difficulty, include_inactive=include_inactive) + + +@mcp.tool() +def scripts_by_difficulty(player: str, limit: int = 40) -> dict: + """A player's scripts and teams by difficulty. script_switches: how many scripts run on which + difficulties according to their easy/normal/hard switches, with the names of those that do + not run on all three. scripts_named / teams_named: scripts and teams whose name carries the + word E, N or H ("SCM E Attack Team 1", "CT Def H"), the convention these AI sets use to tie + things to a difficulty through their production conditions.""" + return wb.call("scripts.by_difficulty", player=player, limit=limit) + + +def _load_script_templates() -> list[dict[str, Any]]: + with open(os.path.join(_PRESET_DIR, "script_templates.json"), encoding="utf-8") as f: + return json.load(f)["templates"] + + +def _fill_template(node: Any, values: dict[str, Any]) -> Any: + """Replaces ${name} placeholders; a string that is only a placeholder takes the value's type.""" + if isinstance(node, dict): + return {key: _fill_template(value, values) for key, value in node.items()} + if isinstance(node, list): + return [_fill_template(value, values) for value in node] + if isinstance(node, str): + if node.startswith("${") and node.endswith("}") and node[2:-1] in values: + return values[node[2:-1]] + for key, value in values.items(): + node = node.replace("${" + key + "}", str(value)) + return node + + +@mcp.tool() +def scripts_list_templates() -> dict: + """Script templates for scripts_apply_template: ready-made combinations of scripts and teams + with parameters, such as a build-order step, an attack team with its condition and launch + scripts, or a repeating timer. They are defined in presets/script_templates.json, where more + can be added.""" + return {"templates": [{"name": t["name"], "description": t["description"], "params": t["params"]} + for t in _load_script_templates()]} + + +@mcp.tool() +def scripts_apply_template(template: str, params: dict[str, Any], dry_run: bool = False) -> dict: + """Creates the scripts and teams of a template (see scripts_list_templates) with the given + parameter values, as one undo step and all or nothing. Parameters without a default are + required. dry_run returns the commands it would run (they are scripts_batch commands, so they + can be adjusted and run with that instead). Returns the warnings of the created scripts and + teams, e.g. unit types or scripts that do not exist.""" + found = [t for t in _load_script_templates() if t["name"] == template] + if not found: + raise ToolError(f"unknown template '{template}' (see scripts_list_templates)") + values = {name: spec["default"] for name, spec in found[0]["params"].items() if "default" in spec} + unknown = [name for name in params if name not in found[0]["params"]] + if unknown: + raise ToolError(f"template '{template}' has no parameter(s) {unknown}; it takes {list(found[0]['params'])}") + values.update(params) + missing = [name for name in found[0]["params"] if name not in values] + if missing: + raise ToolError(f"template '{template}' needs parameter(s) {missing}") + commands = _fill_template(found[0]["commands"], values) + if dry_run: + return {"dry_run": True, "commands": commands} + return wb.call("scripts.batch", commands=commands) + + +@mcp.tool() +def scripts_report(player: str, path: str) -> dict: + """Writes a Markdown report of one player's AI to a file: script groups with counts, the + build order, every team with units, priority and scripts, the army in numbers, scripts and + teams per difficulty, dead logic and broken references. For reading, reviewing with others + or keeping next to a scripts_dump as documentation of a state.""" + groups = wb.call("scripts.list", player=player, detail="groups")["players"][0] + order = wb.call("scripts.build_order", player=player) + teams = wb.call("teams.list", owner=player, limit=1000)["teams"] + summary = wb.call("teams.summary", owner=player, limit=100)["owners"] + difficulty = wb.call("scripts.by_difficulty", player=player, limit=15) + analysis = wb.call("scripts.analyze", player=player, limit=15) + missing = wb.call("scripts.missing", player=player, limit=60) + + def cell(text: Any) -> str: + return str(text).replace("|", "\\|").replace("\r", " ").replace("\n", " ") + + out = [f"# AI report: {player or '(neutral)'}", ""] + total = groups.get("script_count", 0) + sum(g["script_count"] for g in groups["groups"]) + out += [f"{total} scripts in {len(groups['groups'])} groups, {len(teams)} teams.", ""] + + out += ["## Script groups", "", "| Group | Scripts | Subroutines | Inactive |", "|---|---|---|---|"] + if groups.get("script_count"): + out.append(f"| (not in a group) | {groups['script_count']} | {groups.get('subroutines', 0)} | {groups.get('inactive', 0)} |") + for g in groups["groups"]: + flags = "" if g.get("active", True) else " (group off)" + out.append(f"| {cell(g['name'])}{flags} | {g['script_count']} | {g.get('subroutines', 0)} | {g.get('inactive', 0)} |") + + out += ["", "## Build order", "", "| # | Script | Builds | When | Notes |", "|---|---|---|---|---|"] + for step in order["steps"]: + notes = [n for n in ("inactive" if step.get("active") is False else "", "repeats" if step.get("repeats") else "", + f"difficulty {step['difficulty']}" if step.get("difficulty") else "") if n] + builds = ", ".join(b.get("what", b["action"]) for b in step["builds"]) + out.append(f"| {step['step']} | {cell(step['script'])} | {cell(builds)} | {cell(' OR '.join(step['if']))} | {', '.join(notes)} |") + + out += ["", "## Teams", "", "| Team | Units | Priority | Max | Built when | Scripts |", "|---|---|---|---|---|---|"] + for t in teams: + scripts = dict(t["scripts"]) + condition = scripts.pop("ProductionCondition", "") + hooks = ", ".join(f"{k.replace('GenericScriptHook', 'hook ').replace('Script', '')}: {v}" for k, v in scripts.items()) + out.append(f"| {cell(t['name'])} | {cell(', '.join(t['units']))} | {t.get('priority', '')} | {t.get('max_instances', '')} " + f"| {cell(condition)} | {cell(hooks)} |") + + for s in summary: + out += ["", "## Army in numbers", ""] + if "team_size" in s: + out.append(f"Team size {s['team_size']['min']} to {s['team_size']['max']} units (average {s['team_size']['average']:.1f}); " + f"full team cost {s['team_cost']['min']} to {s['team_cost']['max']} (average {s['team_cost']['average']:.0f}).") + out.append("") + out += ["| Unit | Teams | Min total | Max total | Cost |", "|---|---|---|---|---|"] + for u in s["units"]: + out.append(f"| {cell(u['type'])} | {u['teams']} | {u['min_total']} | {u['max_total']} | {u.get('cost', 'unknown type')} |") + out += ["", "Production priorities: " + ", ".join(f"{p['priority']} ({p['teams']} teams)" for p in s["priorities"]) + "."] + + out += ["", "## Difficulty", ""] + for s in difficulty["script_switches"]: + names = f": {', '.join(s['names'])}" if s.get("names") else "" + out.append(f"- Scripts running on {s['runs_on']}: {s['scripts']}{cell(names)}") + for tag in difficulty["teams_named"]: + out.append(f"- Teams named {tag['tag']}: {tag['teams']}") + out.append(f"- Teams without a difficulty word: {difficulty['teams_without_tag']}") + + out += ["", "## Possible dead logic", ""] + for key, value in analysis.items(): + if value["count"]: + names = ", ".join(i["name"] for i in value["items"]) + more = " ..." if value["count"] > len(value["items"]) else "" + out.append(f"- {key.replace('_', ' ')}: {value['count']} ({cell(names)}{more})") + + out += ["", "## Broken references", ""] + if not missing["missing"]: + out.append("None.") + else: + out += ["| Uses | Kind | Missing | Used by | Closest existing |", "|---|---|---|---|---|"] + for m in missing["missing"]: + out.append(f"| {m['uses']} | {m['kind']} | {cell(m['missing'])} | {cell('; '.join(m['used_by']))} " + f"| {cell(', '.join(m.get('suggestions', [])))} |") + + full = os.path.abspath(path) + with open(full, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(out) + "\n") + return {"path": full, "lines": len(out), "scripts": total, "teams": len(teams), "build_steps": len(order["steps"]), + "broken_references": missing["total_uses"]} + + +@mcp.tool() +def scripts_compare(player: str, with_player: str | None = None, with_file: str | None = None, + replace: dict[str, str] | None = None, include_teams: bool = True, limit: int = 30) -> dict: + """Compares the scripts and teams of `player` (side a) with another player of the map + (with_player) or with a scripts_dump file (with_file; with_player then names the player inside + the file, default the same name), e.g. to see what your edits changed before exporting. + Returns per side what only it has, and for scripts both have, the conditions/actions/flags + that differ, in the editor's wording (lines only in a / only in b). Teams: which properties + differ as [a, b]. Factions name things with their own prefix; `replace` rewrites names of side + b before comparing so they line up, e.g. comparing SkirmishAmerica with the SuperWeapon + general: {"HTC ": "SCM ", "SupW_": ""}. Lists are capped at limit; the counts are complete.""" + data = None + if with_file: + with open(os.path.abspath(with_file), encoding="utf-8") as f: + data = json.load(f) + return wb.call("scripts.compare", player=player, with_player=with_player, with_data=data, replace=replace, + include_teams=include_teams, limit=limit) + + +@mcp.tool() +def scripts_set_values(param_type: str, value: Any, op: Literal["set", "add", "multiply"] = "set", + player: str | None = None, group: str | None = None, script_filter: str | None = None, + type: str | None = None, current: str | None = None, text: str | None = None, + where: Literal["any", "conditions", "actions"] = "any", min: float | None = None, + max: float | None = None, all_scripts: bool = False, dry_run: bool = False) -> dict: + """Changes one kind of parameter in many scripts at once. Picks parameters of param_type + (INT, REAL, OBJECT_TYPE, TEAM, ...) in the conditions/actions that match all given filters: + player, group, script_filter (substring of the script name), type (condition/action type, e.g. + PLAYER_HAS_CREDITS), current (the parameter's exact current value), text (substring of the + editor wording), where. op: "set" gives them `value`; "add" / "multiply" change numbers by + `value` (INT is rounded; min / max clamp the result). Examples: raise every credit threshold + of one faction's build scripts by 20%: param_type="INT", type="PLAYER_HAS_CREDITS", + group="SCM Base Main", op="multiply", value=1.2. Swap a unit everywhere: param_type= + "OBJECT_TYPE", current="OldName", value="NewName" (scripts_rename also covers teams). + At least one filter is required unless all_scripts. Use dry_run first: it returns the counts + and up to 15 before/after samples without changing anything. One undo step.""" + return wb.call("scripts.set_values", param_type=param_type, value=value, op=op, player=player, group=group, + script_filter=script_filter, type=type, current=current, text=text, min=min, max=max, + all_scripts=all_scripts, dry_run=dry_run, **{"in": where}) + + +@mcp.tool() +def scripts_missing(player: str | None = None, limit: int = 50, suggest: bool = True) -> dict: + """Everything scripts and teams refer to that does not exist, grouped by the missing name and + sorted by how often it is used: kind (OBJECT_TYPE, SCRIPT, TEAM, WAYPOINT, UPGRADE, ...), the + players and a few places using it, and `suggestions`, the closest existing names. One typo or + one removed unit usually causes dozens of scripts_verify / teams_verify warnings; this shows + the causes. Fix a group with scripts_rename(kind=rename_kind, from_name=missing, to_name=...) + which updates scripts and teams. exists_for_another_player marks scripts that were not copied + along with the teams or scripts using them.""" + return wb.call("scripts.missing", player=player, limit=limit, suggest=suggest) + + +@mcp.tool() +def scripts_copy(name: str, new_name: str, player: str | None = None, group: str | None = None, + script_index: int | None = None, to_player: str | None = None, to_group: str | None = None, + index: int | None = None) -> dict: + """Duplicates a script under a new name. By default the copy goes to the end of the same list; + to_player / to_group ("" = top level) / index place it elsewhere.""" + return wb.call("scripts.copy", name=name, new_name=new_name, player=player, group=group, + script_index=script_index, to_player=to_player, to_group=to_group, index=index) + + +@mcp.tool() +def scripts_clone_player(from_player: str, to_player: str, replace: dict[str, str] | None = None, + existing: Literal["fail", "replace", "append"] = "fail", include_teams: bool = True, + dry_run: bool = False) -> dict: + """Copies every script, group and team of one player to another player, the way a new + faction's AI is derived from an existing one. `replace` is an ordered {"old text": "new text"} + list of substring replacements applied to script, group and team names, to every name inside + script parameters (teams, scripts, object types, flags, upgrades ...) and to the teams' string + properties, e.g. {"SCM ": "XYZ ", "AmericaTankCrusader": "XyzTankLion"}. Team names are unique + per map, so the replacements must change every team name (it fails otherwise and names some). + Player parameters naming the source player are switched to the target. existing: what to do + if the target already has scripts (replace also removes its teams). Returns counts, how often + each replacement hit, and how many warnings scripts_verify / teams_verify now give for the + target: those are the references still to adapt. dry_run reports without changing anything. + One undo step.""" + return wb.call("scripts.clone_player", **{"from": from_player}, to=to_player, replace=replace, existing=existing, + include_teams=include_teams, dry_run=dry_run) + + +@mcp.tool() +def scripts_rename(kind: str, from_name: str, to_name: str, player: str | None = None) -> dict: + """Renames something together with every reference to it. kind: + - "script" or "group": the script/group and all script parameters and team properties + (production condition, on-create, hooks ...) naming it. Without player it is done for every + player that has one, each within its own scripts and teams. + - "team": the team and all team parameters in scripts. Fails if map objects belong to it. + - a parameter type holding a name, e.g. FLAG, COUNTER, OBJECT_TYPE, UPGRADE, WAYPOINT, + TRIGGER_AREA, SCIENCE: every parameter of that type with that exact value; for OBJECT_TYPE + and WAYPOINT also the teams' unit types and waypoints. + - "script_reference" / "team_reference": only the references, for names that do not exist + (see scripts_missing); nothing has to be named from_name. + Returns how many references changed. One undo step.""" + return wb.call("scripts.rename", kind=kind, **{"from": from_name}, to=to_name, player=player) + + +@mcp.tool() +def scripts_delete(name: str, player: str | None = None, group: str | None = None, + script_index: int | None = None) -> dict: + """Deletes a script.""" + return wb.call("scripts.delete", name=name, player=player, group=group, script_index=script_index) + + +@mcp.tool() +def scripts_move(name: str, player: str | None = None, group: str | None = None, + script_index: int | None = None, to_player: str | None = None, to_group: str | None = None, + index: int | None = None) -> dict: + """Moves a script. With only index it changes position in its list (scripts run top to bottom). + to_group moves it into that group, to_group "" out of its group to the player's top level, + to_player to another player. index defaults to the end.""" + return wb.call("scripts.move", name=name, player=player, group=group, script_index=script_index, + to_player=to_player, to_group=to_group, index=index) + + +@mcp.tool() +def scripts_add_group(player: str, name: str, active: bool = True, subroutine: bool = False, + index: int | None = None) -> dict: + """Adds a script group (folder) to a player. An inactive group disables all its scripts.""" + return wb.call("scripts.add_group", player=player, name=name, active=active, subroutine=subroutine, + index=index) + + +@mcp.tool() +def scripts_update_group(name: str, player: str | None = None, new_name: str | None = None, + active: bool | None = None, subroutine: bool | None = None, + index: int | None = None, to_player: str | None = None) -> dict: + """Renames a script group, changes its flags, its position (index) or its player (to_player).""" + return wb.call("scripts.update_group", name=name, player=player, new_name=new_name, active=active, + subroutine=subroutine, index=index, to_player=to_player) + + +@mcp.tool() +def scripts_delete_group(name: str, player: str | None = None, delete_scripts: bool = False) -> dict: + """Deletes a script group. A group that still holds scripts needs delete_scripts=true.""" + return wb.call("scripts.delete_group", name=name, player=player, delete_scripts=delete_scripts) + + +@mcp.tool() +def scripts_list_actions(filter: str | None = None, limit: int = 50, offset: int = 0) -> dict: + """Script action types: `type` (the name to use), `ui_name` (the editor's menu path), `params` + (parameter types in order), `sentence` (how the editor words it) and `help`. There are several + hundred, so search with filter (matches type, ui_name and help), e.g. "TEAM_ATTACK" or "timer".""" + return wb.call("scripts.list_actions", filter=filter, limit=limit, offset=offset) + + +@mcp.tool() +def scripts_list_conditions(filter: str | None = None, limit: int = 50, offset: int = 0) -> dict: + """Script condition types, like scripts_list_actions.""" + return wb.call("scripts.list_conditions", filter=filter, limit=limit, offset=offset) + + +@mcp.tool() +def scripts_list_values(param_type: str, filter: str | None = None, limit: int = 100, offset: int = 0) -> dict: + """The values a parameter type accepts, as the script editor's dropdown offers them: e.g. + UPGRADE, SCIENCE, SPECIAL_POWER, COMMAND_BUTTON, COMMANDBUTTON_ALL_ABILITIES, OBJECT_TYPE, + OBJECT_TYPE_LIST, KIND_OF_PARAM, OBJECT_STATUS, FACTION_NAME, SOUND, MUSIC, DIALOG, and the + names this map defines (TEAM, SIDE, SCRIPT, SCRIPT_SUBROUTINE, FLAG, COUNTER, WAYPOINT, + TRIGGER_AREA, ATTACK_PRIORITY_SET). filter is a substring; page with limit/offset. Types + without a list (INT, REAL, COORD3D, ...) return `free_form`, how to write the value. Look + names up here instead of guessing them.""" + return wb.call("scripts.list_values", param_type=param_type, filter=filter, limit=limit, offset=offset) + + +@mcp.tool() +def scripts_verify(player: str | None = None, name: str | None = None, summary: bool = False) -> dict: + """Runs the script editor's checks on all scripts (or one player's, or one script): parameters + that name a script, team, player, waypoint, area, unit, object type, sound etc. that does not + exist. Also lists script names a player uses more than once (the same name under different + players is normal, e.g. in AI script sets), and `team_warnings` from teams_verify (not when + checking a single script). Takes a few seconds on maps with thousands of scripts. + summary=true returns each distinct warning once with a count and the players it occurs for + (most frequent first) instead of one entry per place; use it on big sets, then scripts_missing + for the causes.""" + return wb.call("scripts.verify", player=player, name=name, summary=summary) + + +@mcp.tool() +def scripts_export(path: str, player: str | None = None, group: str | None = None, name: str | None = None, + include_players: bool | None = None, include_waypoints: bool = False, include_areas: bool = False, + include_units: bool = False, overwrite: bool = False, backup: bool = True) -> dict: + """Writes a script file (.scb) like the script editor's Export. Without a selection: all + scripts of all players, with the players and all teams. This is how an AI script set such as + SkirmishScripts.scb is produced. With player, group or name: only that player's scripts, + that group or that script, as one list that scripts_import puts into a player of choice, with + the teams those scripts refer to. Map data goes along on request, for moving map scripts to + another map: include_waypoints (all waypoints with their links; for a selection those its + scripts name, also by path label), include_areas (trigger areas, same rule; never water) and + include_units (the named units scripts refer to and the units of the teams they name). AI + script sets need none of these. An existing file is only replaced with overwrite=true, and + then a copy named .bak--