diff --git a/.gitignore b/.gitignore index c8d724bcaef..7ae416f7068 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !.gitattributes !.github !.gitmodules +!.mcp.json *.user *.ncb @@ -51,7 +52,6 @@ thumbs.db ## AI assistant config (user-specific) /.claude/ -/CLAUDE.md ## IntelliJ, CLion, etc. /.idea @@ -66,4 +66,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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..5bfb44be414 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,157 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this fork is + +A **modding-focused fork** of TheSuperHackers/GeneralsGameCode, the community port of the *Command & Conquer: Generals* / *Zero Hour* engine from VS6/C++98 to VS2022/C++20. + +**This fork only cares about Zero Hour (`GeneralsMD/`).** Treat the original Generals (`Generals/`) as out of scope unless explicitly asked. Upstream rules say "Zero Hour changes take precedence; mirror to Generals" — here, skip the mirror step. `Core/` holds the shared engine and must be considered for most changes. + +**Game data INIs** (Object/Weapon/OCL/etc. that configure the modules this engine parses) live outside the repo at `F:\Operation-Firestorm-2.0\Data\INI`. Read/search there when a task involves how units, weapons, or modules are configured in-game. + +## Build + +This fork builds with the modern VS2022 toolchain (not VS6). The relevant preset is in `CMakeUserPresets.json`: + +```bash +cmake --preset win32-modding-zh # configure (Release + RTS_DEBUG_LOGGING + RTS_BUILD_OPTION_DEBUG + stacktrace + crashing) +cmake --build build/win32 --preset w32-modding-zh # builds targets: z_generals, z_worldbuilder +``` + +- `win32-modding-zh` writes to `build/win32` and enables in-game logging/asserts/stacktraces for mod debugging. +- Stock presets also exist: `win32` (clean Release), `win32-debug`, `win32-vcpkg` (uses vcpkg toolchain for zlib/ffmpeg), `unix`, `mingw-w64-i686`, plus the `vc6*` family used upstream for **retail replay compatibility** (not needed for modding work). +- `compile_commands.json` is exported (`CMAKE_EXPORT_COMPILE_COMMANDS=ON`) under the build dir. + +### Verifying a change compiles + +Use the **"BUILD ZH AND WB FOR MODDING"** preset (`w32-modding-zh`). To verify, build only what the change touches: +- Build `z_generals` (`generalszh.exe`) for normal engine/game changes. +- Build `z_worldbuilder` (`worldbuilder.exe`) only for WorldBuilder-specific changes. + +```bash +cmake --build build/win32 --preset w32-modding-zh --target z_generals +``` + +Run cmake/ninja from a **VS dev shell** (`vcvarsall.bat amd64_x86`), not a plain PowerShell/Bash session, or the build fails to launch the compiler. If a configured `build/win32` errors with `CreateProcess failed: The system cannot find the file specified`, reconfigure (`cmake --preset win32-modding-zh`) from that dev shell to regenerate compiler paths. + +### Targets / output names + +Zero Hour targets are prefixed `z_` (Generals uses `g_`): +- `z_generals` → `generalszh.exe` (the game) +- `z_worldbuilder` → `worldbuilder.exe` (map editor) +- Other ZH tools: `z_imagepacker`, `z_mapcachebuilder`, `z_guiedit`, `z_w3dview`, etc. + +### Key build options (`cmake/config-build.cmake`) + +`RTS_BUILD_ZEROHOUR` (default ON), `RTS_BUILD_GENERALS` (turn OFF to skip original Generals), `RTS_BUILD_OPTION_DEBUG`, `RTS_BUILD_OPTION_PROFILE`, `RTS_DEBUG_LOGGING`, `RTS_DEBUG_CRASHING`, `RTS_DEBUG_STACKTRACE`, `RTS_BUILD_OPTION_FFMPEG`. Debug defines: `RTS_DEBUG WWDEBUG DEBUG`; Release: `RTS_RELEASE NDEBUG`. + +## Architecture + +Three source roots, each a CMake subtree: + +- **`Core/`** — engine + libraries shared by both games. Don't duplicate here what already lives in Core. + - `Core/GameEngine/` — base engine. + - `Core/GameEngineDevice/` — platform/render backends (DirectX 8). + - `Core/Libraries/Source/WWVegas/` — Westwood "WW" graphics/math/3D framework (WW3D2, WWMath, WWLib...). Foundational; touched indirectly by most rendering work. + - `Core/Libraries/Source/Compression/`, `debug/`, `profile/`. + - `Core/Tools/` — shared dev tools. +- **`GeneralsMD/Code/`** — Zero Hour specific code. **Primary work area.** +- **`Generals/Code/`** — original Generals. Ignore for this fork. + +### Engine split (the big picture) + +Inside `GameEngine/Source/`: +- **`GameLogic/`** — simulation, rules, game state. Deterministic; this is what must stay in sync across the network and across replays. +- **`GameClient/`** — rendering, UI, input, audio — everything player-facing and machine-local. +- **`Common/`** — shared interfaces/types between Logic and Client (`GameEngine/Include/Common/`). +- **`GameNetwork/`** — netcode. +- `GameEngineDevice/Source/{W3DDevice,Win32Device}/` — concrete implementations of Client abstractions on W3D (DirectX) and Win32. + +The Logic/Client separation is load-bearing: gameplay-affecting logic must be deterministic (replays/multiplayer depend on it). Keep rendering/UI out of `GameLogic`. Entry point: `GeneralsMD/Code/Main/WinMain.cpp`. + +## Code conventions + +- **Match surrounding legacy style.** Original code is C++98-flavored and deliberately simple. Prefer not to introduce modern C++ features unless they make the change clearly more robust/maintainable. Manual memory management (`new`/`delete`) is the norm. +- **Scope discipline.** Edits touch only the lines serving the change. Never mix a large refactor with a logic change in the same commit. +- **No `TheSuperHackers` change-marker comments.** That convention belongs to the upstream `TheSuperHackers/GeneralsGameCode` repo this was forked from — do not add those markers here. Use plain comments that explain *why* a change was made, only where the reason isn't obvious from the code. +- **Commit / PR titles** use Conventional Commits: `bugfix:`, `fix:`, `feat:`, `build:`, `perf:`, `refactor:`, `tweak:`, `chore:`, `ci:`, `docs:`, `style:`, `test:`, `unify:`. Optional scope, e.g. `bugfix(system): fix uninitialized memory in Get_OS_Info`. Present tense ("Fixes", not "Fixed"). +- When naming game units in docs/comments, spell out and prefix with faction (USA/China/GLA/Boss/Civilian), e.g. "USA Missile Defender" not "MD". + +## Testing + +No unit-test suite for gameplay. Correctness for engine changes is verified via **replay compatibility** (`GeneralsReplays/`): + +``` +generalszh.exe -jobs 4 -headless -replay subfolder/*.rep +``` + +Copy replays into `%USERPROFILE%/Documents/Command and Conquer Generals Zero Hour Data/Replays/` and maps into `.../Maps`. Exit code signals pass/fail. Note: upstream CI runs this against a **VC6 optimized, `RTS_BUILD_OPTION_DEBUG=OFF`** build for retail-byte compatibility — modding-preset builds are not retail-compatible and won't match retail replays. + +Running the game requires an installed copy of retail Generals/Zero Hour (assets). + +## Tooling / linting + +- `.clang-tidy` at root; custom checks in `scripts/clang-tidy-plugin/`. Run via `scripts/run-clang-tidy.py` against `compile_commands.json`. +- `scripts/cpp/` holds maintenance helpers (include-case fixes, whitespace cleanup, refactor scripts). +- `scripts/docker-build.sh` / `docker-install.sh --detect` for Linux builds + install to a detected game dir. + +## WorldBuilder MCP + +An MCP server that drives a running WorldBuilder: it edits the open map, its scripts and teams, and the AI data around them. Use its tools (`mcp__worldbuilder__*`) for map and script work instead of editing `.map` / `.scb` files by hand. Full tool reference: `scripts/worldbuilder-mcp/README.md`; every tool also documents itself. + +``` +Claude <-- stdio --> scripts/worldbuilder-mcp/server.py <-- TCP 127.0.0.1:47800 --> WorldBuilderZH.exe -mcp +``` + +### Running it + +- WorldBuilder must run **from the mod folder** (`F:\Operation-Firestorm-2.0`, it needs the game data): `WorldBuilderMCP.bat` there starts `WorldBuilderZH.exe -mcp -ignoreAsserts`. Without `-ignoreAsserts` an assert dialog blocks the bridge. +- The server is registered in the repo's `.mcp.json` and started through `uv`. New or changed tools appear only after Claude is **fully quit and restarted**; WorldBuilder must be restarted after rebuilding it. +- The bridge serves one client. If tools fail with "Cannot reach WorldBuilder", WorldBuilder is not running with `-mcp` (or hangs in a dialog). +- WorldBuilder and the game read INI data at startup: restart after changing game data. + +### Rules that apply to every edit + +- **World units**: 10 per terrain cell, (0,0) at the lower left of the playable area. Heights are raw 0..255 (times `height_map_scale`). Angles in degrees, counterclockwise, 0 = +x. Start with `map_info`. +- **Look names up, never guess**: `objects_list_templates`, `terrain_list_textures`, `roads_list_types`, `sides_list_factions`, `scripts_list_actions` / `scripts_list_conditions`, `scripts_list_values`, `teams_list_properties`. +- **Every edit is one undo step** (`edit_undo`); WorldBuilder keeps 15. Many script edits belong in one `scripts_batch` (one step, all or nothing). +- **Nothing is saved until `map_save`** (or `scripts_export` for a script file). Overwriting an existing file needs an explicit flag; ask before overwriting the user's files. +- Script edits fail while WorldBuilder's own script editor window is open. +- `view_screenshot` shows the result; use it to check terrain and object placement. + +### Map editing + +- **Terrain**: `terrain_generate` (presets), `terrain_brush`, `terrain_set_heights` / `terrain_import_heightmap`, `terrain_limit_slope`, `terrain_set_passability`; textures with `terrain_paint_texture`, `terrain_auto_texture`, `terrain_auto_blend`. +- **Whole-map operations**: `map_resize`, `map_transform`, `map_symmetrize` (mirror or rotate one part onto the rest), `map_replace`. +- **Objects**: `objects_place` / `objects_place_many` / `objects_scatter`, `objects_modify`, `objects_set_properties`. Owners are team names from `sides_list` (`team` is neutral). +- **Waypoints, areas, water, roads**: `waypoints_add` / `waypoints_link`, `polygons_add`, `roads_add`, `roads_route` (road that follows the terrain). +- **Skirmish maps**: `map_generate_starts` for `Player__Start` waypoints, then `ai_skirmish_setup` (perimeters, `Center` / `Flank` / `Backdoor` paths) and `ai_skirmish_check`. Plain `Center` labels without a player number are ignored by the AI. +- **Ships**: a map is meant for ships when its `map.ini` sets `EnableShips` (`map_info`: `ships_enabled`; `map_set_info` writes it). The AI then needs waypoints `Shipyard_`, the area `NavalPerimeter` and paths `Naval` / `NavalFlank`; `ai_skirmish_check` lists what is there. + +### Script and team editing + +- **Read**: `scripts_list` (start with `detail="groups"` on big sets), `scripts_get`, `scripts_find` (who uses a team, flag, object type ...), `teams_list`, `teams_get`. **Write**: `scripts_add`, `scripts_update`, `scripts_move`, `scripts_delete`, the group tools, `teams_set`, `teams_set_many`. +- A script is JSON: flags, `conditions` (a list of OR clauses, each a list of ANDed conditions), `actions`, `false_actions`. Parameters are given in the order of the action's parameter types. +- **Bulk work**: `scripts_copy`, `teams_copy`, `scripts_clone_player`, `scripts_rename` (renames with all references), `scripts_set_values`, `scripts_batch`, `scripts_dump` / `scripts_load` (readable JSON, good for diffs and backups). +- **Checks**: `scripts_verify` (what the editor would flag), `scripts_missing` (broken references by cause), `scripts_analyze` (dead logic), `scripts_compare`. +- **Understanding a set**: `scripts_build_order`, `scripts_trace`, `scripts_by_difficulty`, `teams_summary`, `scripts_report`. +- **Files**: `scripts_import` / `scripts_export` read and write `.scb`. The mod's AI is `Data\Scripts\SkirmishScripts.scb` (about 3400 scripts, 750 teams, one `Skirmish` player per faction): import it into a scratch map to work on it, and treat the installed file as the user's, to be overwritten only on request. + +### Writing and testing AI + +The pieces build on each other; files live in `worldbuilder_factions\` next to the WorldBuilder executable. + +1. **What a faction can do**: `faction_generate` (once, and after game data changes), then `faction_get` (`overview`, `tree`, `units`, `naval`, single types). Roles, slots and phases are guesses until the faction's `.overrides.yaml` has been reviewed; types marked `unavailable` cannot be built. +2. **What every AI shares**: `ai_framework_generate` / `ai_framework_get` (common scripts, flags, timers, team roster and hooks, object lists). +3. **What is outside the scripts**: `aidata_get` reads `AIData.ini`. `SKIRMISH_BUILD_BUILDING` only builds structures that have a place in the side's `SkirmishBuildList`; `SideInfo` holds gatherer counts, the base defense structure and the science skill sets. Edit with `buildlist_from_aidata` / `buildlist_export` and `aidata_set_side` (they write only when given a file). +4. **A new faction AI**: `ai_scaffold` derives it from a related faction's player (dry run first), using `faction_map`; then work through its `unmapped` list. +5. **Static check**: `faction_check` on the player finds teams and build orders the faction cannot fulfil. +6. **Play it**: `ai_match_run` lets the game play a headless AI-vs-AI skirmish and `ai_match_report` says what each side built, never built, which scripts and teams never ran and what went wrong. `scripts="open"` plays the scripts open in WorldBuilder and `ai_data=` a changed `AIData.ini`, without installing either. The same seed repeats a match exactly, so run before and after a change. + +### Working on the MCP itself + +- Bridge commands: `GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmd*.cpp` (handlers run on the UI thread, take and return `McpJson`, fail with `mcpFail`, are registered with `mcpRegisterCommand`). Edits go through an `Undoable` so that undo works. +- Server tools: `scripts/worldbuilder-mcp/server.py`; logic that needs no bridge is in `faction.py`, `framework.py`, `scaffold.py`, `aidata.py`, `match.py`. +- AI matches are an engine feature (`GameLogic/AIMatch.h`): `generalszh.exe -aiMatch -aiPlayers Faction:difficulty,... -aiMatchLog [-headless]`. +- After a change: build `z_worldbuilder`, copy `WorldBuilderZH.exe` to the mod folder, restart it with `-mcp -ignoreAsserts`, run `python scripts/worldbuilder-mcp/smoke_test.py` (it replaces the open map with a test map) and extend that test for new commands. diff --git a/Core/GameEngine/CMakeLists.txt b/Core/GameEngine/CMakeLists.txt index 8b6bb3b1094..2f4b8d5ddcd 100644 --- a/Core/GameEngine/CMakeLists.txt +++ b/Core/GameEngine/CMakeLists.txt @@ -117,6 +117,7 @@ set(GAMEENGINE_SRC # Include/Common/StackDump.h # Include/Common/StateMachine.h # Include/Common/StatsCollector.h + Include/Common/SciencePurchaseSlots.h Include/Common/STLTypedefs.h Include/Common/StreamingArchiveFile.h Include/Common/SubsystemInterface.h diff --git a/Core/GameEngine/Include/Common/SciencePurchaseSlots.h b/Core/GameEngine/Include/Common/SciencePurchaseSlots.h new file mode 100644 index 00000000000..ffb777bc51b --- /dev/null +++ b/Core/GameEngine/Include/Common/SciencePurchaseSlots.h @@ -0,0 +1,32 @@ +/* +** 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 . +*/ + +// SciencePurchaseSlots.h +// How many sciences the purchase screens offer per rank. Kept apart from the control bar so that +// game logic can size its own tables by it without pulling in the user interface. + +#pragma once + +enum { + MAX_PURCHASE_SCIENCE_RANK_1 = 7, + MAX_PURCHASE_SCIENCE_RANK_3 = 21, + MAX_PURCHASE_SCIENCE_RANK_8 = 7, + }; + +/// Every science a player can purchase: one per slot of the purchase screens. +enum { MAX_PURCHASE_SCIENCES = MAX_PURCHASE_SCIENCE_RANK_1 + MAX_PURCHASE_SCIENCE_RANK_3 + MAX_PURCHASE_SCIENCE_RANK_8 }; diff --git a/Core/GameEngine/Include/GameClient/ControlBar.h b/Core/GameEngine/Include/GameClient/ControlBar.h index 01b47928662..31df222e256 100644 --- a/Core/GameEngine/Include/GameClient/ControlBar.h +++ b/Core/GameEngine/Include/GameClient/ControlBar.h @@ -34,6 +34,7 @@ #include "Common/GameType.h" #include "Common/Overridable.h" #include "Common/Science.h" +#include "Common/SciencePurchaseSlots.h" #include "GameClient/Color.h" // FORWARD REFERENCES ///////////////////////////////////////////////////////////////////////////// @@ -466,11 +467,6 @@ class CommandButton : public Overridable //------------------------------------------------------------------------------------------------- enum { MAX_COMMANDS_PER_SET = 32 }; // user interface max is 14 (but internally it's 18 for script only buttons!) enum { MAX_RIGHT_HUD_UPGRADE_CAMEOS = 9}; -enum { - MAX_PURCHASE_SCIENCE_RANK_1 = 7, - MAX_PURCHASE_SCIENCE_RANK_3 = 21, - MAX_PURCHASE_SCIENCE_RANK_8 = 7, - }; enum { MAX_STRUCTURE_INVENTORY_BUTTONS = 10 }; // there are this many physical buttons in "inventory" windows for structures enum { MAX_BUILD_QUEUE_BUTTONS = 9 };// physical button count for the build queue enum { MAX_SPECIAL_POWER_SHORTCUTS = 32}; diff --git a/Core/GameEngine/Source/Common/CommandLine.cpp b/Core/GameEngine/Source/Common/CommandLine.cpp index 772830f0f67..014e0ae8f23 100644 --- a/Core/GameEngine/Source/Common/CommandLine.cpp +++ b/Core/GameEngine/Source/Common/CommandLine.cpp @@ -448,6 +448,14 @@ Int parseReplay(char *args[], int num) return 1; } +/// A skirmish between AI players (see AIMatch.h, which reads the match options itself). Tools +/// start it while WorldBuilder or another game is open, so it runs as an instance of its own. +Int parseAIMatch(char *args[], int num) +{ + rts::ClientInstance::setMultiInstance(TRUE); + return (num > 1) ? 2 : 1; +} + Int parseJobs(char *args[], int num) { if (num > 1) @@ -1136,6 +1144,9 @@ static CommandLineParam paramsForStartup[] = // You can also include wildcards. The file must be in the replay folder or in a subfolder. { "-replay", parseReplay }, + // Run a skirmish between AI players and log it. The map name follows. + { "-aiMatch", parseAIMatch }, + // TheSuperHackers @feature helmutbuhler 23/05/2025 // Simulate each replay in a separate process and use 1..N processes at the same time. // (If you have 4 cores, call it with -jobs 4) diff --git a/Core/GameEngine/Source/Common/System/Debug.cpp b/Core/GameEngine/Source/Common/System/Debug.cpp index 815ec558ea8..7ac5e7209e0 100644 --- a/Core/GameEngine/Source/Common/System/Debug.cpp +++ b/Core/GameEngine/Source/Common/System/Debug.cpp @@ -156,7 +156,11 @@ inline Bool ignoringAsserts() if (TheGlobalData && TheGlobalData->m_headless) return true; #ifdef DEBUG_CRASHING + // Remembered, so that asserts raised during shutdown (after the global data is gone) are ignored too. + static Bool ignoreAssertsWasSet = false; if (TheGlobalData && TheGlobalData->m_debugIgnoreAsserts) + ignoreAssertsWasSet = true; + if (ignoreAssertsWasSet) return true; #endif 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/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 diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 06ece6e8613..76b320ebcc6 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -233,6 +233,7 @@ set(GAMEENGINE_SRC Include/GameLogic/AIGuardRetaliate.h # Include/GameLogic/AIPathfind.h Include/GameLogic/AIPlayer.h + Include/GameLogic/AIMatch.h Include/GameLogic/AISkirmishPlayer.h Include/GameLogic/AIStateMachine.h Include/GameLogic/AITNGuard.h @@ -830,6 +831,7 @@ set(GAMEENGINE_SRC Source/GameLogic/AI/AIGuardRetaliate.cpp # Source/GameLogic/AI/AIPathfind.cpp Source/GameLogic/AI/AIPlayer.cpp + Source/GameLogic/AI/AIMatch.cpp Source/GameLogic/AI/AISkirmishPlayer.cpp Source/GameLogic/AI/AIStates.cpp Source/GameLogic/AI/AITNGuard.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/Common/ProductionPrerequisite.h b/GeneralsMD/Code/GameEngine/Include/Common/ProductionPrerequisite.h index 8f5255a60ea..72c5a9493b7 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/ProductionPrerequisite.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/ProductionPrerequisite.h @@ -97,6 +97,12 @@ class ProductionPrerequisite Int getAllPossibleBuildFacilityTemplates(const ThingTemplate* tmpls[], Int maxtmpls) const; + // Read access to the raw prerequisite data, for tools. + Int getUnitPrereqCount() const { return (Int)m_prereqUnits.size(); } + const ThingTemplate* getNthUnitPrereq(Int i) const { return m_prereqUnits[i].unit; } ///< null if the name was not found + Bool isNthUnitPrereqOrWithPrevious(Int i) const { return (m_prereqUnits[i].flags & UNIT_OR_WITH_PREV) != 0; } + const ScienceVec& getSciencePrereqs() const { return m_prereqSciences; } + private: enum diff --git a/GeneralsMD/Code/GameEngine/Include/Common/Science.h b/GeneralsMD/Code/GameEngine/Include/Common/Science.h index cba2a18b078..6f6f27ed53f 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/Science.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/Science.h @@ -124,6 +124,16 @@ class ScienceStore : public SubsystemInterface // do not use it in RTS! std::vector friend_getScienceNames() const; + /// The sciences that must be owned before this one can be purchased. For tools. + Bool friend_getPrereqSciences(ScienceType st, ScienceVec& prereqs) const + { + const ScienceInfo* info = findScienceInfo(st); + if (info == nullptr) + return false; + prereqs = info->m_prereqSciences; + return true; + } + private: diff --git a/GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h b/GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h index ab129333e4a..9a055bd785f 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h @@ -54,6 +54,9 @@ //----------------------------------------------------------------------------- #include "Common/Snapshot.h" +#include +#include + //----------------------------------------------------------------------------- // FORWARD REFERENCES ///////////////////////////////////////////////////////// //----------------------------------------------------------------------------- @@ -93,6 +96,9 @@ class ScoreKeeper : public Snapshot Int getTotalFactionBuildingsCaptured() { return m_totalFactionBuildingsCaptured; } Int getTotalObjectsBuilt( const ThingTemplate *pTemplate ); // get a count of objects built matching a specific thing template + /// Everything built and lost so far, by template name. For logs and tools. + void friend_countObjects( std::map& built, std::map& lost ) const; + // for battle honor calculation. done once at the end of each online game Int getTotalUnitsBuilt( KindOfMaskType validMask, KindOfMaskType invalidMask ); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/ThingTemplate.h b/GeneralsMD/Code/GameEngine/Include/Common/ThingTemplate.h index 74559909805..60ce98776c8 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/ThingTemplate.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/ThingTemplate.h @@ -628,6 +628,7 @@ class ThingTemplate : public Overridable /// Used only by Skirmish AI. Everyone else should call calcCostToBuild. Int friend_getBuildCost() const { return m_buildCost; } + Real friend_getBuildTime() const { return m_buildTime; } ///< seconds, before any player modifiers const AsciiString& getDefaultOwningSide() const { return m_defaultOwningSide; } @@ -665,6 +666,7 @@ class ThingTemplate : public Overridable AsciiString getUpgradeCameoName( Int n)const{ return m_upgradeCameoUpgradeNames[n]; } const WeaponTemplateSetVector& getWeaponTemplateSets() const {return m_weaponTemplateSets;} + const ArmorTemplateSetVector& getArmorTemplateSets() const {return m_armorTemplateSets;} Int getMaxPathFindingCellRadius(void) const { return static_cast(m_maxPathfindingCellRadius); }; @@ -815,6 +817,7 @@ class ThingTemplate : public Overridable Bool m_isForbidden; ///< useful when overriding in .ini Bool m_armorCopiedFromDefault; Bool m_weaponsCopiedFromDefault; + Bool m_prereqsCopiedFromExtended; ///< prerequisites came with ObjectExtend; the object's own replace them // ---- Byte-sized things Byte m_radarPriority; ///< does object appear on radar, and if so at what priority diff --git a/GeneralsMD/Code/GameEngine/Include/Common/Upgrade.h b/GeneralsMD/Code/GameEngine/Include/Common/Upgrade.h index fa4bfe89549..6c88e6c3304 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/Upgrade.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/Upgrade.h @@ -183,6 +183,8 @@ class UpgradeTemplate : public MemoryPoolObject AcademyClassificationType getAcademyClassificationType() const { return m_academyClassificationType; } const Bool isSilentCompletion(void) const { return m_silentCompletion; } + Real friend_getBuildTime() const { return m_buildTime; } ///< seconds, before any player modifiers + Int friend_getBuildCost() const { return m_cost; } /// inventory pictures void cacheButtonImage(); diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/AI.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/AI.h index 8bd3792d966..da34621ae73 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/AI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/AI.h @@ -34,6 +34,7 @@ #include "Common/GameType.h" #include "GameLogic/Damage.h" #include "Common/STLTypedefs.h" +#include "Common/SciencePurchaseSlots.h" class AIGroup; class AttackPriorityInfo; @@ -88,7 +89,8 @@ enum // If we want to consider the inner radius, we will specify guard inner. Otherwise, use guard outer. AI_VISIONFACTOR_GUARDINNER = 0x04, }; -enum {MAX_AI_UPGRADES = 20}; +// A skill set can list every science a player can purchase. +enum {MAX_AI_UPGRADES = MAX_PURCHASE_SCIENCES}; typedef struct { Int m_numSkills; diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/AIMatch.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/AIMatch.h new file mode 100644 index 00000000000..653cfb8776c --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/AIMatch.h @@ -0,0 +1,133 @@ +/* +** 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 . +*/ + +// AIMatch.h +// A skirmish between AI players that is started from the command line, watched by an observer +// and written down: what every player built, bought and lost, which of its scripts ran and what +// the script engine complained about. Tools use the log to judge AI scripts without anyone +// watching the match. +// +// -aiMatch map name or part of its path; starts the match +// -aiPlayers Faction:difficulty[:team], comma separated, e.g. +// FactionChina:hard,FactionGLA:hard (difficulty easy|normal|hard) +// -aiMatchLog where the log goes (JSON, one record per line) +// -aiMatchMinutes game time after which the match is stopped (default 30) +// -aiMatchInterval game time between snapshots (default 10) +// -aiMatchCash starting cash (default: the game's) +// -aiMatchScripts AI scripts to use instead of Data\Scripts\SkirmishScripts.scb +// -aiMatchAIData an AIData.ini read on top of the game's, to try out changes to it +// -seed random seed, for a repeatable match +// -headless no window, graphics or sound: only the game logic runs, as fast as it can + +#pragma once + +#include "Common/AsciiString.h" +#include "Common/GameType.h" + +#include +#include +#include +#include +#include + +class Player; + +class AIMatch +{ +public: + /// Reads the command line; returns the match if one was asked for. + static AIMatch *createFromCommandLine(); + + ~AIMatch(); + + /// Sets the skirmish up and asks the game logic to start it. + void start(); + + /// Called once per logic frame. + void update(); + + /// Runs the match without the game client, as replays are simulated. Returns the process exit code. + Int runHeadless(); + + /// A script ran its actions (or its false actions). + void scriptFired(const AsciiString &scriptName, Bool conditionsTrue); + + /// The script engine or the AI reported something. + void scriptMessage(const AsciiString &text); + + /// The player whose AI is at work, so that what it reports can be put down to it. Null when none is. + void setAIPlayer(Player *player) { m_aiPlayer = player; } + + /// The script file the skirmish players get their AI from; empty for the game's own. + const AsciiString &getScriptFile() const { return m_scriptFile; } + +private: + struct Entrant + { + AsciiString faction; + Int difficulty; ///< SlotState of the AI + Int team; ///< -1 for none + }; + + struct ScriptUse + { + Int count; + UnsignedInt first; + UnsignedInt last; + }; + + struct PlayerState + { + std::set sciences; + std::set upgrades; + std::set teams; + Bool alive; + }; + + AIMatch(); + + void fail(const char *reason); + void writeStart(); + void writeSnapshot(); + void scanTeams(); + void finish(const char *reason); + void writePlayerScore(Player *player); + std::vector matchPlayers() const; + Player *scriptPlayer(); + + AsciiString m_map; + AsciiString m_logPath; + AsciiString m_scriptFile; + AsciiString m_aiDataFile; + std::vector m_entrants; + UnsignedInt m_maxFrames; + UnsignedInt m_snapshotFrames; + Int m_startingCash; + + FILE *m_log; + Bool m_started; + Bool m_finished; + Bool m_failed; + std::map m_state; ///< by player index + std::map > m_scripts; ///< by player name, then script name + std::map, ScriptUse> m_messages; ///< by player name and text + Player *m_aiPlayer; + Bool m_askingForPlayer; +}; + +extern AIMatch *TheAIMatch; ///< null unless a match was asked for on the command line diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/Armor.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/Armor.h index 5d64c60c032..0a537d2a8a8 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/Armor.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/Armor.h @@ -115,6 +115,9 @@ class ArmorStore : public SubsystemInterface const ArmorTemplate* findArmorTemplate(const AsciiString& name) const; const ArmorTemplate* findArmorTemplate(const char* name) const; + /// Name of a template of this store, or an empty string. Slow; for tools. + AsciiString friend_findArmorTemplateName(const ArmorTemplate* tmpl) const; + inline Armor makeArmor(const ArmorTemplate *tmpl) const { return Armor(tmpl); // my, that was easy diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/Locomotor.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/Locomotor.h index 81c6a383c4e..47cd1e871d2 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/Locomotor.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/Locomotor.h @@ -139,6 +139,11 @@ class LocomotorTemplate : public Overridable void friend_setName(const AsciiString& n) { m_name = n; } + // Read access for tools. + const AsciiString& friend_getName() const { return m_name; } + Real friend_getMaxSpeed() const { return m_maxSpeed; } ///< distance per logic frame + LocomotorSurfaceTypeMask friend_getSurfaces() const { return m_surfaces; } + void validate(); protected: diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/Weapon.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/Weapon.h index 61c507e1a64..e0a2ed0f8bb 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/Weapon.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/Weapon.h @@ -433,6 +433,8 @@ class WeaponTemplate : public MemoryPoolObject WeaponPrefireType getPrefireType() const { return m_prefireType; } Bool getAutoReloadsClip() const { return m_reloadType == AUTO_RELOAD; } Int getClipSize() const { return m_clipSize; } + Int getMinDelayBetweenShots() const { return m_minDelayBetweenShots; } ///< frames, without bonus or random spread + Int getMaxDelayBetweenShots() const { return m_maxDelayBetweenShots; } Int getContinuousFireOneShotsNeeded() const { return m_continuousFireOneShotsNeeded; } Int getContinuousFireTwoShotsNeeded() const { return m_continuousFireTwoShotsNeeded; } UnsignedInt getContinuousFireCoastFrames() const { return m_continuousFireCoastFrames; } diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index 936b2b9c002..ee6eedba717 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp @@ -104,6 +104,7 @@ #include "GameClient/Drawable.h" #include "GameClient/GUICallbacks.h" +#include "GameLogic/AIMatch.h" #include "GameNetwork/NetworkInterface.h" #include "GameNetwork/WOLBrowser/WebBrowser.h" #include "GameNetwork/LANAPI.h" @@ -283,6 +284,9 @@ GameEngine::~GameEngine() delete TheSkirmishGameInfo; TheSkirmishGameInfo = nullptr; + delete TheAIMatch; + TheAIMatch = nullptr; + delete TheChallengeGameInfo; TheChallengeGameInfo = nullptr; @@ -739,6 +743,13 @@ void GameEngine::init() } } + // A skirmish between AI players, asked for on the command line. + TheAIMatch = AIMatch::createFromCommandLine(); + if (TheAIMatch) + { + TheAIMatch->start(); + } + // if (TheMapCache && TheGlobalData->m_shellMapOn) { diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp index ed94ec7bf54..02a15506176 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameMain.cpp @@ -31,6 +31,7 @@ #include "Common/FramePacer.h" #include "Common/GameEngine.h" #include "Common/ReplaySimulation.h" +#include "GameLogic/AIMatch.h" /** @@ -49,6 +50,10 @@ Int GameMain() { exitcode = ReplaySimulation::simulateReplays(TheGlobalData->m_simulateReplays, TheGlobalData->m_simulateReplayJobs); } + else if (TheAIMatch && TheGlobalData->m_headless) + { + exitcode = TheAIMatch->runHeadless(); + } else { // run it diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp index e81094e3900..7a07b8f0a59 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/Player.cpp @@ -80,6 +80,7 @@ #include "GameClient/GameText.h" #include "GameLogic/AI.h" +#include "GameLogic/AIMatch.h" #include "GameLogic/AIPathfind.h" #include "GameLogic/AISkirmishPlayer.h" #include "GameLogic/ExperienceTracker.h" @@ -702,7 +703,13 @@ void Player::addToPriorityBuildList(AsciiString templateName, Coord3D *pos, Real void Player::update() { if (m_ai) + { + if (TheAIMatch) + TheAIMatch->setAIPlayer(this); m_ai->update(); + if (TheAIMatch) + TheAIMatch->setAIPlayer(nullptr); + } // Allow the teams this player owns to update themselves. for( PlayerTeamList::iterator it = m_playerTeamPrototypes.begin(); it != m_playerTeamPrototypes.end(); ++it ) @@ -1591,7 +1598,13 @@ void Player::onUnitCreated( Object *factory, Object *unit ) // ai notification callback if( m_ai ) + { + if (TheAIMatch) + TheAIMatch->setAIPlayer(this); m_ai->onUnitProduced( factory, unit ); + if (TheAIMatch) + TheAIMatch->setAIPlayer(nullptr); + } } @@ -1699,7 +1712,13 @@ void Player::onStructureConstructionComplete(Object* builder, Object* structure, // ai notification callback if (m_ai) + { + if (TheAIMatch) + TheAIMatch->setAIPlayer(this); m_ai->onStructureProduced(builder, structure); + if (TheAIMatch) + TheAIMatch->setAIPlayer(nullptr); + } // the GUI needs to re-evaluate the information being displayed to the user now if (TheControlBar) diff --git a/GeneralsMD/Code/GameEngine/Source/Common/RTS/ScoreKeeper.cpp b/GeneralsMD/Code/GameEngine/Source/Common/RTS/ScoreKeeper.cpp index 2c373ed1794..be33b059ec7 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/RTS/ScoreKeeper.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/RTS/ScoreKeeper.cpp @@ -165,6 +165,21 @@ Int ScoreKeeper::getTotalUnitsBuilt( KindOfMaskType validMask, KindOfMaskType in return count; } +void ScoreKeeper::friend_countObjects( std::map& built, std::map& lost ) const +{ + for (ObjectCountMap::const_iterator it = m_objectsBuilt.begin(); it != m_objectsBuilt.end(); ++it) + { + if (it->first && it->second > 0) + built[it->first->getName().str()] += it->second; + } + for (ObjectCountMap::const_iterator it = m_objectsLost.begin(); it != m_objectsLost.end(); ++it) + { + if (it->first && it->second > 0) + lost[it->first->getName().str()] += it->second; + } +} + +//------------------------------------------------------------------------------------------------- Int ScoreKeeper::getTotalObjectsBuilt( const ThingTemplate *pTemplate ) { Int count = 0; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Thing/ThingTemplate.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Thing/ThingTemplate.cpp index 40a0a78f5ff..cf941c7ef90 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Thing/ThingTemplate.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Thing/ThingTemplate.cpp @@ -747,8 +747,10 @@ void ThingTemplate::parsePrerequisites( INI* ini, void *instance, void *store, c { nullptr, nullptr, nullptr, 0 } }; - if (ini->getLoadType() == INI_LOAD_CREATE_OVERRIDES) + // An extended object that states prerequisites means those, not the ones of the object it extends in addition. + if (ini->getLoadType() == INI_LOAD_CREATE_OVERRIDES || self->m_prereqsCopiedFromExtended) { + self->m_prereqsCopiedFromExtended = FALSE; self->m_prereqInfo.clear(); } @@ -1125,6 +1127,7 @@ ThingTemplate::ThingTemplate() : //m_defaultOwningSide = ""; // unnecessary m_isBuildFacility = FALSE; m_isPrerequisite = FALSE; + m_prereqsCopiedFromExtended = FALSE; m_placementViewAngle = 0.0f; m_factoryExitWidth = 0.0f; m_factoryExtraBibWidth = 0.0f; @@ -1354,6 +1357,7 @@ void ThingTemplate::setCopiedFromDefaultExtended() // cannot be removed with RemoveModule m_armorCopiedFromDefault = true; m_weaponsCopiedFromDefault = true; + m_prereqsCopiedFromExtended = true; //m_behaviorModuleInfo.setCopiedFromDefault(true); //m_drawModuleInfo.setCopiedFromDefault(true); //m_clientUpdateModuleInfo.setCopiedFromDefault(true); diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIMatch.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIMatch.cpp new file mode 100644 index 00000000000..a842ba66530 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIMatch.cpp @@ -0,0 +1,685 @@ +/* +** 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 . +*/ + +// AIMatch.cpp +// A skirmish between AI players, started from the command line and written to a log. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#include "GameLogic/AIMatch.h" + +#include "Common/Energy.h" +#include "Common/FileSystem.h" +#include "Common/GameEngine.h" +#include "Common/INI.h" +#include "Common/GlobalData.h" +#include "Common/KindOf.h" +#include "Common/MessageStream.h" +#include "Common/Money.h" +#include "Common/NameKeyGenerator.h" +#include "Common/Player.h" +#include "Common/PlayerList.h" +#include "Common/PlayerTemplate.h" +#include "Common/RandomValue.h" +#include "Common/Science.h" +#include "Common/ScoreKeeper.h" +#include "Common/Team.h" +#include "Common/ThingTemplate.h" +#include "Common/Upgrade.h" +#include "GameClient/MapUtil.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Object.h" +#include "GameLogic/ScriptEngine.h" +#include "GameNetwork/GameInfo.h" + +#include + +AIMatch *TheAIMatch = nullptr; + +namespace +{ + +const UnsignedInt TEAM_SCAN_FRAMES = LOGICFRAMES_PER_SECOND; + +/// A string as a JSON string literal. +std::string quoted(const char *text) +{ + std::string out = "\""; + for (const char *c = text; *c; c++) { + if (*c == '"' || *c == '\\') { + out += '\\'; + out += *c; + } else if ((unsigned char)*c < 0x20) { + char buffer[8]; + sprintf(buffer, "\\u%04x", (unsigned char)*c); + out += buffer; + } else { + out += *c; + } + } + return out + "\""; +} + +std::string quoted(const AsciiString &text) +{ + return quoted(text.str()); +} + +std::string playerName(Player *player) +{ + return TheNameKeyGenerator->keyToName(player->getPlayerNameKey()).str(); +} + +void writeCounts(FILE *file, const std::map &counts) +{ + fputc('{', file); + for (std::map::const_iterator it = counts.begin(); it != counts.end(); ++it) { + fprintf(file, "%s%s:%d", it == counts.begin() ? "" : ",", quoted(it->first.c_str()).c_str(), it->second); + } + fputc('}', file); +} + +Int slotStateFor(const AsciiString &difficulty) +{ + if (difficulty.compareNoCase("easy") == 0) return SLOT_EASY_AI; + if (difficulty.compareNoCase("normal") == 0 || difficulty.compareNoCase("medium") == 0) return SLOT_MED_AI; + if (difficulty.compareNoCase("hard") == 0 || difficulty.compareNoCase("brutal") == 0) return SLOT_BRUTAL_AI; + return -1; +} + +const char *difficultyName(GameDifficulty difficulty) +{ + switch (difficulty) { + case DIFFICULTY_EASY: return "easy"; + case DIFFICULTY_NORMAL: return "normal"; + case DIFFICULTY_HARD: return "hard"; + default: return ""; + } +} + +/// What counts as a unit or structure of a player in the log. +Bool isArmyObject(const Object *object) +{ + if (object->isEffectivelyDead() || object->isKindOf(KINDOF_PROJECTILE)) { + return FALSE; + } + return object->isKindOf(KINDOF_STRUCTURE) || object->isKindOf(KINDOF_INFANTRY) || object->isKindOf(KINDOF_VEHICLE) + || object->isKindOf(KINDOF_AIRCRAFT); +} + +} // namespace + +//------------------------------------------------------------------------------------------------- +AIMatch::AIMatch() : + m_maxFrames(30 * 60 * LOGICFRAMES_PER_SECOND), + m_snapshotFrames(10 * LOGICFRAMES_PER_SECOND), + m_startingCash(0), + m_log(nullptr), + m_started(FALSE), + m_finished(FALSE), + m_failed(FALSE), + m_aiPlayer(nullptr), + m_askingForPlayer(FALSE) +{ +} + +//------------------------------------------------------------------------------------------------- +AIMatch::~AIMatch() +{ + if (m_log) { + fclose(m_log); + } +} + +//------------------------------------------------------------------------------------------------- +AIMatch *AIMatch::createFromCommandLine() +{ + // The options are read here and not with the other command line options, which both games share. + AIMatch *match = nullptr; + AsciiString players; + for (Int i = 1; i < __argc; i++) { + const char *arg = __argv[i]; + const char *value = (i + 1 < __argc) ? __argv[i + 1] : nullptr; + if (value == nullptr) { + continue; + } + if (stricmp(arg, "-aiMatch") == 0) { + match = match ? match : new AIMatch; + match->m_map = value; + } else if (stricmp(arg, "-aiPlayers") == 0) { + players = value; + } else if (match && stricmp(arg, "-aiMatchLog") == 0) { + match->m_logPath = value; + } else if (match && stricmp(arg, "-aiMatchMinutes") == 0) { + match->m_maxFrames = (UnsignedInt)(atof(value) * 60.0 * (Real)LOGICFRAMES_PER_SECOND); + } else if (match && stricmp(arg, "-aiMatchInterval") == 0) { + match->m_snapshotFrames = (UnsignedInt)(atof(value) * (Real)LOGICFRAMES_PER_SECOND); + } else if (match && stricmp(arg, "-aiMatchCash") == 0) { + match->m_startingCash = atoi(value); + } else if (match && stricmp(arg, "-aiMatchScripts") == 0) { + match->m_scriptFile = value; + } else if (match && stricmp(arg, "-aiMatchAIData") == 0) { + match->m_aiDataFile = value; + } + } + if (match == nullptr) { + return nullptr; + } + if (match->m_snapshotFrames < 1) { + match->m_snapshotFrames = 1; + } + if (match->m_logPath.isEmpty()) { + match->m_logPath = "AIMatch.jsonl"; + } + + // Faction:difficulty[:team], ... + AsciiString entry; + while (players.nextToken(&entry, ",")) { + Entrant entrant; + entrant.difficulty = SLOT_BRUTAL_AI; + entrant.team = -1; + AsciiString part; + if (entry.nextToken(&part, ":")) { + entrant.faction = part; + } + if (entry.nextToken(&part, ":")) { + entrant.difficulty = slotStateFor(part); + } + if (entry.nextToken(&part, ":")) { + entrant.team = atoi(part.str()); + } + match->m_entrants.push_back(entrant); + } + return match; +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::fail(const char *reason) +{ + if (m_log == nullptr) { + m_log = fopen(m_logPath.str(), "w"); + } + if (m_log) { + fprintf(m_log, "{\"t\":\"error\",\"reason\":%s}\n", quoted(reason).c_str()); + fflush(m_log); + } + m_finished = TRUE; + m_failed = TRUE; + TheGameEngine->setQuitting(TRUE); +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::start() +{ + m_log = fopen(m_logPath.str(), "w"); + if (m_log == nullptr) { + m_finished = TRUE; + m_failed = TRUE; + TheGameEngine->setQuitting(TRUE); + return; + } + + // The map: its key in the map cache, or the first multiplayer map whose path holds the name. + AsciiString mapKey = m_map; + mapKey.toLower(); + if (TheMapCache->find(mapKey) == TheMapCache->end()) { + AsciiString found; + for (MapCache::const_iterator it = TheMapCache->begin(); it != TheMapCache->end(); ++it) { + if (it->second.m_isMultiplayer && strstr(it->first.str(), mapKey.str()) != nullptr) { + found = it->first; + break; + } + } + if (found.isEmpty()) { + AsciiString reason; + reason.format("no multiplayer map matches '%s'", m_map.str()); + fail(reason.str()); + return; + } + mapKey = found; + } + const MapMetaData *mapData = TheMapCache->findMap(mapKey); + + // Files that stand in for the game's own, to try changes without installing them. + if (m_scriptFile.isNotEmpty() && !TheFileSystem->doesFileExist(m_scriptFile.str())) { + AsciiString reason; + reason.format("script file '%s' not found", m_scriptFile.str()); + fail(reason.str()); + return; + } + if (m_aiDataFile.isNotEmpty()) { + if (!TheFileSystem->doesFileExist(m_aiDataFile.str())) { + AsciiString reason; + reason.format("AIData file '%s' not found", m_aiDataFile.str()); + fail(reason.str()); + return; + } + // Read over what the game has loaded: its settings, side infos and build lists replace those of the same name. + INI ini; + ini.load(m_aiDataFile, INI_LOAD_OVERWRITE, nullptr); + } + + if (m_entrants.size() < 2) { + fail("-aiPlayers needs at least two players (Faction:difficulty,Faction:difficulty)"); + return; + } + if (mapData && (Int)m_entrants.size() > mapData->m_numPlayers) { + AsciiString reason; + reason.format("the map has %d start positions for %d players", mapData->m_numPlayers, (Int)m_entrants.size()); + fail(reason.str()); + return; + } + + if (TheSkirmishGameInfo == nullptr) { + TheSkirmishGameInfo = NEW SkirmishGameInfo; + } + TheSkirmishGameInfo->init(); + TheSkirmishGameInfo->clearSlotList(); + TheSkirmishGameInfo->reset(); + TheSkirmishGameInfo->setLocalIP(TheSkirmishGameInfo->getSlot(0)->getIP()); + TheSkirmishGameInfo->enterGame(); + + // The local player only watches. + UnicodeString observerName; + observerName.translate("Observer"); + GameSlot observer; + observer.setState(SLOT_PLAYER, observerName); + observer.setPlayerTemplate(PLAYERTEMPLATE_OBSERVER); + TheSkirmishGameInfo->setSlot(0, observer); + + for (size_t i = 0; i < m_entrants.size(); i++) { + const Entrant &entrant = m_entrants[i]; + Int templateIndex = -1; + for (Int t = 0; t < ThePlayerTemplateStore->getPlayerTemplateCount(); t++) { + const PlayerTemplate *playerTemplate = ThePlayerTemplateStore->getNthPlayerTemplate(t); + if (playerTemplate && (playerTemplate->getName().compareNoCase(entrant.faction.str()) == 0 + || playerTemplate->getSide().compareNoCase(entrant.faction.str()) == 0)) { + templateIndex = t; + break; + } + } + if (templateIndex < 0 || entrant.difficulty < 0) { + AsciiString reason; + reason.format("-aiPlayers entry %d: unknown faction '%s' or difficulty (easy, normal, hard)", (Int)i + 1, entrant.faction.str()); + fail(reason.str()); + return; + } + GameSlot slot; + slot.setState((SlotState)entrant.difficulty); + slot.setPlayerTemplate(templateIndex); + slot.setTeamNumber(entrant.team); + TheSkirmishGameInfo->setSlot((Int)i + 1, slot); + } + + const Int seed = TheGlobalData->m_fixedSeed >= 0 ? TheGlobalData->m_fixedSeed : (Int)GetTickCount(); + TheSkirmishGameInfo->setSeed(seed); + TheSkirmishGameInfo->setMap(mapKey); + TheSkirmishGameInfo->setMapCRC(mapData ? mapData->m_CRC : 0); + TheSkirmishGameInfo->setMapSize(mapData ? mapData->m_filesize : 0); + if (m_startingCash > 0) { + Money cash; + cash.init(); + cash.deposit(m_startingCash, FALSE); + TheSkirmishGameInfo->setStartingCash(cash); + } else { + TheSkirmishGameInfo->setStartingCash(TheGlobalData->m_defaultStartingCash); + } + + fprintf(m_log, "{\"t\":\"setup\",\"map\":%s,\"seed\":%d,\"max_frames\":%u,\"snapshot_frames\":%u,\"frames_per_second\":%d," + "\"scripts\":%s,\"ai_data\":%s}\n", + quoted(mapKey).c_str(), seed, m_maxFrames, m_snapshotFrames, (Int)LOGICFRAMES_PER_SECOND, + quoted(m_scriptFile).c_str(), quoted(m_aiDataFile).c_str()); + fflush(m_log); + + // From here on as the skirmish menu starts a game. + TheWritableGlobalData->m_mapName = mapKey; + TheWritableGlobalData->m_shellMapOn = FALSE; + TheSkirmishGameInfo->startGame(0); + InitRandom(seed); + + // Without the client nobody carries messages from the stream to the logic, so the message + // goes to the logic's command list directly, as it does when a replay is simulated. + GameMessage *msg; + if (TheGlobalData->m_headless) { + msg = newInstance(GameMessage)(GameMessage::MSG_NEW_GAME); + TheCommandList->appendMessage(msg); + } else { + msg = TheMessageStream->appendMessage(GameMessage::MSG_NEW_GAME); + } + msg->appendIntegerArgument(GAME_SKIRMISH); + msg->appendIntegerArgument(DIFFICULTY_NORMAL); + msg->appendIntegerArgument(0); + msg->appendIntegerArgument(1000); // no frame rate limit: the match runs as fast as the machine allows +} + +//------------------------------------------------------------------------------------------------- +Int AIMatch::runHeadless() +{ + // Only the logic is updated; the match ends itself in update(). + UnsignedInt framesNotInGame = 0; + while (!m_finished && !TheGameEngine->getQuitting()) { + TheGameLogic->UPDATE(); + if (TheGameLogic->isInGame()) { + framesNotInGame = 0; + } else if (++framesNotInGame > 300) { + fail(m_started ? "the game ended by itself" : "the game did not start"); + } + } + return m_failed ? 1 : 0; +} + +//------------------------------------------------------------------------------------------------- +std::vector AIMatch::matchPlayers() const +{ + std::vector players; + for (Int i = 0; i < ThePlayerList->getPlayerCount(); i++) { + Player *player = ThePlayerList->getNthPlayer(i); + if (player == nullptr || TheGameInfo == nullptr) { + continue; + } + const Int slotIndex = ThePlayerList->getSlotIndex(player->getPlayerIndex()); + const GameSlot *slot = slotIndex >= 0 ? TheGameInfo->getConstSlot(slotIndex) : nullptr; + if (slot && slot->isAI()) { + players.push_back(player); + } + } + return players; +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::writeStart() +{ + const std::vector players = matchPlayers(); + fprintf(m_log, "{\"t\":\"start\",\"frame\":%u,\"players\":[", TheGameLogic->getFrame()); + for (size_t i = 0; i < players.size(); i++) { + Player *player = players[i]; + const Int slotIndex = ThePlayerList->getSlotIndex(player->getPlayerIndex()); + const GameSlot *slot = TheGameInfo->getConstSlot(slotIndex); + fprintf(m_log, "%s{\"name\":%s,\"side\":%s,\"faction\":%s,\"difficulty\":\"%s\",\"team\":%d,\"start_position\":%d}", + i ? "," : "", quoted(playerName(player).c_str()).c_str(), quoted(player->getSide()).c_str(), + quoted(player->getPlayerTemplate() ? player->getPlayerTemplate()->getName() : AsciiString::TheEmptyString).c_str(), + difficultyName(player->getPlayerDifficulty()), slot->getTeamNumber(), slot->getStartPos()); + m_state[player->getPlayerIndex()].alive = TRUE; + } + fprintf(m_log, "]}\n"); + fflush(m_log); +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::writePlayerScore(Player *player) +{ + ScoreKeeper *score = player->getScoreKeeper(); + fprintf(m_log, "\"score\":{\"units_built\":%d,\"units_lost\":%d,\"units_destroyed\":%d,\"buildings_built\":%d," + "\"buildings_lost\":%d,\"buildings_destroyed\":%d,\"money_earned\":%d,\"money_spent\":%d}", + score->getTotalUnitsBuilt(), score->getTotalUnitsLost(), score->getTotalUnitsDestroyed(), score->getTotalBuildingsBuilt(), + score->getTotalBuildingsLost(), score->getTotalBuildingsDestroyed(), score->getTotalMoneyEarned(), score->getTotalMoneySpent()); +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::scanTeams() +{ + // A team exists from the moment the AI starts to build it. + const std::vector players = matchPlayers(); + for (size_t i = 0; i < players.size(); i++) { + Player *player = players[i]; + PlayerState &state = m_state[player->getPlayerIndex()]; + const Player::PlayerTeamList *prototypes = player->getPlayerTeams(); + for (Player::PlayerTeamList::const_iterator it = prototypes->begin(); it != prototypes->end(); ++it) { + for (DLINK_ITERATOR iter = (*it)->iterate_TeamInstanceList(); !iter.done(); iter.advance()) { + if (state.teams.insert((UnsignedInt)iter.cur()->getID()).second && TheGameLogic->getFrame() > TEAM_SCAN_FRAMES) { + fprintf(m_log, "{\"t\":\"team\",\"frame\":%u,\"player\":%s,\"team\":%s}\n", TheGameLogic->getFrame(), + quoted(playerName(player).c_str()).c_str(), quoted((*it)->getName()).c_str()); + } + } + } + } +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::writeSnapshot() +{ + const std::vector players = matchPlayers(); + const UnsignedInt frame = TheGameLogic->getFrame(); + + // Objects per player and type. + std::map > objects; + for (Object *object = TheGameLogic->getFirstObject(); object; object = object->getNextObject()) { + Player *owner = object->getControllingPlayer(); + if (owner && isArmyObject(object)) { + objects[owner->getPlayerIndex()][object->getTemplate()->getName().str()]++; + } + } + + static std::vector scienceNames; + if (scienceNames.empty()) { + scienceNames = TheScienceStore->friend_getScienceNames(); + } + + // What a player gained since the last snapshot comes first, as events. + for (size_t i = 0; i < players.size(); i++) { + Player *player = players[i]; + PlayerState &state = m_state[player->getPlayerIndex()]; + const std::string name = quoted(playerName(player).c_str()); + for (size_t s = 0; s < scienceNames.size(); s++) { + if (player->hasScience(TheScienceStore->getScienceFromInternalName(scienceNames[s])) + && state.sciences.insert(scienceNames[s].str()).second && frame > m_snapshotFrames) { + fprintf(m_log, "{\"t\":\"science\",\"frame\":%u,\"player\":%s,\"science\":%s}\n", frame, name.c_str(), quoted(scienceNames[s]).c_str()); + } + } + for (const UpgradeTemplate *upgrade = TheUpgradeCenter->firstUpgradeTemplate(); upgrade; upgrade = upgrade->friend_getNext()) { + if (player->hasUpgradeComplete(upgrade) && state.upgrades.insert(upgrade->getUpgradeName().str()).second) { + fprintf(m_log, "{\"t\":\"upgrade\",\"frame\":%u,\"player\":%s,\"upgrade\":%s}\n", frame, name.c_str(), + quoted(upgrade->getUpgradeName()).c_str()); + } + } + } + + fprintf(m_log, "{\"t\":\"snap\",\"frame\":%u,\"players\":[", frame); + for (size_t i = 0; i < players.size(); i++) { + Player *player = players[i]; + PlayerState &state = m_state[player->getPlayerIndex()]; + std::map teams; + const Player::PlayerTeamList *prototypes = player->getPlayerTeams(); + for (Player::PlayerTeamList::const_iterator it = prototypes->begin(); it != prototypes->end(); ++it) { + const Int instances = (*it)->countTeamInstances(); + if (instances > 0) { + teams[(*it)->getName().str()] = instances; + } + } + fprintf(m_log, "%s{\"name\":%s,\"alive\":%s,\"money\":%u,\"power\":[%d,%d],\"rank\":%d,\"science_points\":%d,", + i ? "," : "", quoted(playerName(player).c_str()).c_str(), state.alive ? "true" : "false", player->getMoney()->countMoney(), + player->getEnergy()->getProduction(), player->getEnergy()->getConsumption(), player->getRankLevel(), + player->getSciencePurchasePoints()); + writePlayerScore(player); + fprintf(m_log, ",\"objects\":"); + writeCounts(m_log, objects[player->getPlayerIndex()]); + fprintf(m_log, ",\"teams\":"); + writeCounts(m_log, teams); + fputc('}', m_log); + } + fprintf(m_log, "]}\n"); + fflush(m_log); +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::finish(const char *reason) +{ + if (m_finished) { + return; + } + m_finished = TRUE; + const std::vector players = matchPlayers(); + + fprintf(m_log, "{\"t\":\"end\",\"frame\":%u,\"reason\":\"%s\",\"winners\":[", TheGameLogic->getFrame(), reason); + Bool first = TRUE; + for (size_t i = 0; i < players.size(); i++) { + if (m_state[players[i]->getPlayerIndex()].alive && strcmp(reason, "victory") == 0) { + fprintf(m_log, "%s%s", first ? "" : ",", quoted(playerName(players[i]).c_str()).c_str()); + first = FALSE; + } + } + fprintf(m_log, "],\"players\":["); + for (size_t i = 0; i < players.size(); i++) { + Player *player = players[i]; + std::map built, lost; + player->getScoreKeeper()->friend_countObjects(built, lost); + fprintf(m_log, "%s{\"name\":%s,\"alive\":%s,", i ? "," : "", quoted(playerName(player).c_str()).c_str(), + m_state[player->getPlayerIndex()].alive ? "true" : "false"); + writePlayerScore(player); + fprintf(m_log, ",\"built\":"); + writeCounts(m_log, built); + fprintf(m_log, ",\"lost\":"); + writeCounts(m_log, lost); + fputc('}', m_log); + } + fprintf(m_log, "]}\n"); + + // How often every script ran, and what was reported how often. + for (std::map >::const_iterator owner = m_scripts.begin(); owner != m_scripts.end(); ++owner) { + fprintf(m_log, "{\"t\":\"scripts\",\"player\":%s,\"scripts\":{", quoted(owner->first.c_str()).c_str()); + for (std::map::const_iterator it = owner->second.begin(); it != owner->second.end(); ++it) { + fprintf(m_log, "%s%s:[%d,%u,%u]", it == owner->second.begin() ? "" : ",", quoted(it->first.c_str()).c_str(), + it->second.count, it->second.first, it->second.last); + } + fprintf(m_log, "}}\n"); + } + fprintf(m_log, "{\"t\":\"messages\",\"messages\":["); + for (std::map, ScriptUse>::const_iterator it = m_messages.begin(); it != m_messages.end(); ++it) { + fprintf(m_log, "%s{\"player\":%s,\"text\":%s,\"count\":%d,\"first\":%u,\"last\":%u}", it == m_messages.begin() ? "" : ",", + quoted(it->first.first.c_str()).c_str(), quoted(it->first.second.c_str()).c_str(), it->second.count, it->second.first, it->second.last); + } + fprintf(m_log, "]}\n"); + fclose(m_log); + m_log = nullptr; + + // Leave as the main menu's quit button does. Without the client there is nothing to tell. + TheGameEngine->setQuitting(TRUE); + if (TheGameLogic->isInGame() && !TheGlobalData->m_headless) { + TheGameLogic->exitGame(); + } +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::update() +{ + if (m_finished || m_log == nullptr || !TheGameLogic->isInGame() || TheGameLogic->getGameMode() != GAME_SKIRMISH) { + return; + } + const UnsignedInt frame = TheGameLogic->getFrame(); + if (!m_started) { + if (frame < 2) { + return; + } + m_started = TRUE; + writeStart(); + } + + if (frame % TEAM_SCAN_FRAMES == 0) { + scanTeams(); + } + if (frame % m_snapshotFrames != 0 && frame < m_maxFrames) { + return; + } + + // A player is out when it has neither structures that count for victory nor units, as in multiplayer. + KindOfMaskType victoryMask; + victoryMask.set(KINDOF_MP_COUNT_FOR_VICTORY); + const std::vector players = matchPlayers(); + std::set sidesAlive; + for (size_t i = 0; i < players.size(); i++) { + Player *player = players[i]; + PlayerState &state = m_state[player->getPlayerIndex()]; + if (state.alive && frame > 10 * LOGICFRAMES_PER_SECOND && !player->hasAnyBuildings(victoryMask) && !player->hasAnyUnits()) { + state.alive = FALSE; + fprintf(m_log, "{\"t\":\"defeated\",\"frame\":%u,\"player\":%s}\n", frame, quoted(playerName(player).c_str()).c_str()); + } + if (state.alive) { + const GameSlot *slot = TheGameInfo->getConstSlot(ThePlayerList->getSlotIndex(player->getPlayerIndex())); + // Players without a team stand alone. + sidesAlive.insert(slot->getTeamNumber() >= 0 ? slot->getTeamNumber() : 100 + player->getPlayerIndex()); + } + } + writeSnapshot(); + + if (sidesAlive.size() <= 1) { + finish("victory"); + } else if (frame >= m_maxFrames) { + finish("time_limit"); + } +} + +//------------------------------------------------------------------------------------------------- +/// The player whose script is running, or null. +Player *AIMatch::scriptPlayer() +{ + // The script engine reports it when there is none, which would come straight back to scriptMessage. + m_askingForPlayer = TRUE; + Player *player = TheScriptEngine->getCurrentPlayer(); + m_askingForPlayer = FALSE; + return player; +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::scriptFired(const AsciiString &scriptName, Bool conditionsTrue) +{ + if (m_finished || m_log == nullptr || !conditionsTrue || TheGameLogic->getGameMode() != GAME_SKIRMISH) { + return; + } + Player *player = scriptPlayer(); + const std::string owner = player ? playerName(player) : std::string(); + std::map &scripts = m_scripts[owner]; + std::map::iterator it = scripts.find(scriptName.str()); + const UnsignedInt frame = TheGameLogic->getFrame(); + if (it == scripts.end()) { + ScriptUse use; + use.count = 1; + use.first = use.last = frame; + scripts[scriptName.str()] = use; + // The first run of a script goes into the timeline. + if (m_started) { + fprintf(m_log, "{\"t\":\"script\",\"frame\":%u,\"player\":%s,\"script\":%s}\n", frame, quoted(owner.c_str()).c_str(), quoted(scriptName).c_str()); + } + } else { + it->second.count++; + it->second.last = frame; + } +} + +//------------------------------------------------------------------------------------------------- +void AIMatch::scriptMessage(const AsciiString &text) +{ + if (!m_started || m_finished || m_askingForPlayer) { + return; + } + const UnsignedInt frame = TheGameLogic->getFrame(); + // The AI of a player reports while it is being updated; scripts report for the player that owns them. + Player *player = m_aiPlayer ? m_aiPlayer : scriptPlayer(); + const std::pair key(player ? playerName(player) : std::string(), text.str()); + std::map, ScriptUse>::iterator it = m_messages.find(key); + if (it == m_messages.end()) { + ScriptUse use; + use.count = 1; + use.first = use.last = frame; + m_messages[key] = use; + fprintf(m_log, "{\"t\":\"message\",\"frame\":%u,\"player\":%s,\"text\":%s}\n", frame, + quoted(key.first.c_str()).c_str(), quoted(text).c_str()); + } else { + it->second.count++; + it->second.last = frame; + } +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Map/SidesList.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Map/SidesList.cpp index b2c05e49597..98619814ea2 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Map/SidesList.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Map/SidesList.cpp @@ -51,6 +51,7 @@ #include "Common/WellKnownKeys.h" #include "Common/Xfer.h" #include "GameLogic/AI.h" +#include "GameLogic/AIMatch.h" #include "GameLogic/Scripts.h" #include "GameLogic/SidesList.h" @@ -518,6 +519,9 @@ void SidesList::prepareForMP_or_Skirmish() } if (!gotScripts) { AsciiString path = "data\\Scripts\\SkirmishScripts.scb"; + if (TheAIMatch && TheAIMatch->getScriptFile().isNotEmpty()) { + path = TheAIMatch->getScriptFile(); + } DEBUG_LOG(("Skirmish map using standard scripts")); m_skirmishTeamrec.clear(); CachedFileInputStream theInputStream; diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Armor.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Armor.cpp index 97a9547bcc0..5fa50a535fe 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Armor.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Armor.cpp @@ -170,6 +170,19 @@ const ArmorTemplate* ArmorStore::findArmorTemplate(const char* name) const return findArmorTemplate(TheNameKeyGenerator->nameToKey(name)); } +//------------------------------------------------------------------------------------------------- +AsciiString ArmorStore::friend_findArmorTemplateName(const ArmorTemplate* tmpl) const +{ + for (ArmorTemplateMap::const_iterator it = m_armorTemplates.begin(); it != m_armorTemplates.end(); ++it) + { + if (&(*it).second == tmpl) + { + return TheNameKeyGenerator->keyToName((*it).first); + } + } + return AsciiString::TheEmptyString; +} + //------------------------------------------------------------------------------------------------- /*static */ void ArmorStore::parseArmorDefinition(INI *ini) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptEngine.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptEngine.cpp index e0c9d8b7812..cd136a08520 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptEngine.cpp @@ -54,6 +54,7 @@ #include "GameLogic/PartitionManager.h" #include "GameLogic/ScriptActions.h" #include "GameLogic/ScriptConditions.h" +#include "GameLogic/AIMatch.h" #include "GameLogic/ScriptEngine.h" #include "GameLogic/SidesList.h" @@ -8515,6 +8516,9 @@ void ScriptEngine::forceUnfreezeTime() void ScriptEngine::AppendDebugMessage(const AsciiString& strToAdd, Bool forcePause) { + if (TheAIMatch) { + TheAIMatch->scriptMessage(strToAdd); + } #ifdef INTENSE_DEBUG DEBUG_LOG(("-SCRIPT- %d %s", TheGameLogic->getFrame(), strToAdd.str())); #endif @@ -9380,6 +9384,9 @@ void ScriptEngine::markMPLocalDefeatWindowShown() void _appendMessage(const AsciiString& str, Bool isTrueMessage, Bool shouldPause) { + if (TheAIMatch) { + TheAIMatch->scriptFired(str, isTrueMessage); + } typedef void (*funcptr)(const char*); AsciiString msg; diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index 31bcbcbd6e4..e1ebfc2453a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -80,6 +80,7 @@ #include "GameClient/GameWindowTransitions.h" #include "GameLogic/AI.h" +#include "GameLogic/AIMatch.h" #include "GameLogic/AIPathfind.h" #include "GameLogic/CaveSystem.h" #include "GameLogic/CrateSystem.h" @@ -3743,6 +3744,10 @@ void GameLogic::update() TheScriptEngine->UPDATE(); } + if (TheAIMatch) { + TheAIMatch->update(); + } + // TheSuperHackers @info Updates the frozen time status because it may have changed after the script engine update. TheFramePacer->setTimeFrozen(TheGameEngine->isTimeFrozen()); diff --git a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt index e7ca1b259d1..d50a52c9a9c 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt +++ b/GeneralsMD/Code/Tools/WorldBuilder/CMakeLists.txt @@ -195,6 +195,36 @@ set(WORLDBUILDER_SRC "include/WorldBuilder.h" "include/WorldBuilderDoc.h" "include/WorldBuilderView.h" + "include/mcp/McpCommands.h" + "include/mcp/McpJson.h" + "include/mcp/McpNoise.h" + "include/mcp/McpScriptsShared.h" + "include/mcp/McpShape.h" + "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/McpCmdReplace.cpp" + "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/McpCmdFaction.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" + "src/mcp/McpJson.cpp" + "src/mcp/McpNoise.cpp" + "src/mcp/McpScreenshot.cpp" + "src/mcp/McpServer.cpp" ) add_executable(z_worldbuilder WIN32) @@ -224,6 +254,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/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/WHeightMapEdit.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WHeightMapEdit.h index c51ffb3a4d0..b5da566c8f4 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: @@ -116,10 +130,21 @@ 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 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); + /// 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) ; @@ -143,6 +168,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/WorldBuilderDoc.h b/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h index 4bd88712af4..cdc03f01549 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/WorldBuilderDoc.h @@ -149,6 +149,11 @@ 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); + /// 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/include/mcp/McpCommands.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h new file mode 100644 index 00000000000..d8e8870b3e6 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpCommands.h @@ -0,0 +1,131 @@ +/* +** 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" + +#include "Common/AsciiString.h" +#include + +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); + +/// 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); + +// Per-area registration, called once from mcpDispatch. +void mcpRegisterMapCommands(); +void mcpRegisterTerrainCommands(); +void mcpRegisterObjectCommands(); +void mcpRegisterWaypointCommands(); +void mcpRegisterImageCommands(); +void mcpRegisterSymmetryCommands(); +void mcpRegisterGenerateCommands(); +void mcpRegisterSkirmishCommands(); +void mcpRegisterScatterCommands(); +void mcpRegisterRouteCommands(); +void mcpRegisterReplaceCommands(); +void mcpRegisterResizeCommands(); +void mcpRegisterScriptCommands(); +void mcpRegisterTeamCommands(); +void mcpRegisterScriptToolCommands(); +void mcpRegisterScriptViewCommands(); +void mcpRegisterFactionCommands(); + +//------------------------------------------------------------------------------------------------- +// 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); + +/// 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); + +/// 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); + +/// 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); + +/// 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; +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/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/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/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/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/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/include/mcp/McpUndoables.h b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h new file mode 100644 index 00000000000..6f806750dd5 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/include/mcp/McpUndoables.h @@ -0,0 +1,94 @@ +/* +** 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 "wbview3d.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; +}; + +/// 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/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/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/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/WHeightMapEdit.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WHeightMapEdit.cpp index 2b0faa17a1e..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); @@ -1435,7 +1436,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 +1456,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; @@ -1636,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) && (ndxhoriz = 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; + } + } + } +} + +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. @@ -1891,6 +2052,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)*sizeof(HeightSampleType)); + 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 #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..a7cfdabe42d 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); @@ -1419,7 +1435,8 @@ BOOL CWorldBuilderDoc::OnOpenDocument(LPCTSTR lpszPathName) TheGameText->initMapStringFile(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/McpCmdFaction.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdFaction.cpp new file mode 100644 index 00000000000..d60b35451f1 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdFaction.cpp @@ -0,0 +1,896 @@ +/* +** 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 . +*/ + +// McpCmdFaction.cpp +// MCP bridge command that describes a faction as the game data defines it: its tech tree (what +// builds what, prerequisites, upgrades, sciences, special powers) and the stats of its units and +// structures (cost, health, armor, weapons, movement). Everything is read from the loaded +// templates, so inheritance, reskins and module overrides are already resolved. + +#include "StdAfx.h" +#define DEFINE_BUILDABLE_STATUS_NAMES +#define DEFINE_GUI_COMMAND_NAMES +#define DEFINE_COMMAND_OPTION_NAMES +#define DEFINE_WEAPONSLOTTYPE_NAMES +#define DEFINE_SURFACECATEGORY_NAMES +#include "mcp/McpCommands.h" + +#include "Common/GameCommon.h" +#include "Common/KindOf.h" +#include "Common/Money.h" +#include "Common/NameKeyGenerator.h" +#include "Common/PlayerTemplate.h" +#include "Common/ProductionPrerequisite.h" +#include "Common/Science.h" +#include "Common/SpecialPower.h" +#include "Common/SpecialPowerMaskType.h" +#include "Common/ThingFactory.h" +#include "Common/ThingTemplate.h" +#include "Common/Upgrade.h" +#include "GameClient/ControlBar.h" +#include "GameLogic/Armor.h" +#include "GameLogic/ArmorSet.h" +#include "GameLogic/Damage.h" +#include "GameLogic/Locomotor.h" +#include "GameLogic/LocomotorSet.h" +#include "GameLogic/Module/ActiveBody.h" +#include "GameLogic/Module/AIUpdate.h" +#include "GameLogic/Module/CommandSetUpgrade.h" +#include "GameLogic/Module/OpenContain.h" +#include "GameLogic/Module/StealthDetectorUpdate.h" +#include "GameLogic/Module/StealthUpdate.h" +#include "GameLogic/Module/SupplyTruckAIUpdate.h" +#include "GameLogic/Module/TransportContain.h" +#include "GameLogic/Module/WorkerAIUpdate.h" +#include "GameLogic/Weapon.h" +#include "GameLogic/WeaponSet.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +/// A list that keeps the order in which things were first added. +template +struct OrderedSet +{ + std::vector items; + std::set seen; + + bool add(const T &item) + { + if (!seen.insert(item).second) return false; + items.push_back(item); + return true; + } + bool has(const T &item) const { return seen.find(item) != seen.end(); } +}; + +/// A science as the purchase command sets of the faction offer it. +struct ScienceOffer +{ + std::string button; + Int rank; +}; + +/// Everything the description of one faction refers to. +struct FactionData +{ + OrderedSet objects; + std::set inTechTree; + OrderedSet weapons; + OrderedSet armors; + OrderedSet upgrades; + OrderedSet sciences; + OrderedSet specialPowers; + + std::map > producedBy; + std::map > researchedAt; + std::map > powerUsers; + std::map scienceOffers; +}; + +ControlBar *controlBar() +{ + // WorldBuilder has no control bar until something asks for command sets. + if (TheControlBar == nullptr) { + TheControlBar = new ControlBar; + TheControlBar->init(); + } + return TheControlBar; +} + +McpJson stringArray(const OrderedSet &set) +{ + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < set.items.size(); i++) { + list.push(McpJson(set.items[i])); + } + return list; +} + +std::string displayText(const UnicodeString &text) +{ + AsciiString ascii; + ascii.translate(text); + return ascii.str(); +} + +/// Game data is single precision; rounding keeps 25 from reading 25.0000012. +double num(double value) +{ + return floor(value * 10000.0 + 0.5) / 10000.0; +} + +double framesToSeconds(double frames) +{ + return num(frames / (double)LOGICFRAMES_PER_SECOND); +} + +McpJson scienceNames(const ScienceVec &sciences) +{ + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < sciences.size(); i++) { + list.push(McpJson(TheScienceStore->getInternalNameForScience(sciences[i]).str())); + } + return list; +} + +template +McpJson flagNames(const FLAGS &flags) +{ + McpJson list = McpJson::makeArray(); + const char *const *names = FLAGS::getBitNames(); + for (Int i = 0; names[i]; i++) { + if (flags.test(i)) list.push(McpJson(names[i])); + } + return list; +} + +McpJson kindNames(const ThingTemplate *tt) +{ + McpJson list = McpJson::makeArray(); + const char *const *names = KindOfMaskType::getBitNames(); + for (Int k = KINDOF_FIRST; k < KINDOF_COUNT && names[k]; k++) { + if (tt->isKindOf((KindOfType)k)) list.push(McpJson(names[k])); + } + return list; +} + +Bool isProductionCommand(GUICommandType type) +{ + return type == GUI_COMMAND_UNIT_BUILD || type == GUI_COMMAND_DOZER_CONSTRUCT + || type == GUI_COMMAND_SPECIAL_POWER_CONSTRUCT || type == GUI_COMMAND_SPECIAL_POWER_CONSTRUCT_FROM_SHORTCUT; +} + +/// Commands every unit has; they say nothing about what a type can do. +Bool isPlainCommand(GUICommandType type) +{ + switch (type) { + case GUI_COMMAND_NONE: + case GUI_COMMAND_DOZER_CONSTRUCT_CANCEL: + case GUI_COMMAND_CANCEL_UNIT_BUILD: + case GUI_COMMAND_CANCEL_UPGRADE: + case GUI_COMMAND_ATTACK_MOVE: + case GUI_COMMAND_GUARD: + case GUI_COMMAND_GUARD_WITHOUT_PURSUIT: + case GUI_COMMAND_GUARD_FLYING_UNITS_ONLY: + case GUI_COMMAND_STOP: + case GUI_COMMAND_WAYPOINTS: + case GUI_COMMAND_EXIT_CONTAINER: + case GUI_COMMAND_BEACON_DELETE: + case GUI_COMMAND_SET_RALLY_POINT: + case GUI_COMMAND_SELL: + case GUI_COMMAND_SELECT_ALL_UNITS_OF_TYPE: + case GUI_COMMAND_REVERSE_MOVE: + return TRUE; + default: + return FALSE; + } +} + +/// The command sets a type can show: its own and those its command set upgrades switch to. +std::vector commandSetsOf(const ThingTemplate *tt) +{ + std::vector sets; + if (!tt->friend_getCommandSetString().isEmpty()) sets.push_back(tt->friend_getCommandSetString()); + const ModuleInfo &modules = tt->getBehaviorModuleInfo(); + for (Int m = 0; m < modules.getCount(); m++) { + const CommandSetUpgradeModuleData *data = dynamic_cast(modules.getNthData(m)); + if (data == nullptr) continue; + const AsciiString *names[2] = { &data->m_newCommandSet, &data->m_newCommandSetAlt }; + for (Int n = 0; n < 2; n++) { + if (names[n]->isEmpty() || names[n]->compareNoCase("None") == 0) continue; + if (std::find(sets.begin(), sets.end(), *names[n]) == sets.end()) sets.push_back(*names[n]); + } + } + return sets; +} + +/// Adds a science and the sciences it needs. +void collectScience(FactionData &data, ScienceType science) +{ + if (science == SCIENCE_INVALID || !data.sciences.add(science)) return; + ScienceVec prereqs; + if (TheScienceStore->friend_getPrereqSciences(science, prereqs)) { + for (size_t i = 0; i < prereqs.size(); i++) { + collectScience(data, prereqs[i]); + } + } +} + +void collectSpecialPower(FactionData &data, const SpecialPowerTemplate *power, const ThingTemplate *user) +{ + if (power == nullptr) return; + data.specialPowers.add(power); + if (user) data.powerUsers[power].add(user->getName().str()); + collectScience(data, power->getRequiredScience()); +} + +/// Adds a type and everything that can be built from it, following the command sets. +void collectTechTree(FactionData &data, const ThingTemplate *start) +{ + std::vector open; + open.push_back(start); + while (!open.empty()) { + const ThingTemplate *tt = open.back(); + open.pop_back(); + if (!data.inTechTree.insert(tt).second) continue; + data.objects.add(tt); + + const std::vector sets = commandSetsOf(tt); + for (size_t s = 0; s < sets.size(); s++) { + const CommandSet *commandSet = controlBar()->findCommandSet(sets[s]); + if (commandSet == nullptr) continue; + for (Int i = 0; i < MAX_COMMANDS_PER_SET; i++) { + const CommandButton *button = commandSet->getCommandButton(i); + if (button == nullptr || !isProductionCommand(button->getCommandType())) continue; + const ThingTemplate *built = button->getThingTemplate(); + if (built) { + data.producedBy[built].add(tt->getName().str()); + open.push_back(built); + } + } + } + } +} + +/// Records the upgrades, special powers and sciences the buttons of a type refer to. +void collectCommandRefs(FactionData &data, const ThingTemplate *tt) +{ + const std::vector sets = commandSetsOf(tt); + for (size_t s = 0; s < sets.size(); s++) { + const CommandSet *commandSet = controlBar()->findCommandSet(sets[s]); + if (commandSet == nullptr) continue; + for (Int i = 0; i < MAX_COMMANDS_PER_SET; i++) { + const CommandButton *button = commandSet->getCommandButton(i); + if (button == nullptr) continue; + const GUICommandType type = button->getCommandType(); + if ((type == GUI_COMMAND_PLAYER_UPGRADE || type == GUI_COMMAND_OBJECT_UPGRADE) && button->getUpgradeTemplate()) { + data.upgrades.add(button->getUpgradeTemplate()); + data.researchedAt[button->getUpgradeTemplate()].add(tt->getName().str()); + } + collectSpecialPower(data, button->getSpecialPowerTemplate(), tt); + const ScienceVec &sciences = button->getScienceVec(); + for (size_t k = 0; k < sciences.size(); k++) { + collectScience(data, sciences[k]); + } + } + } +} + +/// The sciences a purchase command set offers (the General's promotion screen). +void collectScienceOffers(FactionData &data, const AsciiString &setName, Int rank) +{ + const CommandSet *commandSet = setName.isEmpty() ? nullptr : controlBar()->findCommandSet(setName); + if (commandSet == nullptr) return; + for (Int i = 0; i < MAX_COMMANDS_PER_SET; i++) { + const CommandButton *button = commandSet->getCommandButton(i); + if (button == nullptr) continue; + const ScienceVec &sciences = button->getScienceVec(); + for (size_t k = 0; k < sciences.size(); k++) { + collectScience(data, sciences[k]); + if (data.scienceOffers.find(sciences[k]) == data.scienceOffers.end()) { + ScienceOffer offer; + offer.button = button->getName().str(); + offer.rank = rank; + data.scienceOffers[sciences[k]] = offer; + } + } + collectSpecialPower(data, button->getSpecialPowerTemplate(), nullptr); + } +} + +/// True for the types of a side that matter to an AI even when nothing in the tech tree builds them. +Bool isArmyType(const ThingTemplate *tt) +{ + if (tt->isKindOf(KINDOF_PROJECTILE) || tt->isKindOf(KINDOF_MINE)) return FALSE; + return tt->isKindOf(KINDOF_STRUCTURE) || tt->isKindOf(KINDOF_INFANTRY) || tt->isKindOf(KINDOF_VEHICLE) + || tt->isKindOf(KINDOF_AIRCRAFT); +} + +McpJson describePrerequisites(const ThingTemplate *tt) +{ + // All of it is required: one type out of every group of objects, and every science. + McpJson groups = McpJson::makeArray(); + McpJson sciences = McpJson::makeArray(); + for (Int p = 0; p < tt->getPrereqCount(); p++) { + const ProductionPrerequisite *prereq = tt->getNthPrereq(p); + McpJson group = McpJson::makeArray(); + for (Int u = 0; u < prereq->getUnitPrereqCount(); u++) { + if (u > 0 && !prereq->isNthUnitPrereqOrWithPrevious(u) && group.size() > 0) { + groups.push(group); + group = McpJson::makeArray(); + } + const ThingTemplate *unit = prereq->getNthUnitPrereq(u); + group.push(McpJson(unit ? unit->getName().str() : "")); + } + if (group.size() > 0) groups.push(group); + for (size_t s = 0; s < prereq->getSciencePrereqs().size(); s++) { + sciences.push(McpJson(TheScienceStore->getInternalNameForScience(prereq->getSciencePrereqs()[s]).str())); + } + } + McpJson j = McpJson::makeObject(); + if (groups.size() > 0) j.set("objects", groups); + if (sciences.size() > 0) j.set("sciences", sciences); + return j; +} + +McpJson describeCommands(FactionData &data, const ThingTemplate *tt) +{ + McpJson builds = McpJson::makeArray(); + McpJson upgrades = McpJson::makeArray(); + McpJson powers = McpJson::makeArray(); + McpJson abilities = McpJson::makeArray(); + std::set seen; + + const std::vector sets = commandSetsOf(tt); + for (size_t s = 0; s < sets.size(); s++) { + const CommandSet *commandSet = controlBar()->findCommandSet(sets[s]); + if (commandSet == nullptr) continue; + for (Int i = 0; i < MAX_COMMANDS_PER_SET; i++) { + const CommandButton *button = commandSet->getCommandButton(i); + if (button == nullptr || button->getName().isEmpty()) continue; + const GUICommandType type = button->getCommandType(); + if (isPlainCommand(type) || !seen.insert(button->getName().str()).second) continue; + + McpJson entry = McpJson::makeObject(); + entry.set("button", button->getName().str()); + if (s > 0) entry.set("command_set", sets[s].str()); + const ThingTemplate *built = button->getThingTemplate(); + if (isProductionCommand(type) && built) { + entry.set("object", built->getName().str()); + if (type != GUI_COMMAND_UNIT_BUILD && type != GUI_COMMAND_DOZER_CONSTRUCT) { + entry.set("type", TheGuiCommandNames[type]); + } + builds.push(entry); + } else if ((type == GUI_COMMAND_PLAYER_UPGRADE || type == GUI_COMMAND_OBJECT_UPGRADE) && button->getUpgradeTemplate()) { + entry.set("upgrade", button->getUpgradeTemplate()->getUpgradeName().str()); + upgrades.push(entry); + } else { + entry.set("type", TheGuiCommandNames[type]); + if (button->getSpecialPowerTemplate()) entry.set("special_power", button->getSpecialPowerTemplate()->getName().str()); + if (!button->getScienceVec().empty()) entry.set("sciences", scienceNames(button->getScienceVec())); + if (type == GUI_COMMAND_FIRE_WEAPON || type == GUI_COMMAND_SWITCH_WEAPON) { + entry.set("weapon_slot", TheWeaponSlotTypeNames[button->getWeaponSlot()]); + } + McpJson options = McpJson::makeArray(); + for (Int bit = 0; TheCommandOptionNames[bit]; bit++) { + if (button->getOptions() & (1u << bit)) options.push(McpJson(TheCommandOptionNames[bit])); + } + if (options.size() > 0) entry.set("options", options); + if (button->getSpecialPowerTemplate()) powers.push(entry); else abilities.push(entry); + } + } + } + + McpJson j = McpJson::makeObject(); + if (builds.size() > 0) j.set("builds", builds); + if (upgrades.size() > 0) j.set("upgrades", upgrades); + if (powers.size() > 0) j.set("special_powers", powers); + if (abilities.size() > 0) j.set("abilities", abilities); + return j; +} + +McpJson describeWeaponSets(FactionData &data, const ThingTemplate *tt) +{ + McpJson list = McpJson::makeArray(); + const WeaponTemplateSetVector &sets = tt->getWeaponTemplateSets(); + for (size_t s = 0; s < sets.size(); s++) { + McpJson weapons = McpJson::makeObject(); + for (Int slot = 0; slot < WEAPONSLOT_COUNT; slot++) { + const WeaponTemplate *weapon = sets[s].getNth((WeaponSlotType)slot); + if (weapon == nullptr) continue; + data.weapons.add(weapon); + weapons.set(TheWeaponSlotTypeNames[slot], weapon->getName().str()); + } + if (weapons.numKeys() == 0) continue; + list.push(McpJson::makeObject().set("conditions", flagNames(sets[s].getNthConditionsYes(0))).set("weapons", weapons)); + } + return list; +} + +McpJson describeArmorSets(FactionData &data, const ThingTemplate *tt) +{ + McpJson list = McpJson::makeArray(); + const ArmorTemplateSetVector &sets = tt->getArmorTemplateSets(); + for (size_t s = 0; s < sets.size(); s++) { + const ArmorTemplate *armor = sets[s].getArmorTemplate(); + if (armor == nullptr) continue; + data.armors.add(armor); + list.push(McpJson::makeObject().set("conditions", flagNames(sets[s].getNthConditionsYes(0))) + .set("armor", TheArmorStore->friend_findArmorTemplateName(armor).str())); + } + return list; +} + +/// What the behavior modules of a type say about health, movement, cargo and stealth. +void describeModules(const ThingTemplate *tt, McpJson &j) +{ + OrderedSet names; + const ModuleInfo &modules = tt->getBehaviorModuleInfo(); + for (Int m = 0; m < modules.getCount(); m++) { + names.add(modules.getNthName(m).str()); + const ModuleData *data = modules.getNthData(m); + if (data == nullptr) continue; + + if (const ActiveBodyModuleData *body = dynamic_cast(data)) { + j.set("health", num(body->m_maxHealth)); + } + if (data->isAiModuleData()) { + const AIUpdateModuleData *ai = static_cast(data); + const LocomotorTemplateVector *locos = ai->findLocomotorTemplateVector(LOCOMOTORSET_NORMAL); + if (locos && !locos->empty() && (*locos)[0]) { + // The first locomotor names the unit's movement; the surfaces are those of all of them, + // as an amphibious unit has one for land and one for water. + const LocomotorTemplate *loco = (*locos)[0]; + Int allSurfaces = 0; + McpJson others = McpJson::makeArray(); + for (size_t l = 0; l < locos->size(); l++) { + if ((*locos)[l] == nullptr) continue; + allSurfaces |= (*locos)[l]->friend_getSurfaces(); + if (l > 0) others.push(McpJson((*locos)[l]->friend_getName().str())); + } + McpJson surfaces = McpJson::makeArray(); + for (Int bit = 0; TheLocomotorSurfaceTypeNames[bit]; bit++) { + if (allSurfaces & (1 << bit)) surfaces.push(McpJson(TheLocomotorSurfaceTypeNames[bit])); + } + McpJson locomotor = McpJson::makeObject(); + locomotor.set("name", loco->friend_getName().str()) + .set("speed", num(loco->friend_getMaxSpeed() * (double)LOGICFRAMES_PER_SECOND)).set("surfaces", surfaces); + if (others.size() > 0) locomotor.set("also", others); + j.set("locomotor", locomotor); + } + if (const SupplyTruckAIUpdateModuleData *truck = dynamic_cast(data)) { + j.set("supply_boxes", truck->m_maxBoxesData); + } + if (const WorkerAIUpdateModuleData *worker = dynamic_cast(data)) { + j.set("supply_boxes", worker->m_maxBoxesData); + } + } + if (const OpenContainModuleData *contain = dynamic_cast(data)) { + McpJson c = McpJson::makeObject(); + c.set("module", modules.getNthName(m).str()); + if (contain->m_containMax >= 0) c.set("max", contain->m_containMax); + if (const TransportContainModuleData *transport = dynamic_cast(data)) { + c.set("slots", transport->m_slotCapacity); + if (!transport->m_initialPayload.name.isEmpty() && transport->m_initialPayload.count > 0) { + c.set("initial_payload", transport->m_initialPayload.name.str()).set("initial_count", transport->m_initialPayload.count); + } + } + if (contain->m_passengersAllowedToFire) c.set("passengers_fire", true); + if (contain->m_allowInsideKindOf.any()) c.set("allows", flagNames(contain->m_allowInsideKindOf)); + if (contain->m_forbidInsideKindOf.any()) c.set("forbids", flagNames(contain->m_forbidInsideKindOf)); + j.set("contain", c); + } + if (const StealthUpdateModuleData *stealth = dynamic_cast(data)) { + // Without innate stealth the module only waits for an upgrade or special power. + if (stealth->m_innateStealth) j.set("stealth", true); + } + if (const StealthDetectorUpdateModuleData *detector = dynamic_cast(data)) { + // A range of 0 means the vision range is used. + j.set("detector_range", num(detector->m_detectionRange > 0 ? detector->m_detectionRange : tt->friend_calcVisionRange())); + } + } + j.set("modules", stringArray(names)); +} + +McpJson describeObject(FactionData &data, const ThingTemplate *tt) +{ + McpJson j = McpJson::makeObject(); + j.set("name", tt->getName().str()); + const std::string displayName = displayText(tt->getDisplayName()); + if (!displayName.empty()) j.set("display_name", displayName); + j.set("side", tt->getDefaultOwningSide().str()); + j.set("in_tech_tree", data.inTechTree.find(tt) != data.inTechTree.end()); + j.set("kind_of", kindNames(tt)); + j.set("cost", (int)tt->friend_getBuildCost()).set("build_time", num(tt->friend_getBuildTime())); + const Int buildable = tt->getBuildable(); + if (buildable != BSTATUS_YES && buildable >= BSTATUS_YES && buildable < BSTATUS_NUM_TYPES) { + j.set("buildable", BuildableStatusNames[buildable - BSTATUS_YES]); + } + if (tt->getEnergyProduction() != 0) j.set("energy", (int)tt->getEnergyProduction()); + if (tt->getEnergyBonus() != 0) j.set("energy_bonus", (int)tt->getEnergyBonus()); + if (tt->getMaxSimultaneousOfType() > 0) j.set("max_simultaneous", (int)tt->getMaxSimultaneousOfType()); + j.set("vision", num(tt->friend_calcVisionRange())); + if (tt->getRawTransportSlotCount() > 0) j.set("size_in_transport", (int)tt->getRawTransportSlotCount()); + if (tt->getCrusherLevel() > 0) j.set("crusher", (int)tt->getCrusherLevel()); + if (tt->getCrushableLevel() > 0) j.set("crushable", (int)tt->getCrushableLevel()); + if (tt->isBuildFacility()) j.set("build_facility", true); + if (!tt->getBuildVariations().empty()) { + McpJson variations = McpJson::makeArray(); + for (size_t i = 0; i < tt->getBuildVariations().size(); i++) { + variations.push(McpJson(tt->getBuildVariations()[i].str())); + } + j.set("build_variations", variations); + } + + const McpJson prereqs = describePrerequisites(tt); + if (prereqs.numKeys() > 0) j.set("prerequisites", prereqs); + std::map >::const_iterator producers = data.producedBy.find(tt); + if (producers != data.producedBy.end()) j.set("built_by", stringArray(producers->second)); + + describeModules(tt, j); + const McpJson weaponSets = describeWeaponSets(data, tt); + if (weaponSets.size() > 0) j.set("weapon_sets", weaponSets); + const McpJson armorSets = describeArmorSets(data, tt); + if (armorSets.size() > 0) j.set("armor_sets", armorSets); + + if (!tt->friend_getCommandSetString().isEmpty()) j.set("command_set", tt->friend_getCommandSetString().str()); + const McpJson commands = describeCommands(data, tt); + for (size_t k = 0; k < commands.numKeys(); k++) { + j.set(commands.keyAt(k).c_str(), commands.valueAt(k)); + } + return j; +} + +McpJson describeWeapon(const WeaponTemplate *weapon) +{ + const WeaponBonus none; + McpJson j = McpJson::makeObject(); + j.set("name", weapon->getName().str()); + j.set("damage", num(weapon->getPrimaryDamage(none))).set("damage_radius", num(weapon->getPrimaryDamageRadius(none))); + if (weapon->getSecondaryDamage(none) > 0) { + j.set("secondary_damage", num(weapon->getSecondaryDamage(none))).set("secondary_radius", num(weapon->getSecondaryDamageRadius(none))); + } + j.set("damage_type", DamageTypeFlags::getNameFromSingleBit(weapon->getDamageType())); + j.set("range", num(weapon->getUnmodifiedAttackRange())); + if (weapon->getMinimumAttackRange() > 0) j.set("min_range", num(weapon->getMinimumAttackRange())); + + const double delay = (weapon->getMinDelayBetweenShots() + weapon->getMaxDelayBetweenShots()) * 0.5; + j.set("shot_delay", framesToSeconds(delay)); + const Int clip = weapon->getClipSize(); + if (clip > 0) { + j.set("clip_size", clip).set("clip_reload", framesToSeconds(weapon->getClipReloadTime(none))); + } + if (weapon->getPreAttackDelay(none) > 0) j.set("pre_attack_delay", framesToSeconds(weapon->getPreAttackDelay(none))); + + // Sustained damage per second against one target, without armor: a clip of shots, then the reload. + double cycleFrames = delay; + double cycleDamage = weapon->getPrimaryDamage(none); + if (clip > 0) { + cycleFrames = delay * (clip - 1) + weapon->getClipReloadTime(none); + cycleDamage *= clip; + } + if (cycleFrames > 0) j.set("dps", num(cycleDamage * (double)LOGICFRAMES_PER_SECOND / cycleFrames)); + + static const char *const antiNames[] = { "AIRBORNE_VEHICLE", "GROUND", "PROJECTILE", "SMALL_MISSILE", "MINE", + "AIRBORNE_INFANTRY", "BALLISTIC_MISSILE", "PARACHUTE", nullptr }; + McpJson anti = McpJson::makeArray(); + for (Int bit = 0; antiNames[bit]; bit++) { + if (weapon->getAntiMask() & (1 << bit)) anti.push(McpJson(antiNames[bit])); + } + j.set("anti", anti); + if (weapon->getProjectileTemplate()) { + j.set("projectile", weapon->getProjectileTemplate()->getName().str()); + if (weapon->getWeaponSpeed() > 0 && weapon->getWeaponSpeed() < 99999.0f) { + j.set("projectile_speed", num(weapon->getWeaponSpeed() * (double)LOGICFRAMES_PER_SECOND)); + } + } + if (weapon->getScatterRadius() > 0) j.set("scatter_radius", num(weapon->getScatterRadius())); + return j; +} + +McpJson describeArmor(const ArmorTemplate *armor) +{ + // The share of each damage type that gets through; types at 100% are left out. + McpJson damage = McpJson::makeObject(); + for (Int type = 0; type < DAMAGE_NUM_TYPES; type++) { + const Real factor = armor->adjustDamage((DamageType)type, 1.0f); + if (factor != 1.0f) damage.set(DamageTypeFlags::getNameFromSingleBit(type), num(factor)); + } + return McpJson::makeObject().set("name", TheArmorStore->friend_findArmorTemplateName(armor).str()).set("damage_taken", damage); +} + +McpJson describeUpgrade(const FactionData &data, const UpgradeTemplate *upgrade) +{ + McpJson j = McpJson::makeObject(); + j.set("name", upgrade->getUpgradeName().str()); + j.set("type", upgrade->getUpgradeType() == UPGRADE_TYPE_PLAYER ? "PLAYER" : "OBJECT"); + j.set("cost", (int)upgrade->friend_getBuildCost()).set("build_time", num(upgrade->friend_getBuildTime())); + std::map >::const_iterator where = data.researchedAt.find(upgrade); + if (where != data.researchedAt.end()) j.set("researched_at", stringArray(where->second)); + return j; +} + +McpJson describeScience(const FactionData &data, const PlayerTemplate *pt, ScienceType science) +{ + McpJson j = McpJson::makeObject(); + j.set("name", TheScienceStore->getInternalNameForScience(science).str()); + UnicodeString name, description; + if (TheScienceStore->getNameAndDescription(science, name, description) && !name.isEmpty()) { + j.set("display_name", displayText(name)); + } + const Int cost = TheScienceStore->getSciencePurchaseCost(science); + if (cost > 0) j.set("point_cost", cost); + ScienceVec prereqs; + if (TheScienceStore->friend_getPrereqSciences(science, prereqs) && !prereqs.empty()) j.set("prerequisites", scienceNames(prereqs)); + std::vector granted; + if (TheScienceStore->getGrantedUpgradeNames(science, granted) && !granted.empty()) { + McpJson upgrades = McpJson::makeArray(); + for (size_t i = 0; i < granted.size(); i++) upgrades.push(McpJson(granted[i].str())); + j.set("grants_upgrades", upgrades); + } + const ScienceVec &intrinsic = pt->getIntrinsicSciences(); + if (std::find(intrinsic.begin(), intrinsic.end(), science) != intrinsic.end()) j.set("intrinsic", true); + std::map::const_iterator offer = data.scienceOffers.find(science); + if (offer != data.scienceOffers.end()) { + j.set("purchase_rank", (int)offer->second.rank).set("purchase_button", offer->second.button); + } + + // What owning the science unlocks. + McpJson unlocks = McpJson::makeArray(); + for (size_t i = 0; i < data.objects.items.size(); i++) { + const ThingTemplate *tt = data.objects.items[i]; + for (Int p = 0; p < tt->getPrereqCount(); p++) { + const ScienceVec &needed = tt->getNthPrereq(p)->getSciencePrereqs(); + if (std::find(needed.begin(), needed.end(), science) != needed.end()) { + unlocks.push(McpJson(tt->getName().str())); + break; + } + } + } + if (unlocks.size() > 0) j.set("unlocks_objects", unlocks); + McpJson powers = McpJson::makeArray(); + for (size_t i = 0; i < data.specialPowers.items.size(); i++) { + if (data.specialPowers.items[i]->getRequiredScience() == science) powers.push(McpJson(data.specialPowers.items[i]->getName().str())); + } + if (powers.size() > 0) j.set("unlocks_special_powers", powers); + return j; +} + +McpJson describeSpecialPower(const FactionData &data, const SpecialPowerTemplate *power) +{ + McpJson j = McpJson::makeObject(); + j.set("name", power->getName().str()); + const char *typeName = SpecialPowerMaskType::getNameFromSingleBit(power->getSpecialPowerType()); + if (typeName) j.set("type", typeName); + j.set("reload_time", framesToSeconds(power->getReloadTime())); + if (power->getRequiredScience() != SCIENCE_INVALID) { + j.set("required_science", TheScienceStore->getInternalNameForScience(power->getRequiredScience()).str()); + } + if (power->isShortcutPower()) j.set("shortcut", true); + if (power->hasPublicTimer()) j.set("public_timer", true); + if (power->getCost() > 0) j.set("cost", (int)power->getCost()); + if (power->getRadiusCursorRadius() > 0) j.set("radius", num(power->getRadiusCursorRadius())); + std::map >::const_iterator users = data.powerUsers.find(power); + if (users != data.powerUsers.end()) j.set("used_by", stringArray(users->second)); + return j; +} + +McpJson describeProductionChanges(const ProductionChangeMap &changes) +{ + McpJson j = McpJson::makeObject(); + for (ProductionChangeMap::const_iterator it = changes.begin(); it != changes.end(); ++it) { + j.set(TheNameKeyGenerator->keyToName(it->first).str(), num(it->second)); + } + return j; +} + +McpJson describePlayerTemplate(const PlayerTemplate *pt) +{ + McpJson j = McpJson::makeObject(); + j.set("name", pt->getName().str()).set("side", pt->getSide().str()); + if (!pt->getBaseSide().isEmpty()) j.set("base_side", pt->getBaseSide().str()); + const std::string displayName = displayText(pt->getDisplayName()); + if (!displayName.empty()) j.set("display_name", displayName); + j.set("playable", (bool)(pt->isPlayableSide() != 0)); + j.set("start_money", (int)pt->getMoney()->countMoney()); + j.set("starting_building", pt->getStartingBuilding().str()); + McpJson units = McpJson::makeArray(); + for (Int i = 0; i < MAX_MP_STARTING_UNITS; i++) { + if (!pt->getStartingUnit(i).isEmpty()) units.push(McpJson(pt->getStartingUnit(i).str())); + } + j.set("starting_units", units); + j.set("intrinsic_sciences", scienceNames(pt->getIntrinsicSciences())); + if (pt->getIntrinsicSciencePurchasePoints() > 0) j.set("intrinsic_science_points", (int)pt->getIntrinsicSciencePurchasePoints()); + j.set("science_command_sets", McpJson::makeObject().set("rank1", pt->getPurchaseScienceCommandSetRank1().str()) + .set("rank3", pt->getPurchaseScienceCommandSetRank3().str()).set("rank8", pt->getPurchaseScienceCommandSetRank8().str())); + if (!pt->getSpecialPowerShortcutCommandSet().isEmpty()) j.set("special_power_shortcut_command_set", pt->getSpecialPowerShortcutCommandSet().str()); + + // Multipliers on cost and build time of single types (1 + the change given in the INI). + if (!pt->getProductionCostChanges().empty()) j.set("production_cost_changes", describeProductionChanges(pt->getProductionCostChanges())); + if (!pt->getProductionTimeChanges().empty()) j.set("production_time_changes", describeProductionChanges(pt->getProductionTimeChanges())); + if (!pt->getProductionVeterancyLevels().empty()) { + McpJson levels = McpJson::makeObject(); + const ProductionVeterancyMap &map = pt->getProductionVeterancyLevels(); + for (ProductionVeterancyMap::const_iterator it = map.begin(); it != map.end(); ++it) { + levels.set(TheNameKeyGenerator->keyToName(it->first).str(), TheVeterancyNames[it->second]); + } + j.set("production_veterancy", levels); + } + return j; +} + +const PlayerTemplate *requireFaction(const std::string &name) +{ + const PlayerTemplate *bySide = nullptr; + for (Int i = 0; i < ThePlayerTemplateStore->getPlayerTemplateCount(); i++) { + const PlayerTemplate *pt = ThePlayerTemplateStore->getNthPlayerTemplate(i); + if (pt == nullptr) continue; + if (pt->getName().compareNoCase(name.c_str()) == 0) return pt; + if (bySide == nullptr && pt->getSide().compareNoCase(name.c_str()) == 0) bySide = pt; + } + if (bySide == nullptr) { + mcpFail("unknown faction '%s' (see sides.list_factions; give the faction name or its side)", name.c_str()); + } + return bySide; +} + +bool wantsSection(const McpJson &args, const char *section) +{ + if (!args.has("sections")) return true; + const McpJson §ions = mcpArgArray(args, "sections"); + for (size_t i = 0; i < sections.size(); i++) { + if (sections.at(i).isString() && sections.at(i).asString() == section) return true; + } + return false; +} + +/// The tech tree and unit stats of a faction, from the loaded game data. +McpJson cmdDescribeFaction(const McpJson &args) +{ + const PlayerTemplate *pt = requireFaction(mcpArgString(args, "faction")); + const std::string scope = mcpArgString(args, "scope", "side"); + if (scope != "side" && scope != "tree") { + mcpFail("scope must be \"side\" (tech tree and the other unit and structure types of the side) or \"tree\""); + } + if (args.has("sections")) { + static const char *const known[] = { "faction", "objects", "weapons", "armors", "upgrades", "sciences", "special_powers", nullptr }; + const McpJson §ions = mcpArgArray(args, "sections"); + for (size_t i = 0; i < sections.size(); i++) { + Bool found = FALSE; + for (Int k = 0; known[k]; k++) { + if (sections.at(i).isString() && sections.at(i).asString() == known[k]) found = TRUE; + } + if (!found) mcpFail("sections may hold: faction, objects, weapons, armors, upgrades, sciences, special_powers"); + } + } + + FactionData data; + McpJson missing = McpJson::makeArray(); + std::vector starts; + starts.push_back(pt->getStartingBuilding()); + for (Int i = 0; i < MAX_MP_STARTING_UNITS; i++) { + starts.push_back(pt->getStartingUnit(i)); + } + for (size_t i = 0; i < starts.size(); i++) { + if (starts[i].isEmpty()) continue; + const ThingTemplate *tt = TheThingFactory->findTemplate(starts[i], FALSE); + if (tt) collectTechTree(data, tt); else missing.push(McpJson(starts[i].str())); + } + if (scope == "side") { + // The types of a faction need not carry the side of its player template, so the sides most + // of the tech tree uses count as well. A single borrowed type does not pull its side in. + std::map sideUse; + for (size_t i = 0; i < data.objects.items.size(); i++) { + sideUse[data.objects.items[i]->getDefaultOwningSide().str()]++; + } + std::set sides; + sides.insert(pt->getSide().str()); + for (std::map::const_iterator it = sideUse.begin(); it != sideUse.end(); ++it) { + if (!it->first.empty() && it->second * 5 >= (Int)data.objects.items.size()) sides.insert(it->first); + } + for (const ThingTemplate *tt = TheThingFactory->firstTemplate(); tt; tt = tt->friend_getNextTemplate()) { + if (isArmyType(tt) && sides.find(tt->getDefaultOwningSide().str()) != sides.end()) data.objects.add(tt); + } + } + + // Narrow the objects down to the ones asked for; the tech tree links were built from all of them. + if (args.has("templates")) { + const McpJson &names = mcpArgArray(args, "templates"); + OrderedSet chosen; + 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) chosen.add(tt); else missing.push(names.at(i)); + } + data.objects = chosen; + } + + for (size_t i = 0; i < data.objects.items.size(); i++) { + collectCommandRefs(data, data.objects.items[i]); + } + collectScienceOffers(data, pt->getPurchaseScienceCommandSetRank1(), 1); + collectScienceOffers(data, pt->getPurchaseScienceCommandSetRank3(), 3); + collectScienceOffers(data, pt->getPurchaseScienceCommandSetRank8(), 8); + collectScienceOffers(data, pt->getSpecialPowerShortcutCommandSet(), 0); + for (size_t i = 0; i < pt->getIntrinsicSciences().size(); i++) { + collectScience(data, pt->getIntrinsicSciences()[i]); + } + for (size_t i = 0; i < data.objects.items.size(); i++) { + const ThingTemplate *tt = data.objects.items[i]; + for (Int p = 0; p < tt->getPrereqCount(); p++) { + const ScienceVec &needed = tt->getNthPrereq(p)->getSciencePrereqs(); + for (size_t k = 0; k < needed.size(); k++) collectScience(data, needed[k]); + } + } + + // Describing the objects is what finds the weapons and armors, so it runs in any case. + McpJson objects = McpJson::makeArray(); + for (size_t i = 0; i < data.objects.items.size(); i++) { + objects.push(describeObject(data, data.objects.items[i])); + } + + McpJson j = McpJson::makeObject(); + if (wantsSection(args, "faction")) j.set("faction", describePlayerTemplate(pt)); + if (wantsSection(args, "objects")) j.set("objects", objects); + if (wantsSection(args, "weapons")) { + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < data.weapons.items.size(); i++) list.push(describeWeapon(data.weapons.items[i])); + j.set("weapons", list); + } + if (wantsSection(args, "armors")) { + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < data.armors.items.size(); i++) list.push(describeArmor(data.armors.items[i])); + j.set("armors", list); + } + if (wantsSection(args, "upgrades")) { + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < data.upgrades.items.size(); i++) list.push(describeUpgrade(data, data.upgrades.items[i])); + j.set("upgrades", list); + } + if (wantsSection(args, "sciences")) { + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < data.sciences.items.size(); i++) list.push(describeScience(data, pt, data.sciences.items[i])); + j.set("sciences", list); + } + if (wantsSection(args, "special_powers")) { + McpJson list = McpJson::makeArray(); + for (size_t i = 0; i < data.specialPowers.items.size(); i++) list.push(describeSpecialPower(data, data.specialPowers.items[i])); + j.set("special_powers", list); + } + j.set("counts", McpJson::makeObject().set("objects", (int)data.objects.items.size()).set("in_tech_tree", (int)data.inTechTree.size()) + .set("weapons", (int)data.weapons.items.size()).set("armors", (int)data.armors.items.size()) + .set("upgrades", (int)data.upgrades.items.size()).set("sciences", (int)data.sciences.items.size()) + .set("special_powers", (int)data.specialPowers.items.size())); + if (missing.size() > 0) j.set("unknown_templates", missing); + return j; +} + +} // namespace + +void mcpRegisterFactionCommands() +{ + mcpRegisterCommand("faction.describe", cmdDescribeFaction, "{faction,scope?=side|tree,sections?:[faction|objects|weapons|armors|upgrades|sciences|special_powers],templates?:[names]} Tech tree and unit stats of a faction from the game data: what builds what, prerequisites, cost, health, armor, weapons, movement, upgrades, sciences, special powers."); +} 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..0be75320b3c --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdGenerate.cpp @@ -0,0 +1,516 @@ +/* +** 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; + } + } +}; + +Int clampHeight(double h) +{ + const Int maxHeight = WorldHeightMap::getMaxHeightValue(); + if (h < 0) return 0; + if (h > maxHeight) return maxHeight; + return (Int)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 Int 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 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); + 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 new file mode 100644 index 00000000000..6339527aa12 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdImage.cpp @@ -0,0 +1,451 @@ +/* +** 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 "Common/MapData.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 = 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); + if (h < minH) minH = h; + 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; + } + } + 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); + j.set("white_is_raw_height", normalize ? maxH : heightLimit); + 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; + // 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()) { + 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 > WorldHeightMap::getMaxHeightValue()) h = WorldHeightMap::getMaxHeightValue(); + copy->setHeight(r.x0 + x, r.y0 + y, (Int)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 +//------------------------------------------------------------------------------------------------- + +} // namespace + +/// True if the cell is covered by a water polygon whose surface is above the terrain. +bool mcpIsCellUnderWater(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; +} + +namespace +{ + +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 = mcpIsCellUnderWater(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/McpCmdMap.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp new file mode 100644 index 00000000000..9ea0ac5cb0d --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdMap.cpp @@ -0,0 +1,443 @@ +/* +** 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/MapData.h" +#include "Common/MapObject.h" +#include "Common/WellKnownKeys.h" +#include "GameLogic/PolygonTrigger.h" + +#include +#include +#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()); + // EnableShips from the map.ini next to the map: whether the AI is to use ships here (SKIRMISH_SHIPS_ENABLED). + j.set("ships_enabled", (bool)(TheMapData->m_enableShips != 0)); + + 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(); + 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 = WorldHeightMap::getMaxHeightValue(), 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); + // 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) { + 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()); +} + +/// Sets a field of the MapData block in the map.ini next to the map, keeping the rest of the file. +void setMapIniField(const char *key, const char *value) +{ + CString mapPath = mcpDoc()->GetPathName(); + const int slash = mapPath.ReverseFind('\\'); + if (mapPath.IsEmpty() || slash < 0) { + mcpFail("save the map first: %s is kept in the map.ini next to it", key); + } + const std::string iniPath = std::string((LPCTSTR)mapPath.Left(slash + 1)) + "map.ini"; + + std::vector lines; + if (FILE *in = fopen(iniPath.c_str(), "r")) { + char buffer[1024]; + while (fgets(buffer, sizeof(buffer), in)) { + std::string line = buffer; + while (!line.empty() && (line[line.size() - 1] == '\n' || line[line.size() - 1] == '\r')) line.erase(line.size() - 1); + lines.push_back(line); + } + fclose(in); + } + + // Find the MapData block and the field in it. + const std::string newLine = std::string(" ") + key + " = " + value; + int blockStart = -1, blockEnd = -1, field = -1; + for (size_t i = 0; i < lines.size(); i++) { + std::string code = lines[i].substr(0, lines[i].find(';')); + const size_t first = code.find_first_not_of(" \t"); + code = first == std::string::npos ? "" : code.substr(first); + while (!code.empty() && (code[code.size() - 1] == ' ' || code[code.size() - 1] == '\t')) code.erase(code.size() - 1); + if (blockStart < 0) { + if (_stricmp(code.c_str(), "MapData") == 0) blockStart = (int)i; + } else if (_stricmp(code.c_str(), "End") == 0) { + blockEnd = (int)i; + break; + } else if (_strnicmp(code.c_str(), key, strlen(key)) == 0 && code.find('=') != std::string::npos) { + field = (int)i; + } + } + if (field >= 0) { + lines[field] = newLine; + } else if (blockStart >= 0 && blockEnd >= 0) { + lines.insert(lines.begin() + blockEnd, newLine); + } else { + lines.push_back("MapData"); + lines.push_back(newLine); + lines.push_back("End"); + } + + FILE *out = fopen(iniPath.c_str(), "w"); + if (out == nullptr) { + mcpFail("cannot write %s", iniPath.c_str()); + } + for (size_t i = 0; i < lines.size(); i++) { + fprintf(out, "%s\n", lines[i].c_str()); + } + fclose(out); +} + +McpJson cmdMapSetInfo(const McpJson &args) +{ + mcpDoc(); + if (args.has("ships_enabled")) { + const bool enable = mcpArgBool(args, "ships_enabled", false); + setMapIniField("EnableShips", enable ? "Yes" : "No"); + TheWriteableMapData->m_enableShips = enable; + } + 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; +} + +/// 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()); + 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(); + } + 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(pitch); + } + // 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(centerX / MAP_XY_FACTOR, centerY / 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?,ships_enabled?} Changes map settings. ships_enabled writes EnableShips to the map.ini next to the saved map."); + 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. 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/McpCmdObjects.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp new file mode 100644 index 00000000000..187977be882 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdObjects.cpp @@ -0,0 +1,389 @@ +/* +** 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 "mcp/McpUndoables.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 +{ + +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/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/McpCmdResize.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdResize.cpp new file mode 100644 index 00000000000..08468a24a4b --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdResize.cpp @@ -0,0 +1,413 @@ +/* +** 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 . +*/ + +// 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 > WorldHeightMap::getMaxHeightValue())) { + mcpFail("fill_height must be 0..%d", WorldHeightMap::getMaxHeightValue()); + } + 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/McpCmdRoute.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdRoute.cpp new file mode 100644 index 00000000000..4b7324f2e6c --- /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 > WorldHeightMap::getMaxHeightValue()) h = WorldHeightMap::getMaxHeightValue(); + if ((Int)h != map->getHeight(x, y)) { + copy->setHeight(x, y, (Int)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/McpCmdScatter.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScatter.cpp new file mode 100644 index 00000000000..26a604ab875 --- /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 = 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); + 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/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..f7a9e6db436 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdScriptViews.cpp @@ -0,0 +1,732 @@ +/* +** 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; +} + +/// Replaces the whole build list of a player, as one undo step. +McpJson cmdSetBuildList(const McpJson &args) +{ + mcpDoc(); + requireScriptEditorClosed(); + SidesList sides = *TheSidesList; + const Int side = requireSide(sides, mcpArgString(args, "player")); + const McpJson &entries = mcpArgArray(args, "entries"); + SidesInfo *info = sides.getSideInfo(side); + + Int removed = 0; + while (BuildListInfo *old = info->getBuildList()) { + info->removeFromBuildList(old); + // Deleting an entry deletes what is linked behind it. + old->setNextBuildList(nullptr); + deleteInstance(old); + removed++; + } + for (size_t i = 0; i < entries.size(); i++) { + const McpJson &fields = entries.at(i); + if (!fields.isObject()) { + mcpFail("entries[%d] must be an object with the fields of buildlist.add", (int)i); + } + mcpArgString(fields, "template"); + mcpArgNumber(fields, "x"); + mcpArgNumber(fields, "y"); + BuildListInfo *entry = newInstance(BuildListInfo); + Coord3D loc; + loc.x = loc.y = loc.z = 0; + entry->setLocation(loc); + info->addToBuildList(entry, (Int)i); + applyBuildListArgs(entry, fields); + } + commitSides(sides); + return McpJson::makeObject().set("player", sideName(sides, side).str()).set("entries", (int)entries.size()).set("replaced", removed); +} + +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.set", cmdSetBuildList, "{player,entries:[{template,x,y,...fields of buildlist.add}]} Replaces a player's whole build list in one undo step."); + 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/McpCmdSkirmish.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp new file mode 100644 index 00000000000..82e87203719 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSkirmish.cpp @@ -0,0 +1,727 @@ +/* +** 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/MapData.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); + } + // Ships: the AI builds shipyards at waypoints Shipyard_ (n from 1, without gaps), keeps its + // fleet by the area NavalPerimeter and attacks along paths labeled Naval and + // NavalFlank. Scripts ask for all of it only when the map.ini says EnableShips. + const bool shipsEnabled = TheMapData->m_enableShips != 0; + McpJson navalProblems = McpJson::makeArray(); + bool anyNaval = false; + for (size_t i = 0; i < starts.size(); i++) { + const Int n = starts[i].number; + Int shipyards = 0; + bool gap = false; + for (Int index = 1; index <= 10; index++) { + AsciiString wanted; + wanted.format("%s%d_%d", SKIRMISH_SHIPYARD, n, index); + bool found = false; + for (MapObject *obj = MapObject::getFirstMapObject(); obj && !found; obj = obj->getNext()) { + found = obj->isWaypoint() && obj->getWaypointName().compareNoCase(wanted.str()) == 0; + } + if (found) { + // The AI stops looking at the first missing number. + if (index > shipyards + 1) gap = true; + shipyards = index; + } + } + Int navalPath = 0, navalFlank = 0; + for (MapObject *obj = MapObject::getFirstMapObject(); obj; obj = obj->getNext()) { + if (!obj->isWaypoint()) continue; + const NameKeyType keys[3] = { TheKey_waypointPathLabel1, TheKey_waypointPathLabel2, TheKey_waypointPathLabel3 }; + for (Int k = 0; k < 3; k++) { + Bool exists = false; + const AsciiString label = obj->getProperties()->getAsciiString(keys[k], &exists); + AsciiString path, flank; + path.format("Naval%d", n); + flank.format("NavalFlank%d", n); + if (exists && label.compareNoCase(path.str()) == 0) navalPath++; + if (exists && label.compareNoCase(flank.str()) == 0) navalFlank++; + } + } + AsciiString perimeter; + perimeter.format("%s%d", NAVAL_PERIMETER, n); + const bool hasPerimeter = findArea(perimeter) != nullptr; + anyNaval = anyNaval || shipyards > 0 || hasPerimeter || navalPath > 0 || navalFlank > 0; + + McpJson p = McpJson::makeObject(); + p.set("shipyard_waypoints", shipyards).set("naval_perimeter", hasPerimeter); + p.set("naval_waypoints", navalPath).set("naval_flank_waypoints", navalFlank); + for (size_t k = 0; k < players.size(); k++) { + if ((Int)players.at(k).get("player").asNumber() == n) { + McpJson merged = players.at(k); + merged.set("naval", p); + McpJson rebuilt = McpJson::makeArray(); + for (size_t m = 0; m < players.size(); m++) rebuilt.push(m == k ? merged : players.at(m)); + players = rebuilt; + } + } + if (shipsEnabled) { + AsciiString msg; + if (shipyards == 0) { + msg.format("player %d has no waypoint %s%d_1: its AI cannot build a shipyard", n, SKIRMISH_SHIPYARD, n); + navalProblems.push(McpJson(msg.str())); + } + if (gap) { + msg.format("player %d: the %s%d_ waypoints have a gap; the AI stops at the first missing number", n, SKIRMISH_SHIPYARD, n); + navalProblems.push(McpJson(msg.str())); + } + if (!hasPerimeter) { + msg.format("missing area %s", perimeter.str()); + navalProblems.push(McpJson(msg.str())); + } + if (navalPath == 0) { + msg.format("player %d has no Naval%d approach path", n, n); + navalProblems.push(McpJson(msg.str())); + } + } + } + if (!shipsEnabled && anyNaval) { + navalProblems.push(McpJson("the map has shipyard waypoints, naval areas or naval paths, but its map.ini does not set EnableShips: scripts will not use them (map.set_info ships_enabled)")); + } + + 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); + j.set("ships_enabled", shipsEnabled).set("naval_problems", navalProblems); + j.set("naval_ready", shipsEnabled && navalProblems.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; +} + +//------------------------------------------------------------------------------------------------- +// 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 > WorldHeightMap::getMaxHeightValue()) h = WorldHeightMap::getMaxHeightValue(); + if ((Int)h != copy->getHeight(x, y)) { + copy->setHeight(x, y, (Int)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; for maps with EnableShips also the shipyard waypoints, naval areas and naval paths."); +} 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..c297b897fbf --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdSymmetry.cpp @@ -0,0 +1,807 @@ +/* +** 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 "mcp/McpUndoables.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 +//------------------------------------------------------------------------------------------------- + +/// 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; +}; + +//------------------------------------------------------------------------------------------------- +// 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. + 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)); + 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) { + 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); + MoveInfo *info = moves->add(obj); + info->m_newLocation = loc; + info->m_newAngle = 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/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/McpCmdTerrain.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp new file mode 100644 index 00000000000..f414e268f15 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdTerrain.cpp @@ -0,0 +1,457 @@ +/* +** 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 "mcp/McpShape.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; +} + +Int clampHeight(double h) +{ + const Int maxHeight = WorldHeightMap::getMaxHeightValue(); + if (h < 0) return 0; + if (h > maxHeight) return maxHeight; + return (Int)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; +} + +//------------------------------------------------------------------------------------------------- +// 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"); + McpShape 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); + } + // 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); + } + } + } + } + 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"); + 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"); + } + 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"); + } + 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; + // 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); +} + +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); + McpShape 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 + +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 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."); + 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..04db575c563 --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCmdWaypoints.cpp @@ -0,0 +1,412 @@ +/* +** 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"); + + 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"); + } + 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)); + + 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 + +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."); + 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..083d5b74e5e --- /dev/null +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/mcp/McpCommands.cpp @@ -0,0 +1,565 @@ +/* +** 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/file.h" +#include "Common/FileSystem.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); +} + +/// Where WorldBuilder runs, so that clients can keep their files with the game data it loaded. +McpJson cmdAppInfo(const McpJson &) +{ + char exePath[MAX_PATH]; + exePath[0] = 0; + ::GetModuleFileNameA(nullptr, exePath, sizeof(exePath)); + std::string exeDir = exePath; + const size_t slash = exeDir.find_last_of("\\/"); + if (slash != std::string::npos) exeDir.erase(slash); + char workDir[MAX_PATH]; + workDir[0] = 0; + ::GetCurrentDirectoryA(sizeof(workDir), workDir); + return McpJson::makeObject().set("exe_path", exePath).set("exe_dir", exeDir).set("working_dir", workDir); +} + +/// The text of a game data file as the game sees it, whether it lies in a folder or in an archive. +McpJson cmdReadGameFile(const McpJson &args) +{ + const std::string path = mcpArgString(args, "path"); + const Int maxBytes = mcpArgInt(args, "max_bytes", 4 * 1024 * 1024); + // Game data only: nothing outside of the Data folder. + if (_strnicmp(path.c_str(), "Data\\", 5) != 0 && _strnicmp(path.c_str(), "Data/", 5) != 0) { + mcpFail("path must start with Data\\ (a game data file, e.g. Data\\INI\\Default\\AIData.ini)"); + } + if (path.find("..") != std::string::npos || path.find(':') != std::string::npos) { + mcpFail("path must not contain '..' or ':'"); + } + File *file = TheFileSystem->openFile(path.c_str(), File::READ | File::BINARY); + if (file == nullptr) { + return McpJson::makeObject().set("path", path).set("exists", false); + } + std::string text; + char buffer[16384]; + for (;;) { + const Int got = file->read(buffer, sizeof(buffer)); + if (got <= 0) break; + text.append(buffer, got); + if ((Int)text.size() > maxBytes) { + file->close(); + mcpFail("'%s' is larger than max_bytes (%d)", path.c_str(), maxBytes); + } + } + file->close(); + return McpJson::makeObject().set("path", path).set("exists", true).set("bytes", (int)text.size()).set("text", text); +} + +void registerAllCommands() +{ + static bool registered = false; + if (registered) { + return; + } + registered = true; + mcpRegisterCommand("list_commands", cmdListCommands, "Lists all bridge commands."); + mcpRegisterCommand("app.info", cmdAppInfo, "Path and folder of the WorldBuilder executable and its working directory."); + mcpRegisterCommand("game.read_file", cmdReadGameFile, "{path,max_bytes?} Text of a game data file under Data\\, read the way the game does (folders and archives)."); + mcpRegisterMapCommands(); + mcpRegisterTerrainCommands(); + mcpRegisterObjectCommands(); + mcpRegisterWaypointCommands(); + mcpRegisterImageCommands(); + mcpRegisterSymmetryCommands(); + mcpRegisterGenerateCommands(); + mcpRegisterSkirmishCommands(); + mcpRegisterScatterCommands(); + mcpRegisterRouteCommands(); + mcpRegisterReplaceCommands(); + mcpRegisterResizeCommands(); + mcpRegisterScriptCommands(); + mcpRegisterTeamCommands(); + mcpRegisterScriptToolCommands(); + mcpRegisterScriptViewCommands(); + mcpRegisterFactionCommands(); +} + +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; +} + +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(); + + 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/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/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/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/README.md b/scripts/worldbuilder-mcp/README.md new file mode 100644 index 00000000000..ace22cd118b --- /dev/null +++ b/scripts/worldbuilder-mcp/README.md @@ -0,0 +1,144 @@ +# 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, 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 +``` + +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 +``` + +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. + +## 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. + - 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. + - 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). +- **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. + - 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. + - 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. + - 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`. +- **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. + - `faction_generate` writes a planning description of a faction to `worldbuilder_factions/` next to the WorldBuilder executable, so it stays with the game data it describes (`WB_MCP_FACTION_DIR` or `directory` name another folder, relative to the executable): `.generated.json` with the cleaned facts plus tiers, build paths, combat numbers and guessed roles; `.overrides.yaml` for hand tuning (slots, roles, phases, notes), written once and then left alone; `.doctrine.md` for prose. `faction_get` reads it back in views (overview, tree, units, naval, single types, upgrades, sciences, special powers). Ships and shipyards have roles and slots of their own, and every unit's damage is also given against ship and submarine armor. + - `ai_framework_generate` compares the AI players of the open map and writes the skeleton they share to `ai_framework.generated.json`: scripts that are identical everywhere, scripts that differ only in some parameters (kept as templates), shared flags, counters and timers with their writers and readers, team roster and hooks, object lists, and the waypoint paths and areas expected of a map. `ai_framework_get` reads it in views. + - `faction_map` finds the counterparts of one faction's types, upgrades, sciences, special powers and buttons in another; `ai_scaffold` uses it to derive a new player's AI from an existing one, keeping the shared framework scripts and translating the rest. It is a dry run unless told otherwise and lists what has no counterpart. + - `aidata_get` reads `AIData.ini`: global AI settings and per side the gatherer counts, base defense structure, science skill sets and the `SkirmishBuildList`, the base layout that decides where build scripts place structures. `buildlist_from_aidata` loads a side's layout into a map player's build list for viewing and editing; `buildlist_export` writes a player's build list as a `SkirmishBuildList` block, optionally into an `AIData.ini` (with a backup). `aidata_set_side` changes a side's `SideInfo` (gatherers, base defense, skill sets) line by line, so comments and untouched lines stay; it shows a diff and writes only when given the file to change. + - `ai_match_run` lets the game play a skirmish between AI players on its own (`generalszh.exe -aiMatch -headless`: no window, only the game logic, as fast as the machine allows; a window on request) and writes a log; It can play with the scripts open in WorldBuilder or another `.scb` file, and with a changed `AIData.ini`, without installing either. `ai_match_report` reads it: per player the build timeline, what was never built, teams started, sciences and upgrades bought, scripts and teams that never ran, and what the script engine reported. + - Ships: `map_info` shows and `map_set_info` sets `EnableShips` of the map's `map.ini`; `ai_skirmish_check` reports the shipyard waypoints, naval areas and naval paths per player; `faction_check` knows `SKIRMISH_BUILD_SHIPYARD` and wants naval scripts and teams to ask `SKIRMISH_SHIPS_ENABLED`. + - `faction_check` checks a player's teams and build scripts against that description: types the faction cannot build, structures a team needs but no script builds, build limits, foreign upgrades, sciences and buttons, and what the AI never uses. + - `faction_describe` gives a faction's tech tree and unit stats from the game data: what builds what, prerequisites, cost, build time, health, armor, weapons with damage per second, movement, upgrades, sciences and special powers. With `path` it writes a JSON file. + - `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. + - `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. + - Relative paths are resolved by the Python server, not by WorldBuilder. diff --git a/scripts/worldbuilder-mcp/aidata.py b/scripts/worldbuilder-mcp/aidata.py new file mode 100644 index 00000000000..3f062707cb1 --- /dev/null +++ b/scripts/worldbuilder-mcp/aidata.py @@ -0,0 +1,337 @@ +"""AIData.ini: the base layouts and side settings of the skirmish AI. + +A build script only says that a structure is to be built. Where it goes comes from the +SkirmishBuildList of the player's side in AIData.ini: a layout made on a map with WorldBuilder's +Build List tool, which the game moves to the player's start position. A structure without an +entry there is never built. The SideInfo blocks hold what else the AI is given per side: how many +gatherers to keep, its base defense structure and the orders in which it buys sciences. +""" + +from __future__ import annotations + +import re +from typing import Any + +FILES = ("Data\\INI\\Default\\AIData.ini", "Data\\INI\\AIData.ini") +LOCATION = re.compile(r"X:\s*(-?[0-9.]+)\s+Y:\s*(-?[0-9.]+)", re.IGNORECASE) + + +def _blocks(text: str) -> dict[str, Any]: + """The nested blocks of an INI text. A block is {name, arg, fields: {key: [values]}, blocks: [...]}.""" + root: dict[str, Any] = {"name": "", "arg": "", "fields": {}, "blocks": []} + stack = [root] + for raw in text.splitlines(): + line = raw.split(";", 1)[0].strip() + if not line: + continue + if "=" in line: + key, value = line.split("=", 1) + stack[-1]["fields"].setdefault(key.strip(), []).append(value.strip()) + elif line.lower() == "end": + if len(stack) > 1: + stack.pop() + else: + parts = line.split(None, 1) + block = {"name": parts[0], "arg": parts[1].strip() if len(parts) > 1 else "", "fields": {}, "blocks": []} + stack[-1]["blocks"].append(block) + stack.append(block) + return root + + +def _walk(block: dict[str, Any], name: str) -> list[dict[str, Any]]: + found = [b for b in block["blocks"] if b["name"].lower() == name.lower()] + for child in block["blocks"]: + if child["name"].lower() != name.lower(): + found.extend(_walk(child, name)) + return found + + +def _first(block: dict[str, Any], key: str, default: Any = None) -> Any: + for name, values in block["fields"].items(): + if name.lower() == key.lower(): + return values[0] + return default + + +def _number(text: Any) -> Any: + try: + value = float(text) + except (TypeError, ValueError): + return text + return int(value) if value == int(value) else value + + +def _yes(text: Any) -> bool: + return str(text).strip().lower() in ("yes", "true", "1") + + +def parse(text: str) -> dict[str, Any]: + """Settings, side infos and build lists of one AIData.ini text.""" + root = _blocks(text) + result: dict[str, Any] = {"settings": {}, "sides": {}, "build_lists": {}} + for block in _walk(root, "AIData"): + for key, values in block["fields"].items(): + result["settings"][key] = _number(values[0]) if values[0].lower() not in ("yes", "no") else _yes(values[0]) + for block in _walk(root, "SideInfo"): + side: dict[str, Any] = {"gatherers": {level: _number(_first(block, "ResourceGatherers" + level.capitalize(), 0)) + for level in ("easy", "normal", "hard")}} + defense = _first(block, "BaseDefenseStructure1") + if defense: + side["base_defense"] = defense + sets = sorted((b for b in block["blocks"] if b["name"].lower().startswith("skillset")), key=lambda b: b["name"]) + side["skill_sets"] = [[v for key, values in b["fields"].items() if key.lower() == "science" for v in values] for b in sets] + result["sides"][block["arg"]] = side + for block in _walk(root, "SkirmishBuildList"): + entries = [] + for structure in block["blocks"]: + if structure["name"].lower() != "structure": + continue + entry: dict[str, Any] = {"template": structure["arg"]} + where = LOCATION.search(_first(structure, "Location", "")) + entry["x"], entry["y"] = (float(where.group(1)), float(where.group(2))) if where else (0.0, 0.0) + entry["angle_deg"] = float(_number(_first(structure, "Angle", 0))) + rebuilds = _number(_first(structure, "Rebuilds", 0)) + entry["rebuilds"] = "unlimited" if rebuilds == -1 else rebuilds + entry["initially_built"] = _yes(_first(structure, "InitiallyBuilt", "No")) + if _yes(_first(structure, "AutomaticallyBuild", "No")): + entry["automatically_build"] = True + name = _first(structure, "Name") + if name: + entry["name"] = name + entries.append(entry) + result["build_lists"][block["arg"]] = entries + return result + + +def merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Later files replace settings, side infos and build lists of the same name.""" + for key in ("settings", "sides", "build_lists"): + base[key].update(override[key]) + return base + + +def for_side(data: dict[str, Any], side: str) -> dict[str, Any]: + """What AIData.ini gives one side, as stored with its faction description.""" + def find(table: dict[str, Any]) -> Any: + return next((value for name, value in table.items() if name.lower() == side.lower()), None) + + result: dict[str, Any] = {"side": side} + info = find(data["sides"]) + if info is not None: + result.update(info) + entries = find(data["build_lists"]) + if entries is not None: + counts: dict[str, int] = {} + for entry in entries: + counts[entry["template"]] = counts.get(entry["template"], 0) + 1 + result["build_list"] = counts + result["build_list_entries"] = len(entries) + return result + + +def format_build_list(side: str, entries: list[dict[str, Any]]) -> str: + """A SkirmishBuildList block as WorldBuilder's Build List export writes it.""" + lines = [";Skirmish AI Build List", f"SkirmishBuildList {side}"] + for entry in entries: + rebuilds = entry.get("rebuilds", 0) + lines.append(f" Structure {entry['template']}") + if entry.get("name"): + lines.append(f" Name = {entry['name']}") + lines.append(f" Location = X:{entry['x']:.2f} Y:{entry['y']:.2f}") + lines.append(f" Rebuilds = {-1 if rebuilds == 'unlimited' else int(rebuilds)}") + lines.append(f" Angle = {entry.get('angle_deg', 0):.2f}") + lines.append(f" InitiallyBuilt = {'Yes' if entry.get('initially_built') else 'No'}") + lines.append(f" AutomaticallyBuild = {'Yes' if entry.get('automatically_build') else 'No'}") + lines.append(f" END ;Structure {entry['template']}") + lines.append(f"END ;SkirmishBuildList {side}") + return "\n".join(lines) + "\n" + + +def replace_build_list(text: str, side: str, block: str) -> tuple[str, bool]: + """The INI text with the build list of a side replaced, or added at the end. Returns (text, replaced).""" + newline = "\r\n" if "\r\n" in text else "\n" + lines = text.split(newline) + start = end = None + depth = 0 + for index, raw in enumerate(lines): + line = raw.split(";", 1)[0].strip() + if start is None: + parts = line.split() + if len(parts) == 2 and parts[0].lower() == "skirmishbuildlist" and parts[1].lower() == side.lower(): + start = index + depth = 1 + continue + if not line or "=" in line: + continue + if line.lower() == "end": + depth -= 1 + if depth == 0: + end = index + break + else: + depth += 1 + new_lines = block.rstrip("\n").split("\n") + if start is None or end is None: + # Build lists are part of the AIData block: a new one goes in front of the End that closes it. + closing = next((i for i in range(len(lines) - 1, -1, -1) if lines[i].split(";", 1)[0].strip().lower() == "end"), None) + if closing is None: + return newline.join(lines + [""] + new_lines + [""]), False + return newline.join(lines[:closing] + new_lines + [""] + lines[closing:]), False + # The comment line the export writes above the block belongs to it. + if start > 0 and lines[start - 1].strip().lower().startswith(";skirmish ai build list"): + start -= 1 + return newline.join(lines[:start] + new_lines + lines[end + 1:]), True + + +#------------------------------------------------------------------------------------------------- +# Editing a SideInfo block in place +#------------------------------------------------------------------------------------------------- + +GATHERER_KEYS = {"easy": "ResourceGatherersEasy", "normal": "ResourceGatherersNormal", "hard": "ResourceGatherersHard"} +MAX_SKILL_SETS = 5 +MAX_SKILL_SET_SCIENCES = 35 # MAX_AI_UPGRADES of the engine: the slots of the science purchase screens +SCIENCE_LINE = re.compile(r"^(\s*)Science(\s*)=(\s*)(\S+)(.*)$", re.IGNORECASE) + + +def _code(line: str) -> str: + return line.split(";", 1)[0].strip() + + +def _find_block(lines: list[str], name: str, arg: str | None, first: int, last: int) -> tuple[int, int] | None: + """Line numbers of the header and the End of a block inside lines[first:last], or None.""" + index = first + while index < last: + parts = _code(lines[index]).split() + if parts and "=" not in _code(lines[index]) and parts[0].lower() == name.lower() and ( + arg is None or (len(parts) > 1 and parts[1].lower() == arg.lower())): + depth = 1 + for end in range(index + 1, last): + code = _code(lines[end]) + if not code or "=" in code: + continue + depth += -1 if code.lower() == "end" else 1 + if depth == 0: + return index, end + return None + index += 1 + return None + + +def _indent(line: str) -> str: + return line[:len(line) - len(line.lstrip())] + + +def _set_field(lines: list[str], start: int, end: int, key: str, value: Any) -> int: + """Sets a field of the block lines[start:end], keeping the line's layout and comment. Returns the lines added.""" + pattern = re.compile(r"^(\s*" + re.escape(key) + r"\s*=\s*)(\S+)(.*)$", re.IGNORECASE) + last_field = start + depth = 0 + for index in range(start + 1, end): + code = _code(lines[index]) + if depth == 0: + match = pattern.match(lines[index]) + if match and not lines[index].lstrip().startswith(";"): + # Keep a comment where it was when the value changes its length. + tail = match.group(3) + pad = len(match.group(2)) - len(str(value)) + if tail.strip() and pad > 0: + tail = " " * pad + tail + lines[index] = f"{match.group(1)}{value}{tail}" + return 0 + if "=" in code: + last_field = index + if code and "=" not in code: + depth += -1 if code.lower() == "end" else 1 + indent = _indent(lines[last_field]) if last_field > start else _indent(lines[start]) + " " + lines.insert(last_field + 1, f"{indent}{key} = {value}") + return 1 + + +def _set_sciences(lines: list[str], start: int, end: int, wanted: list[str]) -> None: + """Brings the Science lines of the block lines[start:end] to the wanted list with the fewest line changes.""" + import difflib + + positions = [i for i in range(start + 1, end) if SCIENCE_LINE.match(lines[i]) and not lines[i].lstrip().startswith(";")] + current = [SCIENCE_LINE.match(lines[i]).group(4) for i in positions] + indent = _indent(lines[positions[0]]) if positions else _indent(lines[start]) + " " + remove: list[int] = [] + insert: list[tuple[int, list[str]]] = [] # (after this line, new lines) + for op, a1, a2, b1, b2 in difflib.SequenceMatcher(None, current, wanted, autojunk=False).get_opcodes(): + if op == "equal": + continue + reuse = min(a2 - a1, b2 - b1) if op == "replace" else 0 + for k in range(reuse): + match = SCIENCE_LINE.match(lines[positions[a1 + k]]) + # The comment on the line described the science that stood there. + lines[positions[a1 + k]] = f"{match.group(1)}Science{match.group(2)}={match.group(3)}{wanted[b1 + k]}" + remove.extend(positions[a1 + reuse:a2]) + added = [f"{indent}Science = {name}" for name in wanted[b1 + reuse:b2]] + if added: + if a1 + reuse > 0: + anchor = positions[a1 + reuse - 1] + else: + # In front of the first science, below the comments that head the list. + anchor = positions[0] - 1 if positions else start + insert.append((anchor, added)) + for anchor, added in sorted(insert, reverse=True): + lines[anchor + 1:anchor + 1] = added + remove = [r + len(added) if r > anchor else r for r in remove] + for index in sorted(remove, reverse=True): + del lines[index] + + +def edit_side_info(text: str, side: str, gatherers: dict[str, int] | None = None, base_defense: str | None = None, + skill_sets: dict[int, list[str]] | None = None, copy_from: str | None = None) -> dict[str, Any]: + """Changes the SideInfo of a side in an AIData.ini text line by line, so that comments and + layout stay. A side without a block gets a new one, copied from copy_from if given. + Returns {text, block, created}.""" + newline = "\r\n" if "\r\n" in text else "\n" + # Split on the file's own line ending, so that every untouched line comes back as it was. + lines = text.split(newline) + found = _find_block(lines, "SideInfo", side, 0, len(lines)) + created = False + if found is None: + created = True + if copy_from: + source = _find_block(lines, "SideInfo", copy_from, 0, len(lines)) + if source is None: + raise ValueError(f"AIData.ini has no SideInfo {copy_from} to copy from") + block = lines[source[0]:source[1] + 1] + block[0] = re.sub(r"(?i)(SideInfo\s+)\S+", lambda m: m.group(1) + side, block[0], count=1) + else: + block = [" SideInfo " + side, " ResourceGatherersEasy = 2", " ResourceGatherersNormal = 2", + " ResourceGatherersHard = 2", " End"] + # After the last SideInfo; without any, in front of the End that closes AIData. + after = None + position = 0 + while True: + other = _find_block(lines, "SideInfo", None, position, len(lines)) + if other is None: + break + after, position = other[1], other[1] + 1 + if after is None: + closing = next((i for i in range(len(lines) - 1, -1, -1) if _code(lines[i]).lower() == "end"), len(lines)) + after = closing - 1 + lines[after + 1:after + 1] = [""] + block + found = _find_block(lines, "SideInfo", side, 0, len(lines)) + + start, end = found + for level, value in (gatherers or {}).items(): + if level not in GATHERER_KEYS: + raise ValueError("gatherers takes easy, normal and hard") + end += _set_field(lines, start, end, GATHERER_KEYS[level], int(value)) + if base_defense is not None: + end += _set_field(lines, start, end, "BaseDefenseStructure1", base_defense) + for number, wanted in sorted((skill_sets or {}).items()): + number = int(number) + if not 1 <= number <= MAX_SKILL_SETS: + raise ValueError(f"skill sets are numbered 1 to {MAX_SKILL_SETS}") + start, end = _find_block(lines, "SideInfo", side, 0, len(lines)) + inner = _find_block(lines, f"SkillSet{number}", None, start + 1, end) + if inner is None: + indent = _indent(lines[start]) + " " + lines[end:end] = [f"{indent}SkillSet{number}", f"{indent}End"] + inner = (end, end + 1) + _set_sciences(lines, inner[0], inner[1], list(wanted)) + start, end = _find_block(lines, "SideInfo", side, 0, len(lines)) + return {"text": newline.join(lines), "block": "\n".join(lines[start:end + 1]) + "\n", "created": created} diff --git a/scripts/worldbuilder-mcp/faction.py b/scripts/worldbuilder-mcp/faction.py new file mode 100644 index 00000000000..80594981ace --- /dev/null +++ b/scripts/worldbuilder-mcp/faction.py @@ -0,0 +1,976 @@ +"""Faction descriptions for AI authoring. + +Turns the raw output of the bridge command faction.describe into a description an AI can plan +with: noise filtered out, tech tiers, build paths, combat numbers, guessed roles. The facts go to +.generated.json and are rewritten on every run; the tuning lives in +.overrides.yaml, which is written once as a skeleton and from then on only read. +""" + +from __future__ import annotations + +import json +import os +import re +from collections import Counter +from typing import Any + +import yaml + +# Names of types that exist for cinematics, tests or as leftovers; never part of what a faction plays with. +NOISE_NAME = re.compile(r"^(CINE_|TEST|Test_|GC_|SHELL_)|Cinematic|Parade|HighDef|(OLD|Old)$|Test[A-Z]") + +CATEGORIES = ("infantry", "vehicle", "aircraft", "structure", "ship", "submarine") + +# Structure roles by KindOf, first match wins. +STRUCTURE_ROLES = ( + ("COMMANDCENTER", "command_center"), + ("FS_SUPERWEAPON", "superweapon"), + ("FS_POWER", "power"), + ("FS_SUPPLY_CENTER", "supply_center"), + ("FS_SUPPLY_DROPZONE", "income"), + ("FS_BLACK_MARKET", "income"), + ("FS_BARRACKS", "barracks"), + ("FS_WARFACTORY", "factory"), + ("FS_AIRFIELD", "airfield"), + ("FS_BASE_DEFENSE", "base_defense"), + ("FS_STRATEGY_CENTER", "tech"), + ("FS_ADVANCED_TECH", "tech"), + ("FS_TECHNOLOGY", "tech"), +) + +# Slots that hold one type, and slots that hold a list. +SINGLE_SLOTS = ("command_center", "builder", "supply_gatherer", "supply_center", "power", "barracks", "factory", + "airfield", "shipyard", "capture_unit", "hero") +LIST_SLOTS = ("tech", "base_defense", "superweapon", "income", "main_battle", "anti_air", "anti_infantry", + "anti_tank", "siege", "air_strike", "transport", + "naval_battle", "naval_anti_air", "naval_siege", "submarine", "naval_transport", "naval_support") +NAVAL_ROLES = ("naval_battle", "naval_anti_air", "naval_siege", "submarine", "naval_transport", "naval_support") + +SIEGE_RANGE = 300.0 +NO_DAMAGE_TYPES = ("STATUS", "SURRENDER", "HACK", "DEPLOY", "DISARM", "HEALING", "KILL_PILOT", "PENALTY") + +OVERLAY_UNIT_KEYS = ("role", "phase", "ai_use", "weight", "counters", "countered_by", "micro", "team_hint", "notes") + + +def paths(faction: str, directory: str) -> dict[str, str]: + base = os.path.join(os.path.abspath(directory), faction) + return {"generated": base + ".generated.json", "overlay": base + ".overrides.yaml", "doctrine": base + ".doctrine.md"} + + +def load_overlay(path: str) -> dict[str, Any]: + if not os.path.exists(path): + return {} + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + raise ValueError(f"{path}: the top level must be a mapping") + return data + + +def category_of(obj: dict[str, Any]) -> str | None: + kinds = obj["kind_of"] + if "STRUCTURE" in kinds: + return "structure" + if "AIRCRAFT" in kinds: + return "aircraft" + if "INFANTRY" in kinds: + return "infantry" + if "VEHICLE" in kinds: + return "vehicle" + return None + + +def target_kind(obj: dict[str, Any]) -> str | None: + """The kind of target a type is: like category_of, but ships and submarines apart.""" + if obj.get("naval") and category_of(obj) != "structure": + return "submarine" if "SUBMARINE" in obj["kind_of"] else "ship" + return category_of(obj) + + +def is_naval(obj: dict[str, Any], shipyards: set[str]) -> bool: + """Ships by KindOf, by moving on water only, or by coming out of a shipyard.""" + if category_of(obj) == "structure": + return False + surfaces = obj.get("locomotor", {}).get("surfaces", []) + if "SHIP" in obj["kind_of"] or "SUBMARINE" in obj["kind_of"]: + return True + if "WATER" in surfaces and "GROUND" not in surfaces and "AIR" not in surfaces: + return True + builders = obj.get("built_by", []) + return bool(builders) and all(b in shipyards for b in builders) + + +def _noise_reason(obj: dict[str, Any], forced_in: set[str], forced_out: set[str]) -> str | None: + name = obj["name"] + if name in forced_out: + return "excluded in overrides" + if name in forced_in: + return None + if NOISE_NAME.search(name) or obj.get("side") == "Test": + return "test or cinematic type" + if not obj["in_tech_tree"] and "SELECTABLE" not in obj["kind_of"]: + return "not selectable" + return None + + +def _default_set(sets: list[dict[str, Any]]) -> dict[str, Any] | None: + for entry in sets: + if not entry["conditions"]: + return entry + return sets[0] if sets else None + + +def _strip(obj: dict[str, Any], gone: set[str], known: set[str]) -> None: + """Removes dropped types from the links of an object.""" + prereqs = obj.get("prerequisites") + if prereqs and "objects" in prereqs: + groups = [[n for n in group if n not in gone] for group in prereqs["objects"]] + # A requirement no type of the faction meets cannot be met by building. + foreign = sorted({n for g in groups if g and not any(m in known for m in g) for n in g}) + if foreign: + obj["unavailable"] = "needs types the faction cannot build: " + ", ".join(foreign) + prereqs["objects"] = [g for g in groups if g] + if not prereqs["objects"]: + del prereqs["objects"] + if not prereqs: + del obj["prerequisites"] + if "built_by" in obj: + obj["built_by"] = [n for n in obj["built_by"] if n not in gone] + if not obj["built_by"]: + del obj["built_by"] + if "builds" in obj: + obj["builds"] = [b for b in obj["builds"] if b["object"] not in gone] + if not obj["builds"]: + del obj["builds"] + + +def _tiers(objects: dict[str, dict[str, Any]], starts: list[str]) -> dict[str, int]: + """Number of structures that have to be built before a type can be made.""" + tier = {name: 0 for name in starts if name in objects} + for _ in range(len(objects) + 1): + changed = False + for name, obj in objects.items(): + if name in starts or obj.get("unavailable"): + continue + needs: list[list[str]] = [] + if obj.get("built_by"): + needs.append(obj["built_by"]) + needs.extend(obj.get("prerequisites", {}).get("objects", [])) + if not needs: + continue + depth = 0 + known = True + for group in needs: + # What the player starts with costs no build step. + options = [tier[n] + (1 if category_of(objects[n]) == "structure" and n not in starts else 0) + for n in group if n in tier] + if not options: + known = False + break + depth = max(depth, min(options)) + if known and tier.get(name) != depth and (name not in tier or depth < tier[name]): + tier[name] = depth + changed = True + if not changed: + break + return tier + + +def _build_path(name: str, objects: dict[str, dict[str, Any]], tier: dict[str, int], starts: list[str]) -> list[str]: + """The structures to build before a type, taking the lowest option of every choice.""" + needed: list[str] = [] + seen: set[str] = set() + + def visit(current: str) -> None: + obj = objects[current] + groups: list[list[str]] = [] + if obj.get("built_by"): + groups.append(obj["built_by"]) + groups.extend(obj.get("prerequisites", {}).get("objects", [])) + for group in groups: + options = [n for n in group if n in tier and n in objects] + if not options: + continue + choice = min(options, key=lambda n: (tier[n], objects[n]["cost"])) + if choice in seen: + continue + seen.add(choice) + visit(choice) + if category_of(objects[choice]) == "structure" and choice not in starts: + needed.append(choice) + + seen.add(name) + visit(name) + return needed + + +def _combat(obj: dict[str, Any], weapons: dict[str, dict[str, Any]], armors: dict[str, dict[str, Any]], + reference: dict[str, str]) -> dict[str, Any] | None: + chosen = _default_set(obj.get("weapon_sets", [])) + if chosen is None: + return None + used = [weapons[n] for n in dict.fromkeys(chosen["weapons"].values()) if n in weapons] + # Weapons that disable, convert or heal deal no damage in the sense of these numbers. + used = [w for w in used if w.get("dps", 0) > 0.01 and not w["damage_type"].startswith("SUBDUAL") + and w["damage_type"] not in NO_DAMAGE_TYPES] + if not used: + return None + ground = [w for w in used if "GROUND" in w["anti"]] + air = [w for w in used if "AIRBORNE_VEHICLE" in w["anti"]] + if not ground and not air: + return None + result: dict[str, Any] = {"targets": []} + if ground: + result["targets"].append("ground") + result["range"] = max(w["range"] for w in ground) + if air: + result["targets"].append("air") + result["range_air"] = max(w["range"] for w in air) + dps_vs: dict[str, float] = {} + for cat in CATEGORIES: + if cat not in reference: + continue + pool = air if cat == "aircraft" else ground + factors = armors.get(reference.get(cat, ""), {}).get("damage_taken", {}) + best = max((w["dps"] * factors.get(w["damage_type"], 1.0) for w in pool), default=0.0) + if best > 0: + dps_vs[cat] = round(best, 1) + result["dps_vs"] = dps_vs + if obj["cost"] > 0: + result["dps_per_1000"] = {cat: round(v * 1000.0 / obj["cost"], 1) for cat, v in dps_vs.items()} + return result + + +def _guess_role(obj: dict[str, Any], naval: set[str]) -> str: + kinds = obj["kind_of"] + combat = obj.get("combat") + if category_of(obj) == "structure": + built = [b["object"] for b in obj.get("builds", [])] + if "SHIPYARD" in kinds or (built and sum(n in naval for n in built) * 2 > len(built)): + return "shipyard" + for kind, role in STRUCTURE_ROLES: + if kind in kinds: + return role + return "production" if obj.get("builds") else "structure" + if obj.get("naval"): + if "SUBMARINE" in kinds: + return "submarine" + if combat is None: + return "naval_transport" if obj.get("contain", {}).get("slots") else "naval_support" + dps = combat["dps_vs"] + ground = max(dps.get("vehicle", 0), dps.get("infantry", 0), dps.get("structure", 0), dps.get("ship", 0)) + if combat["targets"] == ["air"] or dps.get("aircraft", 0) > 3 * ground: + return "naval_anti_air" + return "naval_siege" if combat.get("range", 0) >= SIEGE_RANGE else "naval_battle" + if "DOZER" in kinds: + return "builder" + if "HARVESTER" in kinds or obj.get("supply_boxes"): + return "supply_gatherer" + if "HERO" in kinds: + return "hero" + if combat is None: + return "transport" if obj.get("contain", {}).get("slots") else "support" + dps = combat["dps_vs"] + vs_infantry, vs_vehicle = dps.get("infantry", 0), dps.get("vehicle", 0) + if combat["targets"] == ["air"] or dps.get("aircraft", 0) > 3 * max(vs_infantry, vs_vehicle, dps.get("structure", 0)): + return "anti_air" + if category_of(obj) == "aircraft": + return "air_strike" + if combat["range"] >= SIEGE_RANGE: + return "siege" + if category_of(obj) == "infantry": + return "anti_tank" if vs_vehicle > vs_infantry else "anti_infantry" + return "main_battle" if vs_vehicle >= vs_infantry else "anti_infantry" + + +def _guess_slots(objects: dict[str, dict[str, Any]]) -> dict[str, Any]: + slots: dict[str, Any] = {} + hero_capture = None + ordered = sorted(objects.values(), key=lambda o: (o.get("tier", 99), o["cost"], o["name"])) + for obj in ordered: + role = obj["role_guess"] + if obj.get("unavailable"): + continue + if role in SINGLE_SLOTS: + slots.setdefault(role, obj["name"]) + elif role in LIST_SLOTS: + slots.setdefault(role, []).append(obj["name"]) + if "HARVESTER" in obj["kind_of"] or obj.get("supply_boxes"): + slots.setdefault("supply_gatherer", obj["name"]) + if obj.get("naval"): + # Ships fill the naval slots only: a land team cannot use them. + if "air" in (obj.get("combat") or {}).get("targets", []) and role != "naval_anti_air": + slots.setdefault("naval_anti_air", []).append(obj["name"]) + continue + if "air" in (obj.get("combat") or {}).get("targets", []) and role != "anti_air" and category_of(obj) != "structure": + slots.setdefault("anti_air", []).append(obj["name"]) + buttons = [b["button"] for b in obj.get("abilities", []) + obj.get("special_powers", [])] + if category_of(obj) == "infantry" and any("Capture" in b for b in buttons): + if "HERO" in obj["kind_of"]: + hero_capture = hero_capture or obj["name"] + else: + slots.setdefault("capture_unit", obj["name"]) + if hero_capture: + slots.setdefault("capture_unit", hero_capture) + return slots + + +def derive(raw: dict[str, Any], overlay: dict[str, Any], ai_data: dict[str, Any] | None = None) -> dict[str, Any]: + """The description of a faction from the raw faction.describe output.""" + forced_in = set(overlay.get("include") or []) + forced_out = set(overlay.get("exclude") or []) + weapons = {w["name"]: w for w in raw["weapons"]} + armors = {a["name"]: a for a in raw["armors"]} + + all_buttons = sorted({b["button"] for o in raw["objects"] + for key in ("abilities", "special_powers", "upgrades", "builds") for b in o.get(key, [])}) + objects: dict[str, dict[str, Any]] = {} + other: list[dict[str, Any]] = [] + dropped: dict[str, str] = {} + for obj in raw["objects"]: + reason = _noise_reason(obj, forced_in, forced_out) + if reason: + dropped[obj["name"]] = reason + elif obj["in_tech_tree"] or obj["name"] in forced_in: + objects[obj["name"]] = obj + else: + other.append(obj) + gone = set(dropped) | {o["name"] for o in other} + for obj in objects.values(): + _strip(obj, gone, set(objects)) + + # A type the tree does not build that is named like one it does is a variant of it (AI-only copies). + names = sorted(objects, key=len, reverse=True) + others: list[dict[str, Any]] = [] + for obj in other: + entry = {"name": obj["name"], "side": obj["side"], "cost": obj["cost"], "category": category_of(obj)} + base = next((n for n in names if obj["name"].startswith(n + "_")), None) + if base: + entry["variant_of"] = base + objects[base].setdefault("variants", []).append(obj["name"]) + others.append(entry) + + faction = raw["faction"] + faction["starting_units"] = [n for n in faction["starting_units"] if n in objects] + # Something that is unavailable keeps everything only it builds unavailable too. + for _ in range(len(objects)): + spread = False + for obj in objects.values(): + builders = obj.get("built_by", []) + if builders and "unavailable" not in obj and all("unavailable" in objects[b] for b in builders if b in objects): + obj["unavailable"] = "only built by unavailable types" + spread = True + if not spread: + break + # Starting units that a structure also builds get their tier from that structure. + starts = [n for n in [faction["starting_building"]] + faction["starting_units"] + if n in objects and (n == faction["starting_building"] or not objects[n].get("built_by"))] + tier = _tiers(objects, starts) + + # Ships, by what the game data says of them and by where they are built. + shipyards = {n for n, o in objects.items() if category_of(o) == "structure" and "SHIPYARD" in o["kind_of"]} + for obj in objects.values(): + if is_naval(obj, shipyards): + obj["naval"] = True + if "SUBMARINE" in obj["kind_of"]: + obj["submarine"] = True + surfaces = obj.get("locomotor", {}).get("surfaces", []) + if "WATER" in surfaces and "GROUND" in surfaces: + obj["amphibious"] = True + + # Armor a weapon is most likely to meet, per kind of target: the commonest default armor. + reference: dict[str, str] = {} + for cat in CATEGORIES: + counts = Counter() + for obj in objects.values(): + chosen = _default_set(obj.get("armor_sets", [])) + if chosen and target_kind(obj) == cat: + counts[chosen["armor"]] += 1 + if counts: + reference[cat] = counts.most_common(1)[0][0] + + unlocks: dict[str, list[str]] = {} + for obj in objects.values(): + for group in obj.get("prerequisites", {}).get("objects", []): + for needed in group: + unlocks.setdefault(needed, []).append(obj["name"]) + + top = max(tier.values(), default=0) + naval = {n for n, o in objects.items() if o.get("naval")} + for name, obj in objects.items(): + obj["category"] = category_of(obj) + if name in tier: + obj["tier"] = tier[name] + path = _build_path(name, objects, tier, starts) + if path: + obj["build_path"] = path + obj["path_cost"] = sum(objects[n]["cost"] for n in path) + obj["path_time"] = round(sum(objects[n]["build_time"] for n in path), 1) + obj["phase_guess"] = "early" if tier[name] * 3 <= top else ("mid" if tier[name] * 3 <= top * 2 else "late") + if name in unlocks: + obj["unlocks"] = unlocks[name] + combat = _combat(obj, weapons, armors, reference) + if combat: + obj["combat"] = combat + obj["role_guess"] = _guess_role(obj, naval) + + # Ships in one place: what there is, what it needs, and what else can fight ships. + ships = sorted((o for o in objects.values() if o.get("naval")), key=lambda o: (o.get("tier", 99), o["cost"], o["name"])) + hunters = sorted((o for o in objects.values() if (o.get("combat") or {}).get("dps_per_1000", {}).get("ship")), + key=lambda o: -o["combat"]["dps_per_1000"]["ship"]) + notes = [] + for obj in ships: + if "SHIP" not in obj["kind_of"] and "SUBMARINE" not in obj["kind_of"]: + notes.append(f"{obj['name']} is built by a shipyard or moves on water only but lacks KindOf SHIP") + for name in sorted(shipyards): + if objects[name].get("unavailable"): + notes.append(f"{name} cannot be built: {objects[name]['unavailable']}") + naval_summary = { + "shipyards": sorted(shipyards), + "ships": [o["name"] for o in ships], + "amphibious": sorted(n for n, o in objects.items() if o.get("amphibious")), + "best_against_ships": [{"type": o["name"], "dps_per_1000": o["combat"]["dps_per_1000"]["ship"], "range": o["combat"].get("range"), + "naval": bool(o.get("naval"))} + for o in hunters[:8]], + "needs_of_a_map": "EnableShips in map.ini (condition SKIRMISH_SHIPS_ENABLED), waypoints Shipyard_ for " + "SKIRMISH_BUILD_SHIPYARD, area NavalPerimeter, paths Naval and NavalFlank", + "notes": notes, + } + + used_weapons = {n for o in objects.values() for s in o.get("weapon_sets", []) for n in s["weapons"].values()} + used_armors = {s["armor"] for o in objects.values() for s in o.get("armor_sets", [])} + return { + "meta": { + "faction": faction["name"], + "note": "Generated by faction_generate from the game data. Do not edit; tune in the overrides file.", + "reference_armor": reference, + "max_tier": top, + "dropped": dropped, + }, + "faction": faction, + "slots_guess": _guess_slots(objects), + "naval": naval_summary, + "objects": objects, + "other_objects": others, + "weapons": {n: w for n, w in weapons.items() if n in used_weapons}, + "armors": {n: a for n, a in armors.items() if n in used_armors}, + "upgrades": {u["name"]: u for u in raw["upgrades"]}, + "sciences": {s["name"]: s for s in raw["sciences"]}, + "special_powers": {p["name"]: p for p in raw["special_powers"]}, + "buttons": all_buttons, + "ai_data": ai_data or {}, + } + + +def _yaml_value(value: Any) -> str: + if isinstance(value, list): + return "[" + ", ".join(str(v) for v in value) + "]" + return str(value) + + +def overlay_skeleton(doc: dict[str, Any]) -> str: + """First version of the overrides file: every guess spelled out for review.""" + name = doc["meta"]["faction"] + lines = [ + f"# AI tuning for {name}. Edited by hand; faction_generate writes this file once and never again.", + f"# The facts are in {name}.generated.json. Everything below was guessed from them: review it.", + "#", + "# slots abstract roles -> the types that fill them; script templates refer to these.", + "# Ships have slots of their own (shipyard, naval_battle, naval_anti_air, naval_siege,", + "# submarine, naval_transport, naval_support) and are in no land slot.", + "# units per type, any of: role, phase (early|mid|late), ai_use (normal|limited|never; default", + "# normal), weight, counters, countered_by, micro, team_hint, notes.", + "# exclude / include types to drop from, or keep in, the description on the next generate.", + "# counterparts {name in another faction: name here} for types, upgrades, sciences, special", + "# powers and buttons, used when this faction's AI is derived from another one (ai_scaffold).", + "", + f"faction: {name}", + "exclude: []", + "include: []", + "counterparts: {}", + "", + "slots:", + ] + for slot in SINGLE_SLOTS + LIST_SLOTS: + if slot in doc["slots_guess"]: + lines.append(f" {slot}: {_yaml_value(doc['slots_guess'][slot])}") + lines += ["", "units:"] + ordered = sorted(doc["objects"].values(), key=lambda o: (o["category"] == "structure", o.get("tier", 99), o["cost"])) + for obj in ordered: + fields = [f"role: {obj['role_guess']}"] + if "phase_guess" in obj: + fields.append(f"phase: {obj['phase_guess']}") + if obj.get("unavailable"): + fields.append("ai_use: never") + lines.append(f" {obj['name']}: {{{', '.join(fields)}}}") + return "\n".join(lines) + "\n" + + +def doctrine_skeleton(doc: dict[str, Any]) -> str: + name = doc["meta"]["faction"] + return (f"# {name} doctrine\n\n" + "How this faction should be played by the AI. Free text; faction_get shows it with the overview.\n\n" + "## Identity\n\n## Opening\n\n## Economy\n\n## Army composition by phase\n\n## Defense\n\n" + "## General powers and upgrades (priority)\n\n## Per difficulty\n\n## Do not\n") + + +def generate(raw: dict[str, Any], faction: str, directory: str, ai_data: dict[str, Any] | None = None) -> dict[str, Any]: + """Writes the generated description and, the first time, the overrides and doctrine skeletons.""" + where = paths(faction, directory) + os.makedirs(os.path.dirname(where["generated"]), exist_ok=True) + overlay = load_overlay(where["overlay"]) + doc = derive(raw, overlay, ai_data) + with open(where["generated"], "w", encoding="utf-8", newline="\n") as f: + json.dump(doc, f, indent=1, ensure_ascii=False) + + result: dict[str, Any] = { + "generated": where["generated"], + "bytes": os.path.getsize(where["generated"]), + "objects": len(doc["objects"]), + "other_objects": len(doc["other_objects"]), + "dropped": len(doc["meta"]["dropped"]), + "max_tier": doc["meta"]["max_tier"], + "slots_guess": doc["slots_guess"], + "build_list_entries": doc["ai_data"].get("build_list_entries", 0), + } + if overlay: + known = set(overlay.get("units") or {}) + result["overrides"] = where["overlay"] + result["new_in_game_data"] = sorted(set(doc["objects"]) - known) + result["no_longer_in_game_data"] = sorted(known - set(doc["objects"])) + slot_names = {n for v in (overlay.get("slots") or {}).values() for n in (v if isinstance(v, list) else [v])} + result["unknown_in_slots"] = sorted(n for n in slot_names if n not in doc["objects"]) + else: + with open(where["overlay"], "w", encoding="utf-8", newline="\n") as f: + f.write(overlay_skeleton(doc)) + result["overrides_created"] = where["overlay"] + if not os.path.exists(where["doctrine"]): + with open(where["doctrine"], "w", encoding="utf-8", newline="\n") as f: + f.write(doctrine_skeleton(doc)) + result["doctrine_created"] = where["doctrine"] + untiered = sorted(n for n, o in doc["objects"].items() if "tier" not in o) + if untiered: + result["without_tier"] = untiered + return result + + +def load(faction: str, directory: str) -> dict[str, Any]: + """The generated description with the overrides merged in.""" + where = paths(faction, directory) + if not os.path.exists(where["generated"]): + raise FileNotFoundError(f"no description of {faction} in {os.path.dirname(where['generated'])}; run faction_generate first") + with open(where["generated"], encoding="utf-8") as f: + doc = json.load(f) + overlay = load_overlay(where["overlay"]) + units = overlay.get("units") or {} + for name, obj in doc["objects"].items(): + tuning = units.get(name) or {} + obj["role"] = tuning.get("role", obj["role_guess"]) + obj["phase"] = tuning.get("phase", obj.get("phase_guess")) + for key in OVERLAY_UNIT_KEYS[2:]: + if key in tuning: + obj[key] = tuning[key] + # YAML reads a bare yes or no as a boolean. + if isinstance(obj.get("ai_use"), bool): + obj["ai_use"] = "normal" if obj["ai_use"] else "never" + doc["slots"] = dict(doc["slots_guess"], **(overlay.get("slots") or {})) + doc["counterparts"] = overlay.get("counterparts") or {} + if os.path.exists(where["doctrine"]): + with open(where["doctrine"], encoding="utf-8") as f: + doc["doctrine"] = f.read() + return doc + + +def _tree_row(obj: dict[str, Any]) -> dict[str, Any]: + row = {k: obj[k] for k in ("name", "category", "role", "tier", "cost", "build_time", "built_by", "prerequisites", + "unlocks", "build_path", "path_cost", "max_simultaneous", "energy", "unavailable", + "naval") if k in obj} + if obj.get("builds"): + row["builds"] = [b["object"] for b in obj["builds"]] + if obj.get("upgrades"): + row["upgrades"] = [u["upgrade"] for u in obj["upgrades"]] + return row + + +def _unit_row(obj: dict[str, Any]) -> dict[str, Any]: + row = {k: obj[k] for k in ("name", "category", "role", "phase", "ai_use", "weight", "tier", "cost", "health") if k in obj} + if "locomotor" in obj: + row["speed"] = obj["locomotor"]["speed"] + combat = obj.get("combat") + if combat: + row.update({k: combat[k] for k in ("targets", "range", "range_air", "dps_vs") if k in combat}) + for key in ("stealth", "detector_range", "counters", "countered_by", "micro", "team_hint", "notes", "variants", + "unavailable", "naval", "submarine", "amphibious"): + if key in obj: + row[key] = obj[key] + if obj.get("contain", {}).get("slots"): + row["carries"] = obj["contain"]["slots"] + return row + + +def view(doc: dict[str, Any], what: str, names: list[str] | None = None) -> dict[str, Any]: + """One slice of a merged description (see faction_get).""" + objects = doc["objects"] + ordered = sorted(objects.values(), key=lambda o: (o.get("tier", 99), o["cost"], o["name"])) + if what == "overview": + result = {"faction": doc["faction"], "slots": doc["slots"], "max_tier": doc["meta"]["max_tier"], + "reference_armor": doc["meta"]["reference_armor"], + "objects": [{k: o[k] for k in ("name", "role", "phase", "tier", "cost", "ai_use", "unavailable") + if o.get(k) is not None} for o in ordered], + "counts": {k: len(doc[k]) for k in ("objects", "other_objects", "weapons", "upgrades", "sciences", "special_powers")}} + if doc.get("doctrine"): + result["doctrine"] = doc["doctrine"] + return result + if what == "tree": + return {"objects": [_tree_row(o) for o in ordered]} + if what == "units": + return {"reference_armor": doc["meta"]["reference_armor"], + "units": [_unit_row(o) for o in ordered if o["category"] != "structure" and not o.get("naval")], + "ships": [_unit_row(o) for o in ordered if o.get("naval")], + "defenses": [_unit_row(o) for o in ordered if o["category"] == "structure" and o.get("combat")]} + if what == "naval": + summary = doc.get("naval", {}) + slots = {k: v for k, v in doc["slots"].items() if k == "shipyard" or k in NAVAL_ROLES} + return {"shipyards": [_tree_row(objects[n]) for n in summary.get("shipyards", []) if n in objects], + "ships": [dict(_unit_row(objects[n]), build_time=objects[n]["build_time"], + prerequisites=objects[n].get("prerequisites"), build_path=objects[n].get("build_path")) + for n in summary.get("ships", []) if n in objects], + "slots": slots, + "amphibious": summary.get("amphibious", []), + "best_against_ships": summary.get("best_against_ships", []), + "reference_armor": {k: v for k, v in doc["meta"]["reference_armor"].items() if k in ("ship", "submarine")}, + "needs_of_a_map": summary.get("needs_of_a_map"), + "notes": summary.get("notes", [])} + if what == "unit": + if not names: + raise ValueError("view \"unit\" needs names") + found, unknown = [], [] + for name in names: + obj = objects.get(name) + if obj is None: + unknown.append(name) + continue + full = dict(obj) + full["weapons"] = {n: doc["weapons"][n] for s in obj.get("weapon_sets", []) for n in s["weapons"].values() if n in doc["weapons"]} + full["armors"] = {s["armor"]: doc["armors"][s["armor"]] for s in obj.get("armor_sets", []) if s["armor"] in doc["armors"]} + found.append(full) + return {"objects": found, "unknown": unknown} + if what == "ai_data": + return {"ai_data": doc.get("ai_data", {}), + "note": "build_list: how many places the side's SkirmishBuildList has per structure; a structure without one is never built"} + if what in ("upgrades", "sciences", "special_powers", "other_objects"): + values = doc[what] + return {what: list(values.values()) if isinstance(values, dict) else values} + raise ValueError("view must be overview, tree, units, naval, unit, upgrades, sciences, special_powers, other_objects or ai_data") + + +#------------------------------------------------------------------------------------------------- +# Checking a player's scripts and teams against the description +#------------------------------------------------------------------------------------------------- + +BUILD_UPGRADE_ACTIONS = ("AI_PLAYER_BUILD_UPGRADE",) +# Build actions that take the place of the structure from the side's SkirmishBuildList. +BUILD_LIST_ACTIONS = ("SKIRMISH_BUILD_BUILDING",) +# Shipyards go to the map's Shipyard_ waypoints instead. +BUILD_SHIPYARD_ACTIONS = ("SKIRMISH_BUILD_SHIPYARD",) +# The condition that is true on maps whose map.ini sets EnableShips. +SHIPS_ENABLED = "SKIRMISH_SHIPS_ENABLED" +COMMAND_BUTTON_PARAMS = ("COMMANDBUTTON", "COMMANDBUTTON_ABILITY", "COMMANDBUTTON_ALL_ABILITIES") +COMBAT_ROLES = ("main_battle", "siege", "anti_infantry", "anti_tank") +SEVERITIES = ("error", "warning", "info") + + +class _Issues: + """Findings, with equal ones folded into one entry that lists where they occur.""" + + def __init__(self, max_where: int) -> None: + self.max_where = max_where + self.entries: dict[tuple[str, str, str], list[str]] = {} + + def add(self, severity: str, kind: str, message: str, where: str) -> None: + places = self.entries.setdefault((severity, kind, message), []) + if where not in places: + places.append(where) + + def result(self) -> tuple[dict[str, int], list[dict[str, Any]]]: + counts = {s: 0 for s in SEVERITIES} + listed = [] + ordered = sorted(self.entries.items(), key=lambda e: (SEVERITIES.index(e[0][0]), e[0][1], e[0][2])) + for (severity, kind, message), places in ordered: + counts[severity] += 1 + listed.append({"severity": severity, "kind": kind, "message": message, "count": len(places), + "where": places[:self.max_where]}) + return counts, listed + + +def _walk_params(node: Any, script: str, found: list[tuple[str, str, str, str]]) -> None: + """Collects (script, action or condition type, parameter type, value) for every named value.""" + if isinstance(node, dict): + if "conditions" in node or "actions" in node: + script = node.get("name", script) + if "type" in node and isinstance(node.get("params"), list): + for param in node["params"]: + if isinstance(param, dict) and isinstance(param.get("value"), str): + found.append((script, node["type"], param["type"], param["value"])) + for value in node.values(): + _walk_params(value, script, found) + elif isinstance(node, list): + for value in node: + _walk_params(value, script, found) + + +def _team_units(team: dict[str, Any]) -> list[tuple[str, int, int]]: + units = [] + for slot in range(1, 8): + name = team.get(f"teamUnitType{slot}", "") + low, high = team.get(f"teamUnitMinCount{slot}", 0), team.get(f"teamUnitMaxCount{slot}", 0) + if name and name != "" and (low > 0 or high > 0): + units.append((name, low, high)) + return units + + +def _describe_unknown(doc: dict[str, Any], name: str) -> tuple[str, str]: + """Severity and wording for a type the faction description does not hold.""" + for other in doc["other_objects"]: + if other["name"] == name: + hint = f" (variant of {other['variant_of']})" if "variant_of" in other else "" + return "warning", f"{name} is a type of the side that nothing in the tech tree builds{hint}" + if name in doc["meta"]["dropped"]: + return "warning", f"{name} was left out of the description: {doc['meta']['dropped'][name]}" + return "error", f"{name} is not a type of {doc['meta']['faction']}" + + +def check(doc: dict[str, Any], player: str, dump: dict[str, Any], build_order: dict[str, Any], + include_teams: bool = False, max_where: int = 8) -> dict[str, Any]: + """Compares the scripts and teams of a player with what its faction can do.""" + objects = doc["objects"] + issues = _Issues(max_where) + start = doc["faction"]["starting_building"] + + # ---- what the build scripts build + built: dict[str, str] = {} + researched: dict[str, str] = {} + from_list: dict[str, str] = {} + as_shipyard: dict[str, str] = {} + for step in build_order.get("steps", []): + for build in step["builds"]: + what = build.get("what") + if not what: + continue + if build["action"] in BUILD_UPGRADE_ACTIONS: + researched.setdefault(what, step["script"]) + else: + built.setdefault(what, step["script"]) + if build["action"] in BUILD_LIST_ACTIONS: + from_list.setdefault(what, step["script"]) + elif build["action"] in BUILD_SHIPYARD_ACTIONS: + as_shipyard.setdefault(what, step["script"]) + + # Scripts by name, and which of them ask whether the map has ships. + scripts_by_name: dict[str, dict[str, Any]] = {} + for entry in dump.get("players", []): + if entry.get("player") == player: + for script in entry.get("scripts", []) + [s for g in entry.get("groups", []) for s in g.get("scripts", [])]: + scripts_by_name.setdefault(script["name"], script) + + def asks_for_ships(script_name: str) -> bool: + script = scripts_by_name.get(script_name) + return bool(script) and any(c.get("type") == SHIPS_ENABLED for clause in script.get("conditions", []) for c in clause) + + for what, script in as_shipyard.items(): + obj = objects.get(what) + if obj is not None and "SHIPYARD" not in obj["kind_of"]: + issues.add("error", "build_shipyard_wrong_type", f"{what} is no shipyard (KindOf SHIPYARD); SKIRMISH_BUILD_SHIPYARD does not build it", + f"script {script}") + if not asks_for_ships(script): + issues.add("info", "build_shipyard_unguarded", + f"builds a shipyard without the condition {SHIPS_ENABLED}; on maps without ships the action finds no place", + f"script {script}") + for what, script in from_list.items(): + if "SHIPYARD" in objects.get(what, {}).get("kind_of", []): + issues.add("error", "build_shipyard_wrong_action", + f"{what} is a shipyard: SKIRMISH_BUILD_BUILDING has no place for it, use SKIRMISH_BUILD_SHIPYARD", f"script {script}") + has_build_scripts = bool(built) + standing = set(built) | {start} + + for what, script in built.items(): + obj = objects.get(what) + where = f"script {script}" + if obj is None: + # Building another faction's structure can be meant (captured builders), so it is no error. + issues.add("warning", "build_foreign_type", _describe_unknown(doc, what)[1], where) + continue + if obj.get("unavailable"): + issues.add("error", "build_unavailable", f"{what} cannot be built: {obj['unavailable']}", where) + if obj["category"] != "structure": + issues.add("error", "build_not_structure", f"{what} is not a structure", where) + for needed in obj.get("build_path", []): + if needed not in standing: + issues.add("error", "build_prerequisite_never_built", f"{what} needs {needed}, which no build script builds", where) + if obj.get("ai_use") == "never": + issues.add("warning", "build_ai_use_never", f"{what} is marked ai_use: never", where) + + for what, script in researched.items(): + upgrade = doc["upgrades"].get(what) + where = f"script {script}" + if upgrade is None: + issues.add("warning", "upgrade_foreign", f"{what} is not an upgrade of {doc['meta']['faction']}", where) + elif has_build_scripts and upgrade.get("researched_at") and not any(n in standing for n in upgrade["researched_at"]): + issues.add("error", "upgrade_no_structure", + f"{what} is researched at {', '.join(upgrade['researched_at'][:3])}, which no build script builds", where) + + if has_build_scripts: + for slot in ("power", "supply_center", "barracks", "factory"): + name = doc["slots"].get(slot) + if isinstance(name, str) and name in objects and name not in standing: + issues.add("info", "slot_never_built", f"the {slot} ({name}) is never built", "build order") + energy = sum(objects[n].get("energy", 0) for n in standing if n in objects) + if energy < 0 and doc["slots"].get("power"): + issues.add("info", "power_deficit", f"building each structure once leaves power at {energy}", "build order") + + # ---- the base layout and side settings of AIData.ini + ai_data = doc.get("ai_data") or {} + places = ai_data.get("build_list") + side = ai_data.get("side", doc["faction"].get("side", "")) + unplaced: list[str] = [] + if ai_data: + if places is None: + if from_list: + issues.add("error", "build_list_missing", f"AIData.ini has no SkirmishBuildList for side {side}: build scripts have nowhere to build", + "AIData.ini") + else: + for what, script in from_list.items(): + if what not in places: + issues.add("error", "build_no_place", f"{what} has no place in SkirmishBuildList {side}, so the build action does nothing", + f"script {script}") + if not any("COMMANDCENTER" in objects.get(n, {}).get("kind_of", []) for n in places): + issues.add("error", "build_list_no_command_center", + f"SkirmishBuildList {side} has no command center; the layout cannot be moved to the start position", "AIData.ini") + for name in places: + obj = objects.get(name) + if obj is None: + issues.add("warning", "build_list_foreign_type", f"SkirmishBuildList {side}: " + _describe_unknown(doc, name)[1], "AIData.ini") + elif obj.get("unavailable"): + issues.add("warning", "build_list_unavailable", f"SkirmishBuildList {side}: {name} cannot be built: {obj['unavailable']}", "AIData.ini") + unplaced = sorted(n for n in places if n not in built) if has_build_scripts else [] + defense = ai_data.get("base_defense") + if defense and defense not in objects: + issues.add("warning", "side_base_defense_foreign", f"SideInfo {side}: base defense " + _describe_unknown(doc, defense)[1], "AIData.ini") + if "skill_sets" not in ai_data: + issues.add("warning", "side_info_missing", f"AIData.ini has no SideInfo for side {side} (gatherers, base defense, skill sets)", "AIData.ini") + for number, sciences in enumerate(ai_data.get("skill_sets", []), 1): + for science in sciences: + if science not in doc["sciences"]: + issues.add("warning", "skill_set_foreign_science", f"{science} is not a science of the faction", f"SideInfo {side}, SkillSet{number}") + + # ---- teams + used_types: set[str] = set() + team_rows = [] + teams = [t for t in dump.get("teams", []) if t.get("teamOwner") == player] + for team in teams: + where = f"team {team['teamName']}" + units = _team_units(team) + row: dict[str, Any] = {"name": team["teamName"], "cost_min": 0, "cost_max": 0, "tier": 0, "roles": [], "needs": []} + hits_air = False + speeds = [] + naval = set() + for name, low, high in units: + used_types.add(name) + if low > high: + issues.add("error", "team_min_over_max", f"{name}: minimum {low} is above maximum {high}", where) + obj = objects.get(name) + if obj is None: + severity, message = _describe_unknown(doc, name) + issues.add(severity, "team_unknown_type", message, where) + continue + if obj.get("unavailable"): + issues.add("error", "team_unavailable", f"{name} cannot be built: {obj['unavailable']}", where) + continue + if obj["category"] == "structure": + issues.add("error", "team_structure", f"{name} is a structure", where) + continue + if "tier" not in obj or (not obj.get("built_by") and name not in doc["faction"]["starting_units"]): + issues.add("error", "team_nothing_builds", f"nothing builds {name}", where) + continue + if obj.get("ai_use") == "never": + issues.add("warning", "team_ai_use_never", f"{name} is marked ai_use: never", where) + limit = obj.get("max_simultaneous") + if limit and low > limit: + issues.add("error", "team_over_build_limit", f"needs {low} {name} but only {limit} may exist", where) + for needed in obj.get("build_path", []): + if needed not in row["needs"]: + row["needs"].append(needed) + if has_build_scripts and needed not in standing: + issues.add("error", "team_structure_never_built", f"{name} needs {needed}, which no build script builds", where) + row["cost_min"] += obj["cost"] * low + row["cost_max"] += obj["cost"] * high + row["tier"] = max(row["tier"], obj["tier"]) + if obj["role"] not in row["roles"]: + row["roles"].append(obj["role"]) + hits_air = hits_air or "air" in (obj.get("combat") or {}).get("targets", []) + # An amphibious transport belongs with the land units it carries. + naval.add(bool(obj.get("naval")) and not obj.get("amphibious")) + if obj["category"] != "aircraft" and obj.get("locomotor", {}).get("speed", 0) > 0: + speeds.append(obj["locomotor"]["speed"]) + row["anti_air"] = hits_air + if True in naval: + row["naval"] = True + # A team of ships can only be built on maps with ships; its production condition should say so. + condition = team.get("teamProductionCondition", "") + if not asks_for_ships(condition): + issues.add("info", "team_ships_unguarded", + f"team of ships whose production condition does not ask {SHIPS_ENABLED}; it is tried on every map", where) + if len(naval) > 1: + issues.add("warning", "team_land_and_sea", "mixes ships with land or air units", where) + if sum(high for _, _, high in units) >= 4 and not hits_air and any(r in COMBAT_ROLES for r in row["roles"]): + issues.add("info", "team_no_anti_air", "combat team of 4 or more units with nothing that can hit aircraft", where) + if len(speeds) > 1 and max(speeds) > 2 * min(speeds): + issues.add("info", "team_speed_spread", "fastest unit is more than twice as fast as the slowest", where) + if not units: + issues.add("info", "team_empty", "team without units", where) + team_rows.append(row) + + # ---- named values in scripts + refs: list[tuple[str, str, str, str]] = [] + for entry in dump.get("players", []): + if entry.get("player") == player: + _walk_params(entry, "", refs) + buttons = set(doc.get("buttons", [])) + for script, _, kind, value in refs: + where = f"script {script}" + if kind == "UPGRADE" and value not in doc["upgrades"]: + issues.add("info", "ref_upgrade_foreign", f"{value} is not an upgrade of the faction", where) + elif kind == "SPECIAL_POWER" and value not in doc["special_powers"]: + issues.add("warning", "ref_special_power_foreign", f"{value} is not a special power of the faction", where) + elif kind == "SCIENCE" and value not in doc["sciences"]: + issues.add("warning", "ref_science_foreign", f"{value} is not a science of the faction", where) + elif kind in COMMAND_BUTTON_PARAMS and value not in buttons: + issues.add("warning", "ref_button_foreign", f"no type of the faction has the button {value}", where) + + # ---- what the faction has but the AI never uses + def usable(obj: dict[str, Any]) -> bool: + return "tier" in obj and not obj.get("unavailable") and obj.get("ai_use") != "never" + + unused_units = sorted(n for n, o in objects.items() + if o["category"] != "structure" and o.get("combat") and usable(o) and n not in used_types) + never_built = sorted(n for n, o in objects.items() + if o["category"] == "structure" and usable(o) and n not in standing) if has_build_scripts else [] + mentioned = {value for _, _, kind, value in refs if kind == "UPGRADE"} + unused_upgrades = sorted(n for n in doc["upgrades"] if n not in researched and n not in mentioned) + + counts, listed = issues.result() + result: dict[str, Any] = { + "player": player, + "faction": doc["meta"]["faction"], + "checked": {"teams": len(teams), "build_scripts": len(build_order.get("steps", [])), + "structures_built": len(built), "upgrades_researched": len(researched)}, + "issue_counts": counts, + "issues": listed, + "unused": {"combat_units_in_no_team": unused_units, "structures_never_built": never_built, + "upgrades_never_mentioned": unused_upgrades, "build_list_places_never_used": unplaced}, + } + if not has_build_scripts: + result["note"] = "No script of the player builds structures, so nothing was checked against a build order." + if include_teams: + result["teams"] = team_rows + return result diff --git a/scripts/worldbuilder-mcp/framework.py b/scripts/worldbuilder-mcp/framework.py new file mode 100644 index 00000000000..8bb4792931c --- /dev/null +++ b/scripts/worldbuilder-mcp/framework.py @@ -0,0 +1,427 @@ +"""The framework a set of AI players shares. + +AI script sets are written as one skeleton repeated per faction: the same groups, scripts, flags, +timers and team roster, with the faction's types filled in. This module finds that skeleton by +comparing the players of a scripts.dump with each other: which scripts every player has and +where their parameters differ, which flags and counters they pass state through, which scripts +teams hook into, which object lists, waypoint paths and areas they expect. A new faction AI that +keeps to it plugs into the rest. +""" + +from __future__ import annotations + +import json +import os +import re +from collections import Counter +from typing import Any + +TAG = "" +PLAYER = "" + +SYMBOL_KINDS = {"FLAG": "flag", "COUNTER": "counter"} +MAP_KINDS = ("TRIGGER_AREA", "WAYPOINT", "WAYPOINT_PATH", "SKIRMISH_WAYPOINT_PATH") +SCRIPT_REF_KINDS = ("SCRIPT", "SCRIPT_SUBROUTINE") +TEAM_HOOKS = ("teamProductionCondition", "teamOnCreateScript", "teamOnIdleScript", "teamOnDestroyedScript", + "teamEnemySightedScript", "teamAllClearScript", "teamOnUnitDestroyedScript", "teamGenericScriptHook0", + "teamGenericScriptHook1", "teamGenericScriptHook2", "teamGenericScriptHook3", "teamGenericScriptHook4", + "teamGenericScriptHook5", "teamGenericScriptHook6", "teamGenericScriptHook7") +TEAM_SETTINGS = ("teamProductionPriority", "teamMaxInstances", "teamIsSingleton", "teamExecutesActionsOnCreate", + "teamAggressiveness", "teamIsAIRecruitable", "teamIsBaseDefense", "teamIsPerimeterDefense", + "teamAutoReinforce", "teamInitialIdleFrames", "teamDestroyedThreshold") + + +def paths(directory: str) -> dict[str, str]: + base = os.path.join(os.path.abspath(directory), "ai_framework") + return {"generated": base + ".generated.json", "notes": base + ".notes.md"} + + +def _player_tag(teams: list[dict[str, Any]]) -> str: + """The word a player's own names start with (team 'SEC Attack 1' -> SEC).""" + words = Counter(t["teamName"].split(" ")[0] for t in teams if " " in t["teamName"]) + if not words: + return "" + word, count = words.most_common(1)[0] + return word if count * 2 >= len(teams) else "" + + +class _Names: + """Takes what is particular to one player out of names, so that players can be compared.""" + + def __init__(self, player: str, tag: str) -> None: + self.player = player + self.tag = tag + self.tag_word = re.compile(r"(? str: + if text == self.player: + return PLAYER + if self.tag_word: + text = self.tag_word.sub(TAG, text) + return text + + +def _items(script: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """The conditions and actions of a script with a label for each.""" + found = [] + for c, clause in enumerate(script.get("conditions", [])): + for i, condition in enumerate(clause): + found.append((f"condition {c + 1}.{i + 1}", condition)) + for i, action in enumerate(script.get("actions", [])): + found.append((f"action {i + 1}", action)) + for i, action in enumerate(script.get("false_actions", [])): + found.append((f"false action {i + 1}", action)) + return found + + +def _shape(script: dict[str, Any]) -> tuple: + return (tuple(tuple(c["type"] for c in clause) for clause in script.get("conditions", [])), + tuple(a["type"] for a in script.get("actions", [])), + tuple(a["type"] for a in script.get("false_actions", [])), + script.get("subroutine", False)) + + +def _flags(script: dict[str, Any]) -> dict[str, Any]: + flags = {k: script[k] for k in ("active", "one_shot", "subroutine") if k in script} + flags["difficulty"] = "".join(letter for key, letter in (("easy", "E"), ("normal", "N"), ("hard", "H")) if script.get(key, True)) + if script.get("delay_seconds"): + flags["delay_seconds"] = script["delay_seconds"] + return flags + + +def _player_scripts(entry: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """(group name, script) for every script of a player, in evaluation order.""" + found = [("", s) for s in entry.get("scripts", [])] + for group in entry.get("groups", []): + found.extend((group["name"], s) for s in group.get("scripts", [])) + return found + + +def _template(script: dict[str, Any], names: _Names, holes: dict[tuple[str, int], int]) -> dict[str, Any]: + """A script with the player's own names taken out and the varying parameters as ${n}.""" + out: dict[str, Any] = {"name": names(script["name"])} + out.update({k: script[k] for k in ("active", "one_shot", "subroutine", "easy", "normal", "hard", "delay_seconds") if k in script}) + + def convert(label: str, item: dict[str, Any]) -> dict[str, Any]: + params = [] + for index, param in enumerate(item.get("params", [])): + value = param.get("value") + if (label, index) in holes: + value = "${%d}" % holes[(label, index)] + elif isinstance(value, str): + value = names(value) + params.append({"type": param["type"], "value": value}) + return {"type": item["type"], "params": params} + + out["conditions"] = [[convert(f"condition {c + 1}.{i + 1}", cond) for i, cond in enumerate(clause)] + for c, clause in enumerate(script.get("conditions", []))] + out["actions"] = [convert(f"action {i + 1}", a) for i, a in enumerate(script.get("actions", []))] + if script.get("false_actions"): + out["false_actions"] = [convert(f"false action {i + 1}", a) for i, a in enumerate(script["false_actions"])] + return out + + +def extract(dump: dict[str, Any], players: list[str] | None = None) -> dict[str, Any]: + """The framework shared by the players of a dump (all of them, or the named ones).""" + entries = [e for e in dump["players"] if (players is None or e["player"] in players) + and (e.get("scripts") or any(g.get("scripts") for g in e.get("groups", [])))] + if len(entries) < 2: + raise ValueError("a framework needs at least two players with scripts to compare") + order = [e["player"] for e in entries] + total = len(order) + teams_of = {p: [t for t in dump.get("teams", []) if t.get("teamOwner") == p] for p in order} + names = {p: _Names(p, _player_tag(teams_of[p])) for p in order} + + # ---- scripts by normalized name + scripts: dict[str, dict[str, tuple[str, dict[str, Any]]]] = {} + duplicates: dict[str, list[str]] = {} + group_layout: dict[str, dict[str, int]] = {} + group_order: list[str] = [] + for entry in entries: + player = entry["player"] + norm = names[player] + for group in entry.get("groups", []): + key = norm(group["name"]) + if key not in group_layout: + group_layout[key] = {} + group_order.append(key) + group_layout[key][player] = len(group.get("scripts", [])) + for group, script in _player_scripts(entry): + key = norm(script["name"]) + per_player = scripts.setdefault(key, {}) + if player in per_player: + duplicates.setdefault(key, []).append(player) + else: + per_player[player] = (norm(group), script) + + classes: dict[str, dict[str, Any]] = {} + for key, per_player in scripts.items(): + present = [p for p in order if p in per_player] + info: dict[str, Any] = {"group": Counter(g for g, _ in per_player.values()).most_common(1)[0][0]} + if len(present) < total: + info["players"] = present + if len(present) == 1: + info["class"] = "specific" + classes[key] = info + continue + shapes = Counter(_shape(per_player[p][1]) for p in present) + if len(shapes) > 1: + info["class"] = "divergent" if len(present) == total else "partial" + if len(present) == total: + common = shapes.most_common(1)[0][0] + info["differs_in"] = [p for p in present if _shape(per_player[p][1]) != common] + classes[key] = info + continue + + # Same structure everywhere: find the parameters that differ. + reference = per_player[present[0]][1] + varies = [] + holes: dict[tuple[str, int], int] = {} + per_item = {p: _items(per_player[p][1]) for p in present} + for position, (label, item) in enumerate(per_item[present[0]]): + for index, param in enumerate(item.get("params", [])): + values = {} + for p in present: + other = per_item[p][position][1].get("params", []) + value = other[index].get("value") if index < len(other) else None + values[p] = names[p](value) if isinstance(value, str) else value + if len({json.dumps(v, sort_keys=True) for v in values.values()}) > 1: + holes[(label, index)] = len(varies) + varies.append({"at": f"{label}, parameter {index + 1}", "of": item["type"], "type": param["type"], "values": values}) + flag_sets = {p: _flags(per_player[p][1]) for p in present} + if len({json.dumps(f, sort_keys=True) for f in flag_sets.values()}) > 1: + info["flags_differ"] = {p: f for p, f in flag_sets.items()} + if len(present) < total: + info["class"] = "partial" + else: + info["class"] = "parameterized" if varies else "framework" + info.update(_flags(reference)) + if varies: + info["varies"] = varies + info["template"] = _template(reference, names[present[0]], holes) + classes[key] = info + + # ---- flags, counters and timers: who writes, who reads + symbols: dict[tuple[str, str], dict[str, Any]] = {} + script_refs: dict[str, dict[str, set[str]]] = {} + map_names: dict[tuple[str, str], set[str]] = {} + lists: dict[str, dict[str, list[str]]] = {} + priority_sets: dict[str, set[str]] = {} + for entry in entries: + player = entry["player"] + norm = names[player] + for _, script in _player_scripts(entry): + owner = norm(script["name"]) + for label, item in _items(script): + reading = label.startswith("condition") + params = item.get("params", []) + if item["type"] == "OBJECTLIST_ADDOBJECTTYPE" and len(params) >= 2: + members = lists.setdefault(norm(str(params[0].get("value"))), {}).setdefault(player, []) + if params[1].get("value") not in members: + members.append(params[1].get("value")) + for param in params: + value = param.get("value") + if not isinstance(value, str) or not value: + continue + kind = param["type"] + if kind in SYMBOL_KINDS: + symbol = symbols.setdefault((SYMBOL_KINDS[kind], norm(value)), {"players": set(), "written_in": set(), "read_in": set(), "timer": False}) + symbol["players"].add(player) + symbol["read_in" if reading else "written_in"].add(owner) + if "TIMER" in item["type"]: + symbol["timer"] = True + elif kind in SCRIPT_REF_KINDS: + ref = script_refs.setdefault(norm(value), {"players": set(), "from": set(), "how": set()}) + ref["players"].add(player) + ref["from"].add(owner) + ref["how"].add(item["type"]) + elif kind in MAP_KINDS: + map_names.setdefault((kind, value), set()).add(player) + elif kind == "ATTACK_PRIORITY_SET": + priority_sets.setdefault(norm(value), set()).add(player) + + def scope(count: int) -> str: + return "all" if count == total else ("one" if count == 1 else "some") + + symbol_list = [] + for (kind, name), data in sorted(symbols.items()): + row: dict[str, Any] = {"name": name, "kind": "timer" if data["timer"] else kind, "players": scope(len(data["players"])), + "written_in": sorted(data["written_in"]), "read_in": sorted(data["read_in"])} + if len(data["players"]) not in (1, total): + row["player_names"] = [p for p in order if p in data["players"]] + if not data["read_in"]: + row["note"] = "written but never read" + elif not data["written_in"]: + row["note"] = "read but never written by a script" + symbol_list.append(row) + + # ---- teams: the roster and the scripts they hook into + roster: dict[str, dict[str, dict[str, Any]]] = {} + hooks: dict[tuple[str, str], dict[str, int]] = {} + for player in order: + norm = names[player] + for team in teams_of[player]: + roster.setdefault(norm(team["teamName"]), {})[player] = team + for hook in TEAM_HOOKS: + if team.get(hook): + per = hooks.setdefault((hook, norm(team[hook])), {}) + per[player] = per.get(player, 0) + 1 + team_list = [] + for name, per_player in sorted(roster.items()): + present = [p for p in order if p in per_player] + reference = per_player[present[0]] + row = {"name": name, "players": scope(len(present))} + if len(present) not in (1, total): + row["player_names"] = present + for key in TEAM_SETTINGS: + values = {p: per_player[p].get(key) for p in present} + if any(v is not None for v in values.values()): + row[key] = reference.get(key) if len({json.dumps(v) for v in values.values()}) == 1 else values + row["hooks"] = {h: names[present[0]](reference[h]) for h in TEAM_HOOKS if reference.get(h)} + row["units"] = {p: [f"{t.get(f'teamUnitMinCount{i}', 0)}-{t.get(f'teamUnitMaxCount{i}', 0)} {t[f'teamUnitType{i}']}" + for i in range(1, 8) if t.get(f"teamUnitType{i}") and t.get(f"teamUnitMaxCount{i}", 0) > 0] + for p, t in per_player.items()} + team_list.append(row) + hook_list = [{"hook": hook, "script": script, "players": scope(len(per)), "teams": sum(per.values()), + "script_class": classes.get(script, {}).get("class", "missing")} + for (hook, script), per in sorted(hooks.items())] + + by_class = Counter(info["class"] for info in classes.values()) + return { + "meta": { + "note": "Generated by ai_framework_generate by comparing the players' scripts. Do not edit; notes go into ai_framework.notes.md.", + "players": order, + "tags": {p: names[p].tag for p in order}, + "placeholders": {TAG: "the word a player's own teams, groups and scripts start with", PLAYER: "the player's name"}, + "script_classes": dict(by_class), + "duplicate_script_names": {k: v for k, v in sorted(duplicates.items())}, + }, + "groups": [{"name": g, "players": scope(len(group_layout[g])), "scripts": group_layout[g]} for g in group_order], + "scripts": classes, + "symbols": symbol_list, + "script_calls": [{"script": name, "players": scope(len(ref["players"])), "how": sorted(ref["how"]), "from": sorted(ref["from"]), + "script_class": classes.get(name, {}).get("class", "missing")} + for name, ref in sorted(script_refs.items())], + "team_hooks": hook_list, + "teams": team_list, + "object_lists": [{"name": name, "players": scope(len(per)), "members": per} for name, per in sorted(lists.items())], + "map_names": [{"kind": kind, "name": name, "players": scope(len(who))} for (kind, name), who in sorted(map_names.items())], + "attack_priority_sets": [{"name": name, "players": scope(len(who))} for name, who in sorted(priority_sets.items())], + } + + +def notes_skeleton() -> str: + return ("# AI framework notes\n\n" + "What the generated comparison cannot know: why the skeleton is built the way it is. Free text;\n" + "ai_framework_get shows it with the overview.\n\n" + "## How an attack wave is started\n\n## How difficulty is switched\n\n## What every faction must provide\n\n" + "## What a faction may add\n\n## Known quirks\n") + + +def generate(dump: dict[str, Any], directory: str, players: list[str] | None = None) -> dict[str, Any]: + where = paths(directory) + os.makedirs(os.path.dirname(where["generated"]), exist_ok=True) + doc = extract(dump, players) + with open(where["generated"], "w", encoding="utf-8", newline="\n") as f: + json.dump(doc, f, indent=1, ensure_ascii=False) + result: dict[str, Any] = {"generated": where["generated"], "bytes": os.path.getsize(where["generated"])} + if not os.path.exists(where["notes"]): + with open(where["notes"], "w", encoding="utf-8", newline="\n") as f: + f.write(notes_skeleton()) + result["notes_created"] = where["notes"] + result.update(view(doc, "overview")) + return result + + +def load(directory: str) -> dict[str, Any]: + where = paths(directory) + if not os.path.exists(where["generated"]): + raise FileNotFoundError(f"no AI framework in {os.path.dirname(where['generated'])}; run ai_framework_generate first") + with open(where["generated"], encoding="utf-8") as f: + doc = json.load(f) + if os.path.exists(where["notes"]): + with open(where["notes"], encoding="utf-8") as f: + doc["notes"] = f.read() + return doc + + +def _matches(text: str, pattern: str | None) -> bool: + return pattern is None or pattern.lower() in text.lower() + + +def view(doc: dict[str, Any], what: str, filter: str | None = None, names: list[str] | None = None) -> dict[str, Any]: + """One slice of the framework (see ai_framework_get).""" + if what == "overview": + result = {"players": doc["meta"]["players"], "tags": doc["meta"]["tags"], "placeholders": doc["meta"]["placeholders"], + "script_classes": doc["meta"]["script_classes"], + "groups": [{"name": g["name"], "players": g["players"], "scripts": max(g["scripts"].values())} for g in doc["groups"]], + "counts": {"symbols_shared": sum(1 for s in doc["symbols"] if s["players"] == "all"), + "symbols_total": len(doc["symbols"]), + "teams_shared": sum(1 for t in doc["teams"] if t["players"] == "all"), "teams_total": len(doc["teams"]), + "team_hook_scripts_shared": len({h["script"] for h in doc["team_hooks"] if h["players"] == "all"}), + "object_lists": len(doc["object_lists"]), "map_names": len(doc["map_names"])}} + if doc["meta"]["duplicate_script_names"]: + result["duplicate_script_names"] = len(doc["meta"]["duplicate_script_names"]) + if doc.get("notes"): + result["notes"] = doc["notes"] + return result + if what == "scripts": + if filter is None: + # Too many to list: how many of each class every group holds. + table: dict[str, dict[str, int]] = {} + for info in doc["scripts"].values(): + row = table.setdefault(info["group"], {}) + row[info["class"]] = row.get(info["class"], 0) + 1 + return {"total": len(doc["scripts"]), "classes": doc["meta"]["script_classes"], + "by_group": [dict(group=g, **table[g]) for g in sorted(table)], + "hint": "filter by a class (framework, parameterized, partial, divergent, specific), a group or part of a name"} + rows = [] + for name, info in doc["scripts"].items(): + if not (filter == info["class"] or _matches(name, filter) or _matches(info["group"], filter)): + continue + row = {"name": name, "group": info["group"], "class": info["class"]} + if "varies" in info: + row["varies"] = [f"{v['type']} in {v['of']}" for v in info["varies"]] + for key in ("players", "differs_in"): + if key in info: + row[key] = info[key] + rows.append(row) + return {"total": len(rows), "scripts": rows} + if what == "script": + if not names: + raise ValueError("view \"script\" needs names") + return {"scripts": [dict(doc["scripts"][n], name=n) for n in names if n in doc["scripts"]], + "unknown": [n for n in names if n not in doc["scripts"]]} + if what == "symbols": + if filter is None: + shared = [{"name": s["name"], "kind": s["kind"], "written_in": len(s["written_in"]), "read_in": len(s["read_in"]), + **({"note": s["note"]} if "note" in s else {})} for s in doc["symbols"] if s["players"] == "all"] + odd = [{"name": s["name"], "kind": s["kind"], "players": s["players"], "note": s["note"]} + for s in doc["symbols"] if "note" in s and s["players"] != "all"] + return {"shared_by_all": shared, "suspicious_elsewhere": odd, "total": len(doc["symbols"]), + "hint": "filter by part of a name, a kind (flag, counter, timer) or all/some/one for the scripts that write and read"} + return {"symbols": [s for s in doc["symbols"] if filter in (s["kind"], s["players"]) or _matches(s["name"], filter)]} + if what == "teams": + if filter is None: + return {"shared_by_all": [{k: t[k] for k in ("name", "teamProductionPriority", "teamMaxInstances", "hooks") if k in t} + for t in doc["teams"] if t["players"] == "all"], + "total": len(doc["teams"]), + "hint": "filter by part of a name or all/some/one for every setting and the units per player"} + return {"teams": [t for t in doc["teams"] if filter == t["players"] or _matches(t["name"], filter)]} + if what in ("team_hooks", "script_calls"): + if filter is None: + return {what: [h for h in doc[what] if h["players"] == "all"], "total": len(doc[what]), + "hint": "these are shared by all players; filter by part of a script name or some/one for the rest"} + return {what: [h for h in doc[what] if filter == h["players"] or _matches(h["script"], filter)]} + if what == "object_lists": + if filter is None: + return {"object_lists": [{"name": e["name"], "players": e["players"], + "members": max(len(m) for m in e["members"].values())} for e in doc["object_lists"]], + "hint": "filter by part of a name for the members per player"} + return {"object_lists": [e for e in doc["object_lists"] if _matches(e["name"], filter)]} + if what in ("map_names", "attack_priority_sets"): + return {what: [e for e in doc[what] if _matches(e["name"], filter)]} + if what == "groups": + return {"groups": doc["groups"]} + raise ValueError("view must be overview, groups, scripts, script, symbols, teams, team_hooks, script_calls, object_lists, " + "map_names or attack_priority_sets") diff --git a/scripts/worldbuilder-mcp/match.py b/scripts/worldbuilder-mcp/match.py new file mode 100644 index 00000000000..3919693a118 --- /dev/null +++ b/scripts/worldbuilder-mcp/match.py @@ -0,0 +1,251 @@ +"""AI matches: running the game's -aiMatch mode and reading the log it writes. + +The game plays a skirmish between AI players by itself and writes one JSON record per line: the +players, a snapshot every few seconds (money, power, objects by type, teams), events (a team is +started, a science or upgrade arrives, a script runs for the first time, the script engine +reports something) and a summary. This module turns that into what one wants to know about an +AI: what it built when, what it never did, where it stalled. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import time +from typing import Any + +GAME_EXE = "generalszh.exe" +TROUBLE = re.compile(r"\*\*\*|error|unable|could not|not found|not defined|invalid|warning|fail|missing", re.IGNORECASE) +RANK_SCIENCE = re.compile(r"^SCIENCE_Rank\d+$") + + +def command_line(map_name: str, players: list[str], log: str, minutes: float, seed: int, interval: float, + cash: int | None, width: int, height: int, scripts: str | None = None, ai_data: str | None = None, + headless: bool = False) -> list[str]: + screen = ["-headless"] if headless else ["-win", "-xres", str(width), "-yres", str(height)] + args = screen + ["-noaudio", "-quickstart", "-ignoreAsserts", + "-aiMatch", map_name, "-aiPlayers", ",".join(players), "-aiMatchLog", log, + "-aiMatchMinutes", str(minutes), "-aiMatchInterval", str(interval), "-seed", str(seed)] + if cash: + args += ["-aiMatchCash", str(cash)] + if scripts: + args += ["-aiMatchScripts", scripts] + if ai_data: + args += ["-aiMatchAIData", ai_data] + return args + + +def run(game_dir: str, map_name: str, players: list[str], log: str, minutes: float = 15, seed: int = 1, + interval: float = 10, cash: int | None = None, wait_seconds: float = 240, width: int = 1024, + height: int = 768, scripts: str | None = None, ai_data: str | None = None, headless: bool = True) -> dict[str, Any]: + """Starts a match and waits up to wait_seconds for it to end.""" + exe = os.path.join(game_dir, GAME_EXE) + if not os.path.exists(exe): + raise FileNotFoundError(f"{exe} not found") + os.makedirs(os.path.dirname(log), exist_ok=True) + if os.path.exists(log): + os.remove(log) + for label, path in (("script file", scripts), ("AIData file", ai_data)): + if path and not os.path.exists(path): + raise FileNotFoundError(f"{label} {path} not found") + process = subprocess.Popen([exe] + command_line(map_name, players, log, minutes, seed, interval, cash, width, height, scripts, ai_data, + headless), cwd=game_dir) + started = time.time() + result: dict[str, Any] = {"log": log, "pid": process.pid} + try: + process.wait(timeout=wait_seconds) + result["status"] = "finished" + result["exit_code"] = process.returncode + except subprocess.TimeoutExpired: + result["status"] = "running" + result["wall_seconds"] = round(time.time() - started, 1) + return result + + +def read(log: str) -> list[dict[str, Any]]: + records = [] + with open(log, encoding="latin-1") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + # The game is still writing the last line. + break + return records + + +def _seconds(frame: int, fps: int) -> float: + return round(frame / fps, 1) + + +def _player_keys(snaps: list[dict[str, Any]], name: str) -> tuple[str, str]: + """(script player, name suffix) of a match player: the game copies the scripts of + 'SkirmishChina' to the player and puts its index behind every script and team name.""" + for snap in snaps: + for entry in snap["players"]: + if entry["name"] != name: + continue + for team in entry["teams"]: + found = re.fullmatch(r"team(Skirmish.*?)(\d+)", team) + if found: + return found.group(1), found.group(2) + return "", "" + + +def _strip(name: str, suffix: str) -> str: + return name[:-len(suffix)] if suffix and name.endswith(suffix) else name + + +def _trouble(messages: list[dict[str, Any]], suffix: str, fps: int, limit: int) -> list[dict[str, Any]]: + """The messages that speak of a problem, the most frequent first, with the player's name suffix taken out.""" + found = sorted((m for m in messages if TROUBLE.search(m["text"])), key=lambda m: -m["count"]) + tidy = re.compile(r"(?<=\S)" + re.escape(suffix) + r"(?=\s|'|$)") if suffix else None + return [{"text": tidy.sub("", m["text"]) if tidy else m["text"], "count": m["count"], "first_at": _seconds(m["first"], fps)} + for m in found[:limit]] + + +def report(records: list[dict[str, Any]], dump: dict[str, Any] | None = None, factions: dict[str, dict[str, Any]] | None = None, + only_player: str | None = None, limit: int = 25) -> dict[str, Any]: + """What happened in a match, per player. dump is a scripts.dump of the AI set the match used + (to name what never ran); factions maps faction names to their descriptions.""" + by_type: dict[str, list[dict[str, Any]]] = {} + for record in records: + by_type.setdefault(record["t"], []).append(record) + if "error" in by_type: + return {"status": "error", "reason": by_type["error"][0]["reason"]} + if "setup" not in by_type: + return {"status": "no match in this log"} + setup = by_type["setup"][0] + fps = setup.get("frames_per_second", 30) + snaps = by_type.get("snap", []) + end = by_type.get("end", [None])[0] + result: dict[str, Any] = {"status": "finished" if end else ("running" if "start" in by_type else "starting"), + "map": setup["map"], "seed": setup["seed"]} + for key in ("scripts", "ai_data"): + if setup.get(key): + result[key] = setup[key] + if not snaps or "start" not in by_type: + return result + last_frame = end["frame"] if end else snaps[-1]["frame"] + result["game_minutes"] = round(last_frame / fps / 60, 1) + if end: + result["reason"] = end["reason"] + result["winners"] = end["winners"] + + fired_by_player = {r["player"]: r["scripts"] for r in by_type.get("scripts", [])} + # What the script engine and the AI reported; while the match runs only the first of each is known. + messages = by_type["messages"][0]["messages"] if "messages" in by_type else [] + if not messages: + seen: dict[tuple[str, str], dict[str, Any]] = {} + for event in by_type.get("message", []): + seen.setdefault((event.get("player", ""), event["text"]), + {"player": event.get("player", ""), "text": event["text"], "count": 1, "first": event["frame"]}) + messages = list(seen.values()) + players = [] + for start in by_type["start"][0]["players"]: + name = start["name"] + script_player, suffix = _player_keys(snaps, name) + if only_player and only_player not in (name, script_player, start["faction"]): + continue + series = [next(p for p in snap["players"] if p["name"] == name) for snap in snaps] + frames = [snap["frame"] for snap in snaps] + final = series[-1] + doc = (factions or {}).get(start["faction"]) + objects = doc["objects"] if doc else {} + info: dict[str, Any] = {"name": name, "faction": start["faction"], "scripts_of": script_player, "difficulty": start["difficulty"], + "alive": final["alive"], "score": final["score"], "money_end": final["money"], "rank_end": final["rank"]} + + # First time every type stood on the map. + first_seen: dict[str, int] = {} + for frame, entry in zip(frames, series): + for kind in entry["objects"]: + first_seen.setdefault(kind, frame) + ordered = sorted(first_seen.items(), key=lambda kv: kv[1]) + built = next((p["built"] for p in end["players"] if p["name"] == name), {}) if end else {} + lost = next((p["lost"] for p in end["players"] if p["name"] == name), {}) if end else {} + + def is_structure(kind: str) -> bool: + return objects[kind]["category"] == "structure" if kind in objects else kind in built and "Infantry" not in kind + + info["structures"] = [{"type": k, "first_at": _seconds(f, fps), "built": built.get(k, 0), "lost": lost.get(k, 0)} + for k, f in ordered if (objects.get(k, {}).get("category") == "structure") or (not objects and is_structure(k))] + info["units"] = [{"type": k, "first_at": _seconds(f, fps), "built": built.get(k, 0), "lost": lost.get(k, 0)} + for k, f in ordered if k in objects and objects[k]["category"] != "structure"] + foreign = [k for k, _ in ordered if objects and k not in objects] + if foreign: + info["other_types_owned"] = foreign[:limit] + + if doc: + usable = {n: o for n, o in objects.items() if "tier" in o and not o.get("unavailable") and o.get("ai_use") != "never"} + info["never_built"] = { + "structures": sorted(n for n, o in usable.items() if o["category"] == "structure" and n not in first_seen and n not in built), + "units": sorted(n for n, o in usable.items() if o["category"] != "structure" and o.get("combat") + and n not in first_seen and n not in built)} + + # Economy. + low_power = next((f for f, e in zip(frames, series) if e["power"][0] < e["power"][1]), None) + info["economy"] = {"money_min": min(e["money"] for e in series), "money_max": max(e["money"] for e in series), + "money_earned": final["score"]["money_earned"], "money_spent": final["score"]["money_spent"]} + if low_power is not None: + info["economy"]["low_power_from"] = _seconds(low_power, fps) + # Longest time without a new unit or structure. + longest = (0, 0) + since = frames[0] + made = series[0]["score"]["units_built"] + series[0]["score"]["buildings_built"] + for frame, entry in zip(frames, series): + now = entry["score"]["units_built"] + entry["score"]["buildings_built"] + if now != made: + made, since = now, frame + elif frame - since > longest[1] - longest[0]: + longest = (since, frame) + if longest[1] - longest[0] >= 60 * fps: + info["longest_pause"] = {"from": _seconds(longest[0], fps), "to": _seconds(longest[1], fps), + "note": "nothing was built in this time"} + + # Teams, sciences, upgrades. + team_counts: dict[str, int] = {} + team_first: dict[str, int] = {} + for event in by_type.get("team", []): + if event["player"] == name: + team = _strip(event["team"], suffix) + team_counts[team] = team_counts.get(team, 0) + 1 + team_first.setdefault(team, event["frame"]) + info["teams"] = [{"team": t, "started": c, "first_at": _seconds(team_first[t], fps)} + for t, c in sorted(team_counts.items(), key=lambda kv: team_first[kv[0]])] + info["sciences"] = [{"science": e["science"], "at": _seconds(e["frame"], fps)} for e in by_type.get("science", []) + if e["player"] == name and not RANK_SCIENCE.match(e["science"])] + info["upgrades"] = [{"upgrade": e["upgrade"], "at": _seconds(e["frame"], fps)} for e in by_type.get("upgrade", []) if e["player"] == name] + + fired = {_strip(n, suffix): v for n, v in fired_by_player.get(name, {}).items()} + info["scripts_run"] = len(fired) if end else sum(1 for e in by_type.get("script", []) if e["player"] == name) + + # Against the script set: what was there to run and never did. + entry = next((e for e in (dump or {}).get("players", []) if e["player"] == script_player), None) if script_player else None + if entry and end: + level = {"easy": "easy", "normal": "normal", "hard": "hard"}[start["difficulty"]] + never_ran = [] + for group in [{"name": "", "active": True, "scripts": entry.get("scripts", [])}] + entry.get("groups", []): + for script in group.get("scripts", []): + if (script.get("active", True) and group.get("active", True) and script.get(level, True) + and not script.get("subroutine") and script.get("actions") and script["name"] not in fired): + never_ran.append({"script": script["name"], "group": group["name"]}) + teams_defined = [t["teamName"] for t in dump.get("teams", []) if t.get("teamOwner") == script_player] + never_started = sorted(t for t in teams_defined if t not in team_counts and not t.startswith("team")) + info["never_ran"] = {"scripts": len(never_ran), "scripts_first": never_ran[:limit], + "teams": len(never_started), "teams_of": len(teams_defined), "teams_first": never_started[:limit]} + if len(info["teams"]) > limit: + info["teams"] = info["teams"][:limit] + [{"more": len(team_counts) - limit}] + info["trouble"] = _trouble([m for m in messages if m.get("player") == name], suffix, fps, limit) + players.append(info) + result["players"] = players + + # What nobody can be blamed for: reported outside of any player's scripts or AI. + names = {start["name"] for start in by_type["start"][0]["players"]} + result["trouble"] = _trouble([m for m in messages if m.get("player", "") not in names], "", fps, limit) + result["messages_total"] = len(messages) + return result 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/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/pyproject.toml b/scripts/worldbuilder-mcp/pyproject.toml new file mode 100644 index 00000000000..927d3a80e78 --- /dev/null +++ b/scripts/worldbuilder-mcp/pyproject.toml @@ -0,0 +1,13 @@ +[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", + "pyyaml>=6", +] + +[tool.uv] +# Scripts only, nothing to build or install as a package. +package = false diff --git a/scripts/worldbuilder-mcp/scaffold.py b/scripts/worldbuilder-mcp/scaffold.py new file mode 100644 index 00000000000..28a210594e1 --- /dev/null +++ b/scripts/worldbuilder-mcp/scaffold.py @@ -0,0 +1,307 @@ +"""Deriving the AI of one faction from that of another. + +Takes the scripts and teams of an existing AI player and rewrites them for another faction: the +shared framework is kept as it is, everything that names a type, upgrade, science, special power +or command button of the source faction is translated to its counterpart in the target faction. +The counterparts come from comparing the two faction descriptions (see faction.py); what has +none is reported instead of guessed. +""" + +from __future__ import annotations + +import copy +import re +from typing import Any + +from framework import SCRIPT_REF_KINDS, TEAM_HOOKS, _player_tag + +# General prefixes of type names: Tank_ChinaTankGattling, SecW_..., Z_SpecW_... -> ChinaTankGattling. +PREFIX = re.compile(r"^(?:[A-Za-z0-9]{1,5}_)+") +BUTTON_PARAMS = ("COMMANDBUTTON", "COMMANDBUTTON_ABILITY", "COMMANDBUTTON_ALL_ABILITIES") +BUTTON_KEYS = ("builds", "upgrades", "special_powers", "abilities") + + +def base_name(name: str) -> str: + stripped = PREFIX.sub("", name) + return stripped or name + + +def _by_base(names: list[str]) -> dict[str, str]: + """Base name -> name, for the base names that are unambiguous.""" + seen: dict[str, list[str]] = {} + for name in names: + seen.setdefault(base_name(name).lower(), []).append(name) + return {base: found[0] for base, found in seen.items() if len(found) == 1} + + +def _slot_pairs(src: dict[str, Any], dst: dict[str, Any]) -> dict[str, str]: + pairs = {} + for slot, value in src["slots"].items(): + other = dst["slots"].get(slot) + if isinstance(value, str) and isinstance(other, str): + pairs[value] = other + return pairs + + +def map_names(src: dict[str, Any], dst: dict[str, Any], overrides: dict[str, str] | None = None) -> dict[str, Any]: + """Counterparts in the target faction for the things of the source faction, with the reason for each.""" + overrides = dict(dst.get("counterparts", {}), **(overrides or {})) + objects: dict[str, dict[str, str]] = {} + unmapped: list[dict[str, Any]] = [] + dst_objects = dst["objects"] + dst_base = _by_base(list(dst_objects)) + slots = _slot_pairs(src, dst) + + src_names = list(src["objects"]) + [o["name"] for o in src["other_objects"]] + for name in src_names: + obj = src["objects"].get(name) + if name in overrides: + objects[name] = {"to": overrides[name], "by": "override"} + elif name in dst_objects: + objects[name] = {"to": name, "by": "same"} + elif base_name(name).lower() in dst_base: + objects[name] = {"to": dst_base[base_name(name).lower()], "by": "name"} + elif name in slots: + objects[name] = {"to": slots[name], "by": "slot"} + elif obj is not None: + # Nearest type with the same job: role and category must agree. + same_job = [o for o in dst_objects.values() if o["role"] == obj["role"] and o["category"] == obj["category"] + and bool(o.get("naval")) == bool(obj.get("naval")) and not o.get("unavailable") and o.get("ai_use") != "never"] + if same_job: + best = min(same_job, key=lambda o: (abs(o.get("tier", 9) - obj.get("tier", 9)), abs(o["cost"] - obj["cost"]), o["name"])) + objects[name] = {"to": best["name"], "by": "role"} + else: + unmapped.append({"kind": "object", "name": name, "role": obj["role"], "category": obj["category"]}) + else: + unmapped.append({"kind": "object", "name": name}) + + def simple(kind: str, key: str) -> dict[str, dict[str, str]]: + found = {} + base = _by_base(list(dst[key])) + for name in src[key]: + if name in overrides: + found[name] = {"to": overrides[name], "by": "override"} + elif name in dst[key]: + found[name] = {"to": name, "by": "same"} + elif base_name(name).lower() in base: + found[name] = {"to": base[base_name(name).lower()], "by": "name"} + else: + unmapped.append({"kind": kind, "name": name}) + return found + + upgrades = simple("upgrade", "upgrades") + sciences = simple("science", "sciences") + powers = simple("special_power", "special_powers") + + # Buttons: the same button, or the one on the counterpart type that does the counterpart thing. + buttons: dict[str, dict[str, str]] = {} + dst_buttons = set(dst.get("buttons", [])) + button_base = _by_base(sorted(dst_buttons)) + for name, obj in src["objects"].items(): + target = dst_objects.get(objects.get(name, {}).get("to", "")) + for key in BUTTON_KEYS: + for entry in obj.get(key, []): + button = entry["button"] + if button in buttons: + continue + if button in overrides: + buttons[button] = {"to": overrides[button], "by": "override"} + continue + if button in dst_buttons: + buttons[button] = {"to": button, "by": "same"} + continue + match = None + for other in (target or {}).get(key, []): + if key == "builds" and objects.get(entry["object"], {}).get("to") == other["object"]: + match = other + elif key == "upgrades" and upgrades.get(entry["upgrade"], {}).get("to") == other["upgrade"]: + match = other + elif key == "special_powers" and powers.get(entry.get("special_power", ""), {}).get("to") == other.get("special_power"): + match = other + elif key == "abilities" and entry.get("type") == other.get("type") and entry.get("options") == other.get("options"): + match = match or other + if match: + buttons[button] = {"to": match["button"], "by": "counterpart"} + elif base_name(button).lower() in button_base: + buttons[button] = {"to": button_base[base_name(button).lower()], "by": "name"} + else: + unmapped.append({"kind": "button", "name": button, "on": name}) + + def tally(found: dict[str, dict[str, str]]) -> dict[str, int]: + counts: dict[str, int] = {} + for entry in found.values(): + counts[entry["by"]] = counts.get(entry["by"], 0) + 1 + return counts + + return {"from": src["meta"]["faction"], "to": dst["meta"]["faction"], + "objects": objects, "upgrades": upgrades, "sciences": sciences, "special_powers": powers, "buttons": buttons, + "unmapped": unmapped, + "summary": {"objects": tally(objects), "upgrades": tally(upgrades), "sciences": tally(sciences), + "special_powers": tally(powers), "buttons": tally(buttons), "unmapped": len(unmapped)}} + + +class _Translator: + """Rewrites the values of one player's scripts and teams for another faction.""" + + def __init__(self, mapping: dict[str, Any], src: dict[str, Any], renames: dict[str, str], old_player: str, new_player: str) -> None: + self.by_kind = {"OBJECT_TYPE": mapping["objects"], "UPGRADE": mapping["upgrades"], "SCIENCE": mapping["sciences"], + "SPECIAL_POWER": mapping["special_powers"]} + for kind in BUTTON_PARAMS: + self.by_kind[kind] = mapping["buttons"] + # What belongs to the source faction; anything else (enemy types, lists) is left alone. + self.own = {"OBJECT_TYPE": set(src["objects"]) | {o["name"] for o in src["other_objects"]}, + "UPGRADE": set(src["upgrades"]), "SCIENCE": set(src["sciences"]), "SPECIAL_POWER": set(src["special_powers"])} + for kind in BUTTON_PARAMS: + self.own[kind] = set(src.get("buttons", [])) + self.renames = renames + self.old_player = old_player + self.new_player = new_player + self.unmapped: dict[tuple[str, str], list[str]] = {} + self.seen: set[str] = set() + self.translated = 0 + + def value(self, kind: str, value: Any, where: str, translate: bool) -> Any: + if not isinstance(value, str): + return value + if kind in SCRIPT_REF_KINDS or kind == "TEAM": + return self.renames.get(value, value) + if kind == "SIDE" and value == self.old_player: + return self.new_player + if not translate or kind not in self.by_kind or value not in self.own[kind]: + return value + self.seen.add(value) + entry = self.by_kind[kind].get(value) + if entry is None: + places = self.unmapped.setdefault((kind, value), []) + if where not in places: + places.append(where) + return value + if entry["to"] != value: + self.translated += 1 + return entry["to"] + + def unit(self, name: str, where: str) -> str: + return self.value("OBJECT_TYPE", name, where, True) + + +def _retag(name: str, old_tag: str, new_tag: str) -> str: + if not old_tag: + return name + return re.sub(r"(? dict[str, Any]: + """A scripts.load document for new_player, derived from like_player, and a report on it.""" + entry = next((e for e in dump["players"] if e["player"] == like_player), None) + if entry is None: + raise ValueError(f"the map has no player '{like_player}'") + teams = [t for t in dump.get("teams", []) if t.get("teamOwner") == like_player] + old_tag = framework["meta"]["tags"].get(like_player) or _player_tag(teams) + if not re.fullmatch(r"[A-Za-z0-9]{2,8}", tag): + raise ValueError("tag must be 2 to 8 letters or digits; it starts the names of the new player's teams and groups") + taken = {t["teamName"] for t in dump.get("teams", []) if t.get("teamOwner") != new_player} + + # Names: the tag is swapped; a team name without it gets the new tag in front, as team names are unique in a map. + renames: dict[str, str] = {} + for team in teams: + new_name = _retag(team["teamName"], old_tag, tag) + if new_name == team["teamName"] or new_name in taken: + new_name = f"{tag} {team['teamName']}" + renames[team["teamName"]] = new_name + all_scripts = [(None, s) for s in entry.get("scripts", [])] + [(g, s) for g in entry.get("groups", []) for s in g.get("scripts", [])] + for _, script in all_scripts: + new_name = _retag(script["name"], old_tag, tag) + if new_name != script["name"]: + renames[script["name"]] = new_name + + translator = _Translator(mapping, src, renames, like_player, new_player) + classes = framework["scripts"] + counts = {"kept_as_framework": 0, "parameters_filled": 0, "translated": 0, "dropped": 0} + dropped: list[str] = [] + + def norm(name: str) -> str: + return _retag(name, old_tag, "") + + def convert(script: dict[str, Any]) -> dict[str, Any] | None: + info = classes.get(norm(script["name"]), {}) + kind = info.get("class", "specific") + # A framework script is the same for every faction, lists of enemy types included. + translate_all = kind not in ("framework", "parameterized") + holes = set() + if kind == "parameterized": + for vary in info.get("varies", []): + holes.add(vary["at"]) + out = copy.deepcopy(script) + out["name"] = renames.get(script["name"], script["name"]) + before = len(translator.unmapped) + where = f"script {out['name']}" + + def items(label: str, item: dict[str, Any]) -> None: + item.pop("text", None) + for index, param in enumerate(item.get("params", [])): + do = translate_all or f"{label}, parameter {index + 1}" in holes + param["value"] = translator.value(param["type"], param.get("value"), where, do) + + for c, clause in enumerate(out.get("conditions", [])): + for i, condition in enumerate(clause): + items(f"condition {c + 1}.{i + 1}", condition) + for i, action in enumerate(out.get("actions", [])): + items(f"action {i + 1}", action) + for i, action in enumerate(out.get("false_actions", [])): + items(f"false action {i + 1}", action) + if drop_unmapped and len(translator.unmapped) > before: + counts["dropped"] += 1 + dropped.append(where) + return None + counts["kept_as_framework" if kind == "framework" else ("parameters_filled" if kind == "parameterized" else "translated")] += 1 + return out + + new_entry: dict[str, Any] = {"player": new_player, + "properties": dict(entry.get("properties", {}), playerName=new_player, playerDisplayName=new_player, + playerFaction=dst["meta"]["faction"]), + "scripts": [s for s in (convert(s) for s in entry.get("scripts", [])) if s], + "groups": []} + for group in entry.get("groups", []): + new_group = {k: v for k, v in group.items() if k != "scripts"} + new_group["name"] = _retag(group["name"], old_tag, tag) + new_group["scripts"] = [s for s in (convert(s) for s in group.get("scripts", [])) if s] + new_entry["groups"].append(new_group) + + new_teams = [] + for team in teams: + out = dict(team) + out["teamName"] = renames[team["teamName"]] + out["teamOwner"] = new_player + where = f"team {out['teamName']}" + before = len(translator.unmapped) + for slot in range(1, 8): + key = f"teamUnitType{slot}" + if out.get(key) and out[key] != "" and out.get(f"teamUnitMaxCount{slot}", 0) > 0: + out[key] = translator.unit(out[key], where) + for hook in TEAM_HOOKS: + if out.get(hook): + out[hook] = renames.get(out[hook], out[hook]) + if drop_unmapped and len(translator.unmapped) > before: + counts["dropped"] += 1 + dropped.append(where) + continue + new_teams.append(out) + + unmapped = [{"kind": kind, "name": name, "count": len(places), "where": places[:6]} + for (kind, name), places in sorted(translator.unmapped.items())] + by_role = sorted(f"{name} -> {m['to']}" for name, m in mapping["objects"].items() if m["by"] == "role" and name in translator.seen) + report = { + "player": new_player, "faction": dst["meta"]["faction"], "derived_from": like_player, "tag": tag, "source_tag": old_tag, + "scripts": sum(len(g["scripts"]) for g in new_entry["groups"]) + len(new_entry["scripts"]), + "teams": len(new_teams), + "script_counts": counts, + "values_translated": translator.translated, + "unmapped": unmapped, + "mapped_by_role_only": by_role, + } + if dropped: + report["dropped"] = dropped[:40] + document = {"format": dump.get("format", "worldbuilder-scripts-1"), "players": [new_entry], "teams": new_teams} + return {"document": document, "report": report} diff --git a/scripts/worldbuilder-mcp/server.py b/scripts/worldbuilder-mcp/server.py new file mode 100644 index 00000000000..2fd80c2a260 --- /dev/null +++ b/scripts/worldbuilder-mcp/server.py @@ -0,0 +1,2214 @@ +"""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 + +import json +import os +import time +from typing import Any, Literal + +from mcp.server.mcpserver import Image, MCPServer +from mcp.server.mcpserver.exceptions import ToolError + +import yaml + +import aidata as ai_data_file +import faction as faction_data +import framework as framework_data +import match as match_data +import scaffold as scaffold_data +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. + 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. +- 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. +- 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. +- Ships: a map is meant for ships when its map.ini sets EnableShips (map_info: ships_enabled; + map_set_info sets it). Scripts test that with the condition SKIRMISH_SHIPS_ENABLED and build + shipyards with SKIRMISH_BUILD_SHIPYARD, which uses the waypoints Shipyard_. Naval + teams keep to the area [Skirmish]MyNavalPerimeter and attack along Naval / NavalFlank paths. + ai_skirmish_check lists what a map has of these; faction_check wants shipyard scripts and + ship teams to ask SKIRMISH_SHIPS_ENABLED. +- 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). +- What a faction can build and how strong its units are: faction_describe (tech tree, + prerequisites, cost, health, armor, weapons, upgrades, sciences, special powers from the game + data). Write it to a file with path, or ask for single sections or templates. +- Planning an AI for a faction: faction_generate once (and after game data changes), then + faction_get: overview first, then tree, units, or single types. Roles, slots and phases there + are guesses until someone has reviewed the faction's overrides file; `unavailable` types + cannot be built and must not be put into teams or build orders. After writing or changing an + AI, run faction_check on the player: it finds teams and build orders the faction cannot + fulfil. +- Where the skirmish AI builds is not in the scripts: SKIRMISH_BUILD_BUILDING takes the place + from the side's SkirmishBuildList in AIData.ini (aidata_get). A structure without an entry + there is never built. buildlist_from_aidata loads that layout into a map player to look at and + edit, buildlist_export writes it back as an INI block. aidata_set_side changes a side's + gatherer counts, base defense and science skill sets in place, keeping the file's comments. +- Whether an AI really plays is found out by letting it: ai_match_run starts the game for a + skirmish between AI players and logs it, ai_match_report says what each one built when, what + it never built, which scripts and teams never ran and what the script engine complained + about. The match uses the installed scripts unless told otherwise: scripts="open" plays + with what is open in WorldBuilder, ai_data with a changed AIData.ini. With the same seed, a + match before and after a change shows what the change did. +- AI sets repeat one skeleton per faction. ai_framework_generate finds it from the players of + the open map; ai_framework_get shows which scripts, flags, timers, team names, hooks and + object lists every faction is expected to have, and where the factions differ. +- A new faction AI: ai_scaffold derives it from the AI of a related faction (dry run first; it + keeps the shared scripts, translates the rest through faction_map and lists what has no + counterpart). Then work through its `unmapped` list and faction_check. +- 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. +""" + +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, ships_enabled: bool | None = None) -> dict: + """Changes the map name, time of day or weather. ships_enabled sets EnableShips in the + map.ini next to the map (the map must have been saved): it tells the AI scripts that this + map is meant for ships (condition SKIRMISH_SHIPS_ENABLED). map_info shows it; see + ai_skirmish_check for what such a map needs.""" + return wb.call("map.set_info", name=name, time_of_day=time_of_day, weather=weather, ships_enabled=ships_enabled) + + +@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) + + +# -------------------------------------------------------------------------------------------------- +# 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) + + +# -------------------------------------------------------------------------------------------------- +# 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) 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 +# -------------------------------------------------------------------------------------------------- + +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 +# -------------------------------------------------------------------------------------------------- + +@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 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) + + +@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..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) + + +@mcp.tool() +def terrain_set_heights(x0: int, y0: int, heights: list[list[int | None]]) -> dict: + """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) + + +@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) 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) + + +@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) + + +# -------------------------------------------------------------------------------------------------- +# 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 +# -------------------------------------------------------------------------------------------------- + +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 (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, + 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, 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. + 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 +# -------------------------------------------------------------------------------------------------- + +@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 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.""" + 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 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 faction_describe(faction: str, path: str | None = None, scope: Literal["side", "tree"] = "side", + sections: list[str] | None = None, templates: list[str] | None = None) -> dict: + """A faction as the game data defines it, read from the loaded templates (inheritance, reskins + and module overrides resolved). faction is a faction name from sides_list_factions + (FactionChina) or its side (China). No map is needed. Sections of the result: + - faction: starting building and units, start money, intrinsic sciences, science command + sets, cost and build time multipliers of single types. + - objects: per unit and structure name, side, in_tech_tree, kind_of, cost, build_time + (seconds), energy, max_simultaneous, vision, health, locomotor (speed per second, + surfaces), contain, stealth, detector_range, supply_boxes, modules, weapon_sets and + armor_sets by condition, prerequisites, built_by, and from its command sets: builds, + upgrades, special_powers, abilities (button names as script actions take them). + `prerequisites` are all required: one type out of every group in `objects`, and every + science in `sciences`. `stealth` is set for innate stealth only. + - weapons: damage, damage_radius, damage_type, range, shot_delay and clip_reload (seconds), + clip_size, anti (what it may target), projectile, and dps: sustained damage per second on + one target before armor (wrong for weapons that deal their damage through spawned objects). + - armors: damage_taken per damage type as a factor (types at 1.0 left out). + - upgrades: type, cost, build_time, researched_at. + - sciences: point_cost, prerequisites, purchase_rank, what they unlock. + - special_powers: type, reload_time (seconds), required_science, used_by. + scope "side" adds the unit and structure types of the side that nothing in the tech tree + builds (in_tech_tree false; often AI-only variants); "tree" leaves them out. templates + narrows objects, weapons and armors to the named types. The whole description is large + (hundreds of KB): give path to write it to a JSON file and get only the counts back, or ask + for single sections / templates.""" + doc = wb.call("faction.describe", faction=faction, scope=scope, sections=sections, templates=templates) + if path is None: + return 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) + result = {"path": full, "bytes": os.path.getsize(full), "counts": doc["counts"]} + if "unknown_templates" in doc: + result["unknown_templates"] = doc["unknown_templates"] + return result + + +FACTION_FOLDER = "worldbuilder_factions" +_exe_dir: str | None = None + + +def _faction_dir(directory: str | None) -> str: + """The folder of the faction files: next to the WorldBuilder executable, which runs in the + folder of the game data it describes. A relative directory is taken from there as well.""" + global _exe_dir + override = directory or os.environ.get("WB_MCP_FACTION_DIR") + if override and os.path.isabs(override): + return override + if _exe_dir is None: + _exe_dir = wb.call("app.info")["exe_dir"] + return os.path.join(_exe_dir, override or FACTION_FOLDER) + + +def _read_ai_data() -> dict[str, Any]: + """AIData.ini as the game reads it: the default file, then the one that overrides it.""" + data: dict[str, Any] = {"settings": {}, "sides": {}, "build_lists": {}, "files": []} + for path in ai_data_file.FILES: + found = wb.call("game.read_file", path=path) + if found.get("exists"): + ai_data_file.merge(data, ai_data_file.parse(found["text"])) + data["files"].append(path) + return data + + +def _side_of(player: str, side: str | None) -> str: + if side: + return side + wanted = _player_faction(player) + for entry in wb.call("sides.list_factions")["factions"]: + if entry["name"] == wanted: + return entry["side"] + raise ToolError(f"faction '{wanted}' of player '{player}' is unknown; pass side") + + +@mcp.tool() +def aidata_get(side: str | None = None, entries: bool = False) -> dict: + """AIData.ini, the skirmish AI's settings outside of the scripts, read the way the game + reads it. Without side: the global settings (build and team intervals, wealthy and poor + thresholds, guard ranges ...) and per side the number of build list entries. With side + (China, ChinaTankGeneral ...; the side of a faction, see sides_list_factions): + - gatherers: how many supply gatherers the AI keeps per difficulty; + - base_defense: the structure SKIRMISH_BUILD_BASE_DEFENSE_* actions place; + - skill_sets: up to five priority lists for spending General's points. A skirmish AI + takes one at random unless a script chooses with PLAYER_SELECT_SKILLSET, then buys the + first sciences of the list it is able to buy whenever it has points. Without any, the AI + buys no sciences; + - build_list: the SkirmishBuildList, the base layout: per structure how many places it + has. A build script (SKIRMISH_BUILD_BUILDING) only says what to build; the place comes + from here, relative to the command center, and a structure without an entry is never + built. entries=true lists every entry with position, angle and rebuilds.""" + data = _read_ai_data() + if not data["files"]: + raise ToolError("the game data has no AIData.ini") + if side is None: + return {"files": data["files"], "settings": data["settings"], + "sides": {name: {"build_list_entries": len(data["build_lists"].get(name, [])), **info["gatherers"]} + for name, info in data["sides"].items()}, + "build_lists_without_side_info": [n for n in data["build_lists"] if n not in data["sides"]]} + result = ai_data_file.for_side(data, side) + if len(result) == 1: + raise ToolError(f"AIData.ini has nothing for side '{side}'; sides: {', '.join(sorted(set(data['sides']) | set(data['build_lists'])))}") + if entries: + result["entries"] = next((v for n, v in data["build_lists"].items() if n.lower() == side.lower()), []) + return result + + +def _faction_of_side(side: str) -> str | None: + for entry in wb.call("sides.list_factions")["factions"]: + if entry["side"].lower() == side.lower(): + return entry["name"] + return None + + +@mcp.tool() +def aidata_set_side(side: str, gatherers: dict[str, int] | None = None, base_defense: str | None = None, + skill_sets: dict[str, list[str]] | None = None, copy_from: str | None = None, + replace_in: str | None = None, path: str | None = None, allow_unknown: bool = False, + directory: str | None = None) -> dict: + """Changes the SideInfo block of a side in AIData.ini: what the skirmish AI is given outside + of its scripts (see aidata_get). Only what is passed changes: + - gatherers: {"easy": n, "normal": n, "hard": n}, gatherers kept per supply center; + - base_defense: the structure the "build base defense" script actions place; + - skill_sets: {"1": [sciences in buying order], ...} for sets 1 to 5; a list replaces the + whole set, [] empties it. At most 35 sciences per set. + The file is edited line by line: comments, spacing and every line that does not change stay + as they are; a removed science takes its line with it, a new one goes next to its + neighbour in the list. A side without a block gets one after the last SideInfo; copy_from + names a side whose block (with its comments) it starts from. + Checked against the faction description (faction_generate): the base defense must be a + structure of the faction, every science one it can purchase. allow_unknown skips that. + Without replace_in nothing is written: the result holds the new block and a diff against + the game's AIData.ini. path writes the whole edited text to a new file. replace_in names + the AIData.ini to change (the old file is kept as .bak). The game and WorldBuilder + read AIData.ini at startup.""" + sets: dict[int, list[str]] = {} + for key, wanted in (skill_sets or {}).items(): + if not str(key).isdigit() or not 1 <= int(key) <= ai_data_file.MAX_SKILL_SETS: + raise ToolError(f"skill sets are numbered 1 to {ai_data_file.MAX_SKILL_SETS}") + if len(wanted) > ai_data_file.MAX_SKILL_SET_SCIENCES: + raise ToolError(f"skill set {key} has {len(wanted)} sciences; the game keeps {ai_data_file.MAX_SKILL_SET_SCIENCES}") + if len(set(wanted)) != len(wanted): + raise ToolError(f"skill set {key} lists a science twice") + sets[int(key)] = list(wanted) + + if not allow_unknown and (base_defense is not None or sets): + faction = _faction_of_side(side) + try: + doc = faction_data.load(faction, _faction_dir(directory)) if faction else None + except (OSError, ValueError, yaml.YAMLError): + doc = None + if doc is None: + raise ToolError(f"no faction description for side '{side}' to check against; run faction_generate or pass allow_unknown") + problems = [] + if base_defense is not None: + obj = doc["objects"].get(base_defense) + if obj is None or obj["category"] != "structure": + problems.append(f"{base_defense} is not a structure of {doc['meta']['faction']}") + elif obj.get("unavailable"): + problems.append(f"{base_defense} cannot be built: {obj['unavailable']}") + for number, wanted in sets.items(): + for science in wanted: + info = doc["sciences"].get(science) + if info is None: + problems.append(f"skill set {number}: {science} is not a science of {doc['meta']['faction']}") + elif not info.get("point_cost"): + problems.append(f"skill set {number}: {science} cannot be purchased") + if problems: + raise ToolError("; ".join(problems[:12]) + (f"; and {len(problems) - 12} more" if len(problems) > 12 else "")) + + try: + if replace_in: + target = os.path.abspath(replace_in) + with open(target, encoding="latin-1", newline="") as f: + old = f.read() + else: + target = None + old = wb.call("game.read_file", path=ai_data_file.FILES[0]).get("text") + if old is None: + raise ToolError("the game data has no " + ai_data_file.FILES[0]) + edited = ai_data_file.edit_side_info(old, side, gatherers, base_defense, sets, copy_from) + newline = "\r\n" if "\r\n" in old else "\n" + import difflib + diff = [line for line in difflib.unified_diff(old.split(newline), edited["text"].split(newline), lineterm="", n=1)][2:] + result: dict[str, Any] = {"side": side, "created": edited["created"], "changed_lines": sum(1 for d in diff if d[:1] in "+-"), + "diff": diff[:200], "block": edited["block"]} + if len(diff) > 200: + result["diff_truncated"] = len(diff) + if path: + full = os.path.abspath(path) + with open(full, "w", encoding="latin-1", newline="") as f: + f.write(edited["text"]) + result["path"] = full + if target: + if edited["text"] != old: + with open(target + ".bak", "w", encoding="latin-1", newline="") as f: + f.write(old) + with open(target, "w", encoding="latin-1", newline="") as f: + f.write(edited["text"]) + result.update(changed=target, backup=target + ".bak") + else: + result["changed"] = None + del result["block"] + return result + except (OSError, ValueError) as e: + raise ToolError(str(e)) from e + + +@mcp.tool() +def buildlist_from_aidata(player: str, side: str | None = None, command_center_at: list[float] | None = None) -> dict: + """Puts the SkirmishBuildList of a side from AIData.ini into the build list of a player of + the open map, REPLACING that list (one undo step), so that the base layout can be seen and + edited with WorldBuilder's Build List tool or the buildlist_* tools. side defaults to the + side of the player's faction. The layout keeps the coordinates it was made with (around + 500, 500), so the map should be about 1000 units wide; command_center_at=[x, y] moves it + so that the command center lies there. Write it back with buildlist_export.""" + side = _side_of(player, side) + data = _read_ai_data() + found = next((v for n, v in data["build_lists"].items() if n.lower() == side.lower()), None) + if found is None: + raise ToolError(f"AIData.ini has no SkirmishBuildList for side '{side}'") + dx = dy = 0.0 + if command_center_at is not None: + centers = wb.call("objects.info", templates=[e["template"] for e in found][:60])["templates"] + names = {t["template"] for t in centers if "COMMANDCENTER" in t["kind_of"]} + center = next((e for e in found if e["template"] in names), found[0]) + dx, dy = command_center_at[0] - center["x"], command_center_at[1] - center["y"] + placed = [{k: v for k, v in dict(e, x=e["x"] + dx, y=e["y"] + dy).items() if k != "automatically_build"} for e in found] + result = wb.call("buildlist.set", player=player, entries=placed) + result["side"] = side + return result + + +@mcp.tool() +def buildlist_export(player: str, side: str | None = None, path: str | None = None, replace_in: str | None = None) -> dict: + """The build list of a player of the open map as a SkirmishBuildList block for AIData.ini, + as WorldBuilder's Build List export writes it. side is the name the block gets (default: + the side of the player's faction). Positions are written as they are in the map; the game + takes them relative to the command center entry, which the list must contain. Returns the + text; path also writes it to a file. replace_in names an AIData.ini to change: the side's + block is replaced there (or added inside the AIData block) and the old file kept as + .bak. The game and WorldBuilder read AIData.ini at startup.""" + side = _side_of(player, side) + lists = wb.call("buildlist.list", player=player)["players"] + entries = lists[0]["build_list"] if lists else [] + if not entries: + raise ToolError(f"player '{player}' has no build list entries") + text = ai_data_file.format_build_list(side, entries) + result: dict[str, Any] = {"side": side, "entries": len(entries), "text": text} + try: + if path: + full = os.path.abspath(path) + with open(full, "w", encoding="latin-1", newline="\r\n") as f: + f.write(text) + result["path"] = full + if replace_in: + target = os.path.abspath(replace_in) + with open(target, encoding="latin-1", newline="") as f: + old = f.read() + new, replaced = ai_data_file.replace_build_list(old, side, text) + with open(target + ".bak", "w", encoding="latin-1", newline="") as f: + f.write(old) + with open(target, "w", encoding="latin-1", newline="") as f: + f.write(new) + result.update(changed=target, backup=target + ".bak", block="replaced" if replaced else "added") + del result["text"] + except OSError as e: + raise ToolError(str(e)) from e + return result + + +@mcp.tool() +def faction_generate(faction: str, directory: str | None = None) -> dict: + """Writes the description of a faction that AI scripts and teams are planned from, into the + `worldbuilder_factions` folder next to the WorldBuilder executable, i.e. in the game or mod + folder (directory or WB_MCP_FACTION_DIR name another one; relative to the executable): + - .generated.json: the facts of faction_describe, cleaned and extended. Test, + cinematic and unselectable types are dropped; every type gets `tier` (structures to build + before it), `build_path` with `path_cost`, `unlocks`, `combat` (range, targets, damage per + second against infantry, vehicle, aircraft, structure, ship and submarine armor, and + per 1000 credits), `naval` / `submarine` / `amphibious` for what moves on water, + `role_guess`, `phase_guess`, and `unavailable` when its prerequisites cannot be met by + the faction. Rewritten on every run; run it again after the game data changed. + - .overrides.yaml: the tuning, by hand: `slots` (abstract roles such as barracks, + main_battle, anti_air -> types), per unit role, phase, ai_use, weight, counters, micro and + notes, plus `exclude` / `include` lists for the next run. Written once from the guesses, + then never touched; later runs report types that are new or gone. + - .doctrine.md: free text on how the faction should play. A skeleton, written once. + Read the result with faction_get. WorldBuilder reads the game data at startup: restart it + after INI changes.""" + raw = wb.call("faction.describe", faction=faction, scope="side") + try: + return faction_data.generate(raw, raw["faction"]["name"], _faction_dir(directory), + ai_data_file.for_side(_read_ai_data(), raw["faction"]["side"])) + except (ValueError, OSError, yaml.YAMLError) as e: + raise ToolError(str(e)) from e + + +@mcp.tool() +def faction_get(faction: str, view: Literal["overview", "tree", "units", "naval", "unit", "upgrades", "sciences", + "special_powers", "other_objects", "ai_data"] = "overview", + names: list[str] | None = None, directory: str | None = None) -> dict: + """One view of a faction description made by faction_generate, with the overrides merged in + (faction is the name used there, e.g. FactionChina). Start with the overview. + - overview: starting set, slots, every type with role, phase, tier and cost, and the doctrine. + - tree: per type what builds it, prerequisites, what it unlocks and builds, build path, cost. + - units: the combat table: health, speed, range, targets, damage per second by target kind + (infantry, vehicle, aircraft, structure, ship, submarine), stealth, detection, cargo, and + the tuning notes. Ships are under `ships`, structures with weapons under `defenses`. + - naval: the faction at sea: its shipyard (cost, tier, prerequisites, what it builds), its + ships with roles naval_battle, naval_anti_air, naval_siege, submarine, naval_transport or + naval_support, the naval slots, amphibious types, the types that hurt ships most per + cost (land and air included), and what a map needs for ships. + - unit: everything about the types in `names`, with their weapons and armors. + - upgrades, sciences, special_powers: cost, where researched, what they need and unlock. + - other_objects: types of the side that nothing builds (spawned, AI-only, leftovers). + - ai_data: what AIData.ini gives the side: places per structure in its base layout + (SkirmishBuildList), gatherers, base defense, science skill sets. + Damage per second is against the faction's commonest armor of each kind (`reference_armor`).""" + try: + return faction_data.view(faction_data.load(faction, _faction_dir(directory)), view, names) + except (ValueError, OSError, yaml.YAMLError) as e: + raise ToolError(str(e)) from e + + +@mcp.tool() +def faction_check(player: str, faction: str | None = None, include_teams: bool = False, max_where: int = 8, + directory: str | None = None) -> dict: + """Checks the scripts and teams of a player in the open map against what its faction can + do, using the description made by faction_generate. faction defaults to the player's + faction. Equal findings are folded into one entry with `count` and the first `max_where` + places. Errors are things that cannot work: + - teams with types the faction does not have, cannot build (`unavailable`), structures, + types nothing builds, more units than the build limit allows, minimum above maximum; + - teams and build scripts that need a structure no build script builds; + - build scripts for types that are unavailable or no structures; upgrades whose research + structure is never built; + - build scripts for structures that have no place in the side's SkirmishBuildList in + AIData.ini (the action then does nothing), or a list without a command center; + - shipyards built with SKIRMISH_BUILD_BUILDING instead of SKIRMISH_BUILD_SHIPYARD, or the + other way round. + Shipyard scripts and teams of ships that do not ask SKIRMISH_SHIPS_ENABLED are listed as + infos: they are tried on maps without ships too. + Warnings want a look: types, upgrades, sciences, special powers and command buttons of + other factions, types marked ai_use: never, ships mixed with land units. Infos are + judgement calls: combat teams without anti-air, large speed differences, a power deficit, + core structures never built. `unused` lists combat units in no team, structures never + built and upgrades never mentioned: what the AI leaves on the table. + include_teams adds a row per team: cost range, tier, roles, structures needed, anti-air. + This checks what can be built, not whether scripts refer to things that exist + (scripts_verify, scripts_missing) or whether the AI plays well.""" + if faction is None: + for side in wb.call("sides.list")["players"]: + if side.get("playerName") == player: + faction = side.get("playerFaction") + if not faction: + raise ToolError(f"player '{player}' has no faction set; pass faction") + dump = wb.call("scripts.dump", player=player, include_teams=True) + order = wb.call("scripts.build_order", player=player) + try: + doc = faction_data.load(faction, _faction_dir(directory)) + return faction_data.check(doc, player, dump, order, include_teams, max_where) + except (ValueError, OSError, yaml.YAMLError) as e: + raise ToolError(str(e)) from e + + +@mcp.tool() +def ai_framework_generate(players: list[str] | None = None, directory: str | None = None) -> dict: + """Finds the skeleton the AI players of the open map share, by comparing their scripts and + teams with each other, and writes it to ai_framework.generated.json in the faction folder + (see faction_generate). players defaults to those whose name starts with "Skirmish", or + all players with scripts. Names are compared with what is particular to a player taken + out: its name becomes , the word its own teams and groups start with . + It records: the group layout; every script with a class (framework: identical in all + players; parameterized: same structure, some parameters differ, kept as a template with + ${n} holes and the value per player; partial: only some players; divergent: same name, + different structure; specific: one player); flags, counters and timers with the scripts + that write and read them; the scripts teams hook into; the team roster with units per + player; object lists with members per player; the waypoint paths and areas the scripts + expect of a map; attack priority sets. ai_framework.notes.md is written once as a skeleton + for what only a person can say. Read the result with ai_framework_get; run again after the + scripts changed.""" + dump = wb.call("scripts.dump", include_teams=True) + if players is None: + with_scripts = [e["player"] for e in dump["players"] if e.get("scripts") or any(g.get("scripts") for g in e.get("groups", []))] + skirmish = [p for p in with_scripts if p.startswith("Skirmish")] + players = skirmish if len(skirmish) >= 2 else with_scripts + try: + return framework_data.generate(dump, _faction_dir(directory), players) + except (ValueError, OSError) as e: + raise ToolError(str(e)) from e + + +@mcp.tool() +def ai_framework_get(view: Literal["overview", "groups", "scripts", "script", "symbols", "teams", "team_hooks", + "script_calls", "object_lists", "map_names", "attack_priority_sets"] = "overview", + filter: str | None = None, names: list[str] | None = None, directory: str | None = None) -> dict: + """One view of the AI framework found by ai_framework_generate. Without filter the larger + views are summaries; with one they are complete for what matches. + - overview: players and their tags, group layout, counts per script class, the notes. + - groups: every group with its script count per player. + - scripts: counts per group and class; filter by a class, a group or part of a name. + - script: the scripts in `names` in full: flags, template, and per varying parameter the + value each player uses (these are the places a new faction fills in). + - symbols: flags, counters and timers shared by all players, and ones written but never + read or read but never written; filter for the scripts that write and read. + - teams: the roster all players share (name, priority, hooks); filter for all settings and + the units per player. + - team_hooks: scripts that teams run (production condition, on create, generic hooks ...). + - script_calls: scripts that other scripts call, enable or disable. + - object_lists: the named type lists and how many types each holds; filter for members. + - map_names: waypoint paths and areas the scripts expect in every map. + A new faction AI should keep the framework and parameterized scripts, the shared symbols, + the shared team names and hooks, and fill the object lists with its own types.""" + try: + return framework_data.view(framework_data.load(_faction_dir(directory)), view, filter, names) + except (ValueError, OSError) as e: + raise ToolError(str(e)) from e + + +def _player_faction(player: str) -> str: + for side in wb.call("sides.list")["players"]: + if side.get("playerName") == player and side.get("playerFaction"): + return side["playerFaction"] + raise ToolError(f"the map has no player '{player}' with a faction") + + +@mcp.tool() +def faction_map(from_faction: str, to_faction: str, overrides: dict[str, str] | None = None, full: bool = False, + directory: str | None = None) -> dict: + """Counterparts in one faction for the types, upgrades, sciences, special powers and command + buttons of another, from their descriptions (faction_generate). Each is found by, in order: + an override (the argument, or `counterparts` in the target's overrides file); the same + name; the same name without the general's prefix (Tank_ChinaBarracks -> Nuke_ChinaBarracks); + the same single slot (barracks -> barracks); for types, the nearest type with the same role + and category by tier and cost; for buttons, the button on the counterpart type that builds, + researches or fires the counterpart thing. Returns the count per method, the role matches + (the weakest: review them) and what has no counterpart; full adds every pair.""" + try: + folder = _faction_dir(directory) + mapping = scaffold_data.map_names(faction_data.load(from_faction, folder), faction_data.load(to_faction, folder), overrides) + except (ValueError, OSError, yaml.YAMLError) as e: + raise ToolError(str(e)) from e + if full: + return mapping + return {"from": mapping["from"], "to": mapping["to"], "summary": mapping["summary"], + "by_role": {name: m["to"] for name, m in mapping["objects"].items() if m["by"] == "role"}, + "by_slot": {name: m["to"] for name, m in mapping["objects"].items() if m["by"] == "slot"}, + "unmapped": mapping["unmapped"]} + + +@mcp.tool() +def ai_scaffold(player: str, faction: str, like_player: str, tag: str, overrides: dict[str, str] | None = None, + drop_unmapped: bool = False, dry_run: bool = True, path: str | None = None, + directory: str | None = None) -> dict: + """Derives the AI of `player` (faction `faction`) from the scripts and teams of + `like_player`, an AI player of the open map, preferably of a related faction. Needs the + descriptions of both factions (faction_generate) and the framework (ai_framework_generate). + - Scripts all players share are copied unchanged, their lists of enemy types included. + - Scripts that differ between players only in some parameters get those parameters + translated; all other scripts get every type, upgrade, science, special power and command + button of the source faction translated (see faction_map for how; overrides adds pairs). + - Teams are copied with their units translated. `tag` (2-8 letters) replaces the source + player's tag in team, group and script names; team names without it get it in front. + What has no counterpart is left as it is and listed in `unmapped` with where it is used; + drop_unmapped leaves those scripts and teams out instead. `mapped_by_role_only` are the + weakest translations. dry_run (the default) changes nothing; path writes the result as a + scripts_dump file to review or edit and load with scripts_load. With dry_run=false the + player is created if missing and ALL its scripts and teams are replaced (one undo step); + the result then includes faction_check. `side_info` says whether AIData.ini has a SideInfo + and a base layout for the new side, and holds ready arguments for aidata_set_side with the + source side's gatherers, base defense and science skill sets translated; without a + SideInfo the AI buys no sciences. The outcome is a starting point: work through unmapped + and the check, then fill in what makes the faction its own.""" + try: + folder = _faction_dir(directory) + src = faction_data.load(_player_faction(like_player), folder) + dst = faction_data.load(faction, folder) + skeleton = framework_data.load(folder) + dump = wb.call("scripts.dump", include_teams=True) + mapping = scaffold_data.map_names(src, dst, overrides) + made = scaffold_data.scaffold(dump, skeleton, src, dst, like_player, player, tag, mapping, drop_unmapped) + except (ValueError, OSError, yaml.YAMLError) as e: + raise ToolError(str(e)) from e + report = made["report"] + report["mapping"] = mapping["summary"] + # What the new side needs in AIData.ini, derived from the source side the same way. + ai_data = _read_ai_data() + source_side, new_side = src["faction"]["side"], dst["faction"]["side"] + source_info = ai_data_file.for_side(ai_data, source_side) + new_info = ai_data_file.for_side(ai_data, new_side) + side_report: dict[str, Any] = {"side": new_side, "has_side_info": "skill_sets" in new_info, + "has_build_list": "build_list" in new_info} + if "skill_sets" in source_info: + no_counterpart = [] + + def science(name: str) -> str | None: + found = mapping["sciences"].get(name, {}).get("to") or (name if name in dst["sciences"] else None) + if found is None and name not in no_counterpart: + no_counterpart.append(name) + return found + + proposal: dict[str, Any] = {"side": new_side, "gatherers": source_info["gatherers"], + "skill_sets": {str(i + 1): list(dict.fromkeys(s for s in map(science, names) if s)) + for i, names in enumerate(source_info["skill_sets"])}} + defense = mapping["objects"].get(source_info.get("base_defense", ""), {}).get("to") + if defense: + proposal["base_defense"] = defense + if "skill_sets" not in new_info: + proposal["copy_from"] = source_side + side_report["aidata_set_side"] = proposal + side_report["sciences_without_counterpart"] = no_counterpart + report["side_info"] = side_report + if path: + full = os.path.abspath(path) + with open(full, "w", encoding="utf-8", newline="\n") as f: + json.dump(made["document"], f, indent=1, ensure_ascii=False) + report["path"] = full + if dry_run: + report["dry_run"] = True + return report + report["load"] = wb.call("scripts.load", data=made["document"], create_players=True) + checked = faction_data.check(dst, player, wb.call("scripts.dump", player=player, include_teams=True), + wb.call("scripts.build_order", player=player)) + report["check"] = {"issue_counts": checked["issue_counts"], + "errors": [i for i in checked["issues"] if i["severity"] == "error"]} + return report + + +def _game_dir() -> str: + _faction_dir(None) + return _exe_dir or "" + + +@mcp.tool() +def ai_match_run(map: str, players: list[str], minutes: float = 15, seed: int = 1, interval_seconds: float = 10, + starting_cash: int | None = None, wait_seconds: float = 240, log: str | None = None, + scripts: str | Literal["open"] | None = None, ai_data: str | None = None, headless: bool = True) -> dict: + """Lets the game play a skirmish between AI players by itself and write down what happens. + It starts generalszh.exe from WorldBuilder's folder without a window: only the game logic + runs, with the local player as observer, as fast as the machine allows; the game closes + when one side is left or after `minutes` of game time. headless=false shows the match in + a window instead (slower, and it takes the screen), to watch what the AI does. + - map: name of a skirmish map or part of its path ("armed neighbors"). + - players: two or more of "Faction:difficulty[:team]", e.g. ["FactionChina:hard", + "FactionGLA:hard"]; difficulty easy, normal or hard; players with the same team number + are allies. Start positions are random, fixed by seed. + - seed: the same seed gives the same match as long as scripts and game data are the same. + By default the game uses the installed AI (Data/Scripts/SkirmishScripts.scb and + AIData.ini). To try changes without installing them: + - scripts="open" plays with the scripts and teams open in WorldBuilder right now (they + are exported to a file next to the log; the open map must hold the whole AI set, i.e. + the Skirmish... players), or scripts= plays with that file; + - ai_data= reads that AIData.ini on top of the game's: its settings, SideInfo + blocks and build lists replace those of the same name (see aidata_set_side and + buildlist_export with path). + The report names the files a match used. + A match runs several times faster than real time. The call waits up to wait_seconds; if + the match is still going it returns status "running" and ai_match_report can be asked + later. The log (JSON lines) goes to worldbuilder_factions/matches unless log names a file.""" + game_dir = _game_dir() + if log is None: + stamp = time.strftime("%Y%m%d_%H%M%S") + name = "".join(c if c.isalnum() else "_" for c in map)[:30] + log = os.path.join(_faction_dir(None), "matches", f"{stamp}_{name}_seed{seed}.jsonl") + log = os.path.abspath(log) + if scripts == "open": + owners = [p["playerName"] for p in wb.call("sides.list")["players"] if p.get("playerName", "").startswith("Skirmish")] + if not owners: + raise ToolError("the open map has no Skirmish... players: import the AI script set first, or pass a .scb path") + scripts = os.path.splitext(log)[0] + ".scb" + os.makedirs(os.path.dirname(scripts), exist_ok=True) + wb.call("scripts.export", path=scripts, include_players=True, overwrite=True, backup=False) + try: + result = match_data.run(game_dir, map, players, log, minutes, seed, interval_seconds, starting_cash, wait_seconds, + scripts=os.path.abspath(scripts) if scripts else None, + ai_data=os.path.abspath(ai_data) if ai_data else None, headless=headless) + except OSError as e: + raise ToolError(str(e)) from e + if scripts: + result["scripts"] = os.path.abspath(scripts) + if os.path.exists(result["log"]): + brief = match_data.report(match_data.read(result["log"])) + result.update({k: brief[k] for k in ("status", "reason", "winners", "game_minutes") if k in brief}) + if brief.get("status") == "error": + result["error"] = brief["reason"] + elif result["status"] == "finished": + result["status"] = "error" + result["error"] = "the game ended without writing a log (did it start?)" + return result + + +@mcp.tool() +def ai_match_report(log: str | None = None, player: str | None = None, compare_scripts: bool = True, limit: int = 25) -> dict: + """What happened in an AI match (ai_match_run), from its log; log defaults to the newest + one. Per player (or the one named by match name, faction, or script player such as + SkirmishChina): + - score, money at the end, rank; economy (money range, earned, spent, when power ran short); + - structures and units in the order they first appeared, with seconds of game time and + how many were built and lost; + - never_built: structures and combat units of the faction that never appeared (needs the + faction description); + - longest_pause: the longest time in which nothing was built; + - teams the AI started, with counts and first time; sciences and upgrades in order; + - never_ran (compare_scripts, needs the same AI set open in WorldBuilder): scripts that + could have run on this difficulty and never did, and teams that were never started. + Each player's `trouble` lists what its scripts and its AI complained about, most frequent + first ("Dozer unable to build - money or technology missing", "could not find building ... + in the building template list", scripts not defined ...); `trouble` at the top holds what + was reported outside of any player. Times are game seconds. A match that hit the time + limit has no winner; compare scores instead.""" + try: + if log is None: + folder = os.path.join(_faction_dir(None), "matches") + logs = sorted((os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(".jsonl")), key=os.path.getmtime) \ + if os.path.isdir(folder) else [] + if not logs: + raise ToolError("no match logs yet; run ai_match_run") + log = logs[-1] + records = match_data.read(os.path.abspath(log)) + factions: dict[str, Any] = {} + for record in records: + if record["t"] == "start": + for entry in record["players"]: + try: + factions[entry["faction"]] = faction_data.load(entry["faction"], _faction_dir(None)) + except (OSError, ValueError, yaml.YAMLError): + pass + dump = None + if compare_scripts: + try: + dump = wb.call("scripts.dump", include_teams=True) + except ToolError: + dump = None + result = match_data.report(records, dump, factions, player, limit) + result["log"] = os.path.abspath(log) + return result + except OSError as e: + raise ToolError(str(e)) from e + + +@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: + """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 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 +# -------------------------------------------------------------------------------------------------- + +@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, + 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). + Ships: `ships_enabled` is EnableShips of the map.ini. Per player, `naval` holds the number + of shipyard waypoints (Shipyard_, numbered from 1 without gaps; the AI builds + its shipyards there), whether the area NavalPerimeter exists, and the waypoints on + paths labeled Naval and NavalFlank. `naval_problems` lists what a map with + ships lacks, or that a map has naval markers but no EnableShips; `naval_ready` is true when + ships are enabled and nothing is missing.""" + 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--