Index: binaries/data/mods/public/gui/session/selection.js =================================================================== --- binaries/data/mods/public/gui/session/selection.js +++ binaries/data/mods/public/gui/session/selection.js @@ -285,6 +285,7 @@ return; let added = []; + ents = this.addBattalionMembers(ents); for (const ent of ents) { @@ -326,7 +327,8 @@ EntitySelection.prototype.removeList = function(ents) { - var removed = []; + let removed = []; + ents = this.addBattalionMembers(ents); for (let ent of ents) if (this.selected.has(ent)) @@ -410,6 +412,7 @@ EntitySelection.prototype.setHighlightList = function(ents) { const highlighted = new Set(); + ents = this.addBattalionMembers(ents); for (const ent of ents) highlighted.add(ent); @@ -459,7 +462,25 @@ this.addList([entityID]); Engine.CameraMoveTo(entState.position.x, entState.position.z); -} +}; + +/** + * Adds the battalion members of a selected entities to the selection. + * @param {number[]} entities - The entity IDs of selected entities. + * @return {number[]} - Some more entity IDs if part of a battalion was selected. + */ +EntitySelection.prototype.addBattalionMembers = function(entities) +{ + if (entities.length) + { + const battalions = Engine.GuiInterfaceCall("GetBattalions", { "players": undefined }); + for (const battalion of battalions) + if (entities.some(entity => battalion.entities.indexOf(entity) != -1)) + entities = entities.concat(battalion.entities.filter(entity => entities.indexOf(entity) == -1)); + } + + return entities; +}; /** * Cache some quantities which depends only on selection Index: binaries/data/mods/public/gui/session/selection_panels_helpers.js =================================================================== --- binaries/data/mods/public/gui/session/selection_panels_helpers.js +++ binaries/data/mods/public/gui/session/selection_panels_helpers.js @@ -318,6 +318,34 @@ }); } +/** + * @param {number[]} entities - The entity IDs of the entities. + */ +function formBattalion(entities) +{ + if (!entities || !entities.length) + return; + + Engine.PostNetworkCommand({ + "type": "formbattalion", + "entities": entities + }); +} + +/** + * @param {number[]} battalions - The IDs of the battalions to disband. + */ +function disbandBattalion(battalions) +{ + if (!battalions || !battalions.length) + return; + + Engine.PostNetworkCommand({ + "type": "disbandbattalion", + "battalions": battalions + }); +} + function performStance(entities, stanceName) { if (!entities) Index: binaries/data/mods/public/gui/session/unit_actions.js =================================================================== --- binaries/data/mods/public/gui/session/unit_actions.js +++ binaries/data/mods/public/gui/session/unit_actions.js @@ -1491,6 +1491,52 @@ "allowedPlayers": ["Player"] }, + "formBattalion": { + "getInfo": function(entStates) + { + const refTemplateName = entStates[0].identity.selectionGroupName || entStates[0].template; + if (entStates.length < 2 || + entStates.some(entState => + (entState.identity.selectionGroupName || entState.template) != refTemplateName + )) + return false; + + return { + "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.formbattalion") + + translate("Form a battalion with the currently selected units."), + "icon": "training.png", + "enabled": true + }; + }, + "execute": function(entStates) + { + if (entStates.length) + formBattalion(entStates.map(entState => entState.id)); + }, + "allowedPlayers": ["Player"] + }, + + "disbandBattalion": { + "getInfo": function(entStates) + { + if (entStates.every(entState => entState.battalion == false)) + return false; + + return { + "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.disbandbattalion") + + translate("Disband all currently selected battalion(s)."), + "icon": "cancel.png", + "enabled": true + }; + }, + "execute": function(entStates) + { + if (entStates.length) + disbandBattalion(entStates.map(entState => entState.battalion)); + }, + "allowedPlayers": ["Player"] + }, + "garrison": { "getInfo": function(entStates) { Index: binaries/data/mods/public/simulation/components/BattalionManager.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/BattalionManager.js @@ -0,0 +1,181 @@ +function BattalionManager() {} + +BattalionManager.prototype.Schema = + ""; + +BattalionManager.prototype.Init = function() +{ + this.battalions = new Map(); + this.nextId = 0; +}; + +/** + * Creates a new battalion. + * This now reuses IDs of disbanded battalions. + * @param {number[]} entities - The entity IDs to form the battalion with. + */ +BattalionManager.prototype.FormBattalion = function(player, entities) +{ + this.battalions.set(this.nextId++, { + "player": player, + "entities": new Set() + }); + this.AddEntities(id, entities); +}; + +/** + * Removes battalion(s). + * @param {number[]} battalionIDs - The IDs of the battalions. If undefinded all battalions will be removed. + */ +BattalionManager.prototype.DisbandBattalions = function(battalionIDs = undefined) +{ + if (battalionIDs) + for (const id of battalionIDs) + this.battalions.delete(id); + else + this.battalions.clear(); +}; + +/** + * Adds an entity/entities to the battalion. + * @param {number} battalionID - The unique ID of the battalion. + * @param {number} battalionID - The index of the battalion, passed by "formBattalion". + * @param {number[]} entities - The entity IDs to add to the battalion. + */ +BattalionManager.prototype.AddEntities = function(battalionID, entities) +{ + let battalion = this.battalions.get(battalionID); + if (!battalion) + { + error("Entity/Entities " + entities + " could not be added to battalion " + battalionID + " for that battalion does not exist."); + return; + } + const cmpGuiInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface) + for (const entity of entities) + { + // Entities cannot be in more than one battalion at the same time. + let bat = this.PartOfBattalion(entity); + if (bat !== -1) + this.RemoveEntities(bat, [entity]); + + const entState = cmpGuiInterface.GetEntityState(battalion.player, entity); + + // When this function is called during group rebuild, deleted + // entities will not yet have been removed, so entities might + // still be present in the group despite not existing. + if (!entState) + continue; + + battalion.entities.add(entity); + if (!battalion.template) + battalion.template = entState.selectionGroupName || entState.template; + } +}; + +/** + * Removes an entity/entities from the battalion. + * @param {number} battalionID - The unique ID of the battalion. + * @param {number} entities - The entity ID of the entities to remove. + */ +BattalionManager.prototype.RemoveEntities = function(battalionID, entities) +{ + const battalion = this.battalions.get(battalionID); + for (const entity of entities) + battalion.entities.delete(entity); +}; + +/** + * Check wheter an entity is part of an battalion. + * @param {number} entity - The entity ID to check. + * @return {number} - Either a number when the entity is part of a battalion + * or "0" when it is not. + */ +BattalionManager.prototype.PartOfBattalion = function(entity) +{ + for (const [id, battalion] of this.battalions.entries()) + if (battalion.entities.has(entity)) + return id; + + return 0; +}; + +/** + * Update the battalions. E.g. when units die or change ownership and such. + * ToDo: Run this when necessary. + */ +BattalionManager.prototype.Update = function() +{ + this.CheckRenamedEntities(); + const battalionsToDisband = []; + for (const [id, battalion] of this.battalions.entries()) + { + const entitiesToRemove = []; + for (const entity of battalion.entities) + { + const entState = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface).GetEntityState(battalion.player, entity); + // Remove deleted units or now unowned units. + if (!entState || entState.player != battalion.player) + entitiesToRemove.push(entity); + } + this.RemoveEntities(id, entitiesToRemove); + + if (!battalion.entities.length) + battalionsToDisband.push(id); + } + if (battalionsToDisband.length) + this.DisbandBattalions(battalionsToDisband); +}; + +/** + * Update battalions if some entities in the battalion were renamed + * (in case of unit promotion or finishing building structure). + * ToDo: This needs some love. + */ +BattalionManager.prototype.CheckRenamedEntities = function() +{ + const cmpGuiInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface); + // Reconstruct groups if at least one entity has been renamed. + const renamedEntities = cmpGuiInterface.GetRenamedEntities(); + if (renamedEntities.length == 0) + return; + + for (const [id, battalion] of this.battalions.entries()) + { + renamedEntities = cmpGuiInterface.GetRenamedEntities(battalion.player); + for (const renamedEntity of renamedEntities) + { + if (battalion.entities.has(renamedEntity.entity)) + { + this.RemoveEntities(id, [renamedEntity.entity]); + const cmpOwner = Engine.QueryInterface(renamedEntity.newentity, IID_Ownership); + if (cmpOwner && cmpOwner.GetOwner() == battalion.player) + this.AddEntities(id, [renamedEntity.newentity]); + } + } + } +}; + +/** + * Return the battalion(s). + * @param {number[]} players - Optionally the players we want to check. + * @return {object[]} battalions - The currently used battalions. + */ +BattalionManager.prototype.GetBattalions = function(players = undefined) +{ + if (!players) + return this.battalions; + + return this.battalions.filter(battalion => players.indexOf(battalion.player) != -1); +}; + + +/** + * Subscribe to all changes in ownership and such. + */ +BattalionManager.prototype.OnGlobalOwnershipChanged = function(msg) +{ + if (this.PartOfBattalion(msg.entity) !== -1) + this.Update(); +}; + +Engine.RegisterSystemComponentType(IID_BattalionManager, "BattalionManager", BattalionManager); Index: binaries/data/mods/public/simulation/components/GuiInterface.js =================================================================== --- binaries/data/mods/public/simulation/components/GuiInterface.js +++ binaries/data/mods/public/simulation/components/GuiInterface.js @@ -301,6 +301,9 @@ ret.maxCapturePoints = cmpCapturable.GetMaxCapturePoints(); } + let cmpBattalionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_BattalionManager); + ret.battalion = cmpBattalionManager.PartOfBattalion(ent); + let cmpBuilder = Engine.QueryInterface(ent, IID_Builder); if (cmpBuilder) ret.builder = true; @@ -1036,6 +1039,11 @@ return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetNonGaiaEntities(); }; +GuiInterface.prototype.GetBattalions = function(player, data) +{ + return Engine.QueryInterface(SYSTEM_ENTITY, IID_BattalionManager).GetBattalions(data.players); +}; + /** * Displays the rally points of a given list of entities (carried in cmd.entities). * @@ -2097,6 +2105,7 @@ "IsFormationSelected": 1, "GetFormationInfoFromTemplate": 1, "IsStanceSelected": 1, + "GetBattalions": 1, "UpdateDisplayedPlayerColors": 1, "SetSelectionHighlight": 1, Index: binaries/data/mods/public/simulation/components/interfaces/BattalionManager.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/interfaces/BattalionManager.js @@ -0,0 +1 @@ +Engine.RegisterInterface("BattalionManager"); Index: binaries/data/mods/public/simulation/components/tests/test_BattalionManager.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/tests/test_BattalionManager.js @@ -0,0 +1,74 @@ +Engine.LoadComponentScript("interfaces/BattalionManager.js"); +Engine.LoadComponentScript("BattalionManager.js"); + +let cmpBattalionManager = ConstructComponent(SYSTEM_ENTITY, "BattalionManager"); + +let player = 1; +let group1 = [2, 3, 4, 5]; +let group2 = [6, 7, 8, 9]; +let group3 = [5, 6, 10, 11]; +let allEnts = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +let template = "{civ}/{faction}/{entity}/{rank}"; +let GUItemplate = { + "template": template, + "player": player +}; + +AddMock(SYSTEM_ENTITY, IID_GuiInterface, { + "GetEntityState": (owner, entity) => GUItemplate, + "GetRenamedEntities": () => [] +}); + +// No entity is in a battalion yet. +for (let i of allEnts) + TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(i), 0); +TS_ASSERT_UNEVAL_EQUALS(cmpBattalionManager.GetBattalions(), []); + +cmpBattalionManager.FormBattalion(player, group1); +for (let i of group1) + TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(i), 1); + +cmpBattalionManager.FormBattalion(player, group2); +for (let i of group2) + TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(i), 2); + +// No battalions for player 2. +TS_ASSERT_UNEVAL_EQUALS(cmpBattalionManager.GetBattalions([2]), []); + +// Here we form a battalion with parts of another battalion, in-game this should only +// happen when merging complete battalions, since they are only fully selectable. +cmpBattalionManager.FormBattalion(player, group3); +for (let i of group3) + TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(i), 3); +for (let i of group1) + if (i == 5) + TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(i), 3); + else + TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(i), 1); +for (let i of group2) + if (i == 6) + TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(i), 3); + else + TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(i), 2); + +cmpBattalionManager.DisbandBattalions([1]); +let result = [ + { "id":2, "player": player, "entities": [7, 8, 9], "template": template }, + { "id":3, "player": player, "entities": [5, 6, 10, 11], "template": template } +]; +TS_ASSERT_UNEVAL_EQUALS(cmpBattalionManager.GetBattalions(), result); + +// Verify that a removed entity is indeed not part of a battalion anymore. +cmpBattalionManager.RemoveEntities(2, [8]); +TS_ASSERT_EQUALS(cmpBattalionManager.PartOfBattalion(8), 0); + +// Verify that once the last entity of a battalion is removed the battalion is disbanded. +cmpBattalionManager.RemoveEntities(3, [5, 6, 10, 11]); +cmpBattalionManager.Update(); +result = [{ "id":2, "player": player, "entities": [7, 9], "template": template }]; +TS_ASSERT_UNEVAL_EQUALS(cmpBattalionManager.GetBattalions(), result); + +cmpBattalionManager.FormBattalion(player, allEnts); +cmpBattalionManager.Update(); +result = [{ "id":1, "player": player, "entities": allEnts, "template": template }]; +TS_ASSERT_UNEVAL_EQUALS(cmpBattalionManager.GetBattalions(), result); Index: binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js +++ binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js @@ -41,6 +41,7 @@ Engine.LoadComponentScript("interfaces/Upgrade.js"); Engine.LoadComponentScript("interfaces/Upkeep.js"); Engine.LoadComponentScript("interfaces/BuildingAI.js"); +Engine.LoadComponentScript("interfaces/BattalionManager.js"); Engine.LoadComponentScript("GuiInterface.js"); Resources = { @@ -585,6 +586,10 @@ "GetRates": () => ({ "food": 2, "wood": 3, "stone": 5, "metal": 9 }) }); +AddMock(SYSTEM_ENTITY, IID_BattalionManager, { + "PartOfBattalion": () => false +}); + // Note: property order matters when using TS_ASSERT_UNEVAL_EQUALS, // because uneval preserves property order. So make sure this object // matches the ordering in GuiInterface. @@ -606,6 +611,7 @@ "maxHitpoints": 60, "needsRepair": false, "needsHeal": true, + "battalion": false, "builder": true, "visibility": "visible", "isBarterMarket": true, Index: binaries/data/mods/public/simulation/helpers/Commands.js =================================================================== --- binaries/data/mods/public/simulation/helpers/Commands.js +++ binaries/data/mods/public/simulation/helpers/Commands.js @@ -680,6 +680,20 @@ } }, + "formbattalion": function(player, cmd, data) + { + const cmpBattalionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_BattalionManager); + cmpBattalionManager.FormBattalion(player, data.entities); + cmpBattalionManager.Update(); + }, + + "disbandbattalion": function(player, cmd, data) + { + const cmpBattalionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_BattalionManager); + cmpBattalionManager.DisbandBattalions(cmd.battalions) + cmpBattalionManager.Update(); + }, + "lock-gate": function(player, cmd, data) { for (let ent of data.entities)