Index: binaries/data/mods/public/gui/session/input.js =================================================================== --- binaries/data/mods/public/gui/session/input.js +++ binaries/data/mods/public/gui/session/input.js @@ -1642,14 +1642,12 @@ } } -var lastIdleUnit = 0; -var currIdleClassIndex = 0; +var lastIdleUnit = INVALID_ENTITY; var lastIdleClasses = []; function resetIdleUnit() { - lastIdleUnit = 0; - currIdleClassIndex = 0; + lastIdleUnit = INVALID_ENTITY; lastIdleClasses = []; } @@ -1663,19 +1661,13 @@ resetIdleUnit(); lastIdleClasses = classes; - let data = { + let idleUnits = Engine.GuiInterfaceCall("IdleUnitsByClass", { "viewedPlayer": g_ViewedPlayer, - "excludeUnits": append ? g_Selection.toList() : [], - // If the current idle class index is not 0, put the class at that index first. - "idleClasses": classes.slice(currIdleClassIndex, classes.length).concat(classes.slice(0, currIdleClassIndex)) - }; - if (!selectall) - { - data.limit = 1; - data.prevUnit = lastIdleUnit; - } + "idleClasses": classes + }); + if(append) + idleUnits = idleUnits.filter(e => !g_Selection.selected.has(e)); - let idleUnits = Engine.GuiInterfaceCall("FindIdleUnits", data); if (!idleUnits.length) { // TODO: display a message to indicate no more idle units, or something @@ -1688,19 +1680,21 @@ if (!append) g_Selection.reset(); - g_Selection.addList(idleUnits); if (selectall) + { + g_Selection.addList(idleUnits); return; + } - lastIdleUnit = idleUnits[0]; - let entityState = GetEntityState(lastIdleUnit); + const nextIdleUnitIndex = idleUnits.indexOf(lastIdleUnit) + 1; + const idleUnit = idleUnits[nextIdleUnitIndex == idleUnits.length ? 0 : nextIdleUnitIndex]; + g_Selection.addList([idleUnit]); + lastIdleUnit = idleUnit; + + const entityState = GetEntityState(idleUnit); if (entityState.position) Engine.CameraMoveTo(entityState.position.x, entityState.position.z); - - // Move the idle class index to the first class an idle unit was found for. - let indexChange = data.idleClasses.findIndex(elem => MatchesClassList(entityState.identity.classes, elem)); - currIdleClassIndex = (currIdleClassIndex + indexChange) % classes.length; } function clearSelection() Index: binaries/data/mods/public/gui/session/minimap/MiniMapIdleWorkerButton.js =================================================================== --- binaries/data/mods/public/gui/session/minimap/MiniMapIdleWorkerButton.js +++ binaries/data/mods/public/gui/session/minimap/MiniMapIdleWorkerButton.js @@ -25,11 +25,10 @@ rebuild() { - const totalNumberIdleWorkers = Engine.GuiInterfaceCall("FindIdleUnits", { + const totalNumberIdleWorkers = Engine.GuiInterfaceCall("CountIdleUnits", { "viewedPlayer": g_ViewedPlayer, - "idleClasses": this.idleClasses, - "excludeUnits": [] - }).length; + "idleClasses": this.idleClasses + }); this.idleWorkerButton.enabled = totalNumberIdleWorkers > 0; this.totalNumberIdleWorkers.caption = totalNumberIdleWorkers ? setStringTags(totalNumberIdleWorkers, this.DefaultTotalNumberIdleWorkersTags) : ""; } Index: binaries/data/mods/public/gui/summary/counters.js =================================================================== --- binaries/data/mods/public/gui/summary/counters.js +++ binaries/data/mods/public/gui/summary/counters.js @@ -334,6 +334,15 @@ return { "population": playerState.sequences.populationCount[index] }; } +function calculateIdleTime(playerState, index) +{ + const idleTime = {}; + for (const cls of ["worker", "femaleCitizen", "trader", "fishingBoat", "soldier"]) + idleTime[cls] = playerState.sequences.idleTime[cls][index] / 1000; + + return idleTime; +} + function calculateMapExploration(playerState, index) { return { "percent": playerState.sequences.percentMapExplored[index] }; @@ -360,7 +369,7 @@ if (type == "killDeath") return calculateRatio(g_TeamHelperData[team].enemyUnitsKilled[index], g_TeamHelperData[team].unitsLost[index]); - if (type == "bribes" || type == "population") + if (type == "bribes" || type == "population" || type == "idleTime") return summaryArraySum(getPlayerValuesPerTeam(team, index, type, counters, headings)); return { "percent": g_TeamHelperData[team][type][index] }; Index: binaries/data/mods/public/gui/summary/layout.js =================================================================== --- binaries/data/mods/public/gui/summary/layout.js +++ binaries/data/mods/public/gui/summary/layout.js @@ -194,6 +194,7 @@ { "identifier": "playername", "caption": translate("Player name"), "yStart": 26, "width": 200 }, { "identifier": "killDeath", "caption": translate("Kill / Death ratio"), "yStart": 16, "width": 100, "format": "DECIMAL2" }, { "identifier": "population", "caption": translate("Population"), "yStart": 16, "width": 100, "hideInSummary": true }, + { "identifier": "idleTime", "caption": translate("Idle time"), "yStart": 16, "width": 100, "hideInSummary": true, "format": "DURATION_SHORT" }, { "identifier": "mapControlPeak", "caption": translate("Map control (peak)"), "yStart": 16, "width": 100, "format": "PERCENTAGE" }, { "identifier": "mapControl", "caption": translate("Map control (finish)"), "yStart": 16, "width": 100, "format": "PERCENTAGE" }, { "identifier": "mapExploration", "caption": translate("Map exploration"), "yStart": 16, "width": 100, "format": "PERCENTAGE" }, @@ -215,6 +216,7 @@ "counters": [ { "width": 100, "fn": calculateKillDeathRatio, "verticalOffset": 12 }, { "width": 100, "fn": calculatePopulationCount, "verticalOffset": 12, "hideInSummary": true }, + { "width": 100, "fn": calculateIdleTime, "verticalOffset": 12, "hideInSummary": true }, { "width": 100, "fn": calculateMapPeakControl, "verticalOffset": 12 }, { "width": 100, "fn": calculateMapFinalControl, "verticalOffset": 12 }, { "width": 100, "fn": calculateMapExploration, "verticalOffset": 12 }, Index: binaries/data/mods/public/gui/summary/summary.js =================================================================== --- binaries/data/mods/public/gui/summary/summary.js +++ binaries/data/mods/public/gui/summary/summary.js @@ -94,6 +94,21 @@ "caption": translate("Received"), "postfix": "" }, + "worker": { + "caption": translate("Workers") + }, + "femaleCitizen": { + "caption": translate("Female citizens") + }, + "soldier": { + "caption": translate("Soldiers") + }, + "trader": { + "caption": translate("Trader") + }, + "fishingBoat": { + "caption": translate("Fishing boat") + }, "population": { "color": g_TypeColors.red, "caption": translate("Population"), Index: binaries/data/mods/public/simulation/components/Garrisonable.js =================================================================== --- binaries/data/mods/public/simulation/components/Garrisonable.js +++ binaries/data/mods/public/simulation/components/Garrisonable.js @@ -100,6 +100,7 @@ cmpPosition.MoveOutOfWorld(); Engine.PostMessage(this.entity, MT_GarrisonedStateChanged, { + "entity": this.entity, "oldHolder": INVALID_ENTITY, "holderID": target }); @@ -143,6 +144,7 @@ } Engine.PostMessage(this.entity, MT_GarrisonedStateChanged, { + "entity": this.entity, "oldHolder": this.holder, "holderID": INVALID_ENTITY }); 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 @@ -1838,98 +1838,27 @@ }; /** - * Find any idle units. + * Get any idle units. * * @param data.idleClasses Array of class names to include. - * @param data.prevUnit The previous idle unit, if calling a second time to iterate through units. May be left undefined. - * @param data.limit The number of idle units to return. May be left undefined (will return all idle units). - * @param data.excludeUnits Array of units to exclude. * - * Returns an array of idle units. - * If multiple classes were supplied, and multiple items will be returned, the items will be sorted by class. + * Returns an array that contains all units satisfying any of the idleClasses. */ -GuiInterface.prototype.FindIdleUnits = function(player, data) +GuiInterface.prototype.IdleUnitsByClass = function(player, data) { - let idleUnits = []; - // The general case is that only the 'first' idle unit is required; filtering would examine every unit. - // This loop imitates a grouping/aggregation on the first matching idle class. - let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); - for (let entity of cmpRangeManager.GetEntitiesByPlayer(player)) - { - let filtered = this.IdleUnitFilter(entity, data.idleClasses, data.excludeUnits); - if (!filtered.idle) - continue; - - // If the entity is in the 'current' (first, 0) bucket on a resumed search, it must be after the "previous" unit, if any. - // By adding to the 'end', there is no pause if the series of units loops. - let bucket = filtered.bucket; - if (bucket == 0 && data.prevUnit && entity <= data.prevUnit) - bucket = data.idleClasses.length; - - if (!idleUnits[bucket]) - idleUnits[bucket] = []; - idleUnits[bucket].push(entity); - - // If enough units have been collected in the first bucket, go ahead and return them. - if (data.limit && bucket == 0 && idleUnits[0].length == data.limit) - return idleUnits[0]; - } - - let reduced = idleUnits.reduce((prev, curr) => prev.concat(curr), []); - if (data.limit && reduced.length > data.limit) - return reduced.slice(0, data.limit); - - return reduced; -}; - -/** - * Discover if the player has idle units. - * - * @param data.idleClasses Array of class names to include. - * @param data.excludeUnits Array of units to exclude. - * - * Returns a boolean of whether the player has any idle units - */ -GuiInterface.prototype.HasIdleUnits = function(player, data) -{ - let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); - return cmpRangeManager.GetEntitiesByPlayer(player).some(unit => this.IdleUnitFilter(unit, data.idleClasses, data.excludeUnits).idle); + const cmpPlayer = QueryPlayerIDInterface(player); + return cmpPlayer ? cmpPlayer.IdleUnitsByClass(data.idleClasses) : []; }; /** - * Whether to filter an idle unit - * - * @param unit The unit to filter. - * @param idleclasses Array of class names to include. - * @param excludeUnits Array of units to exclude. + * Returns if the current player has any idle units. * - * Returns an object with the following fields: - * - idle - true if the unit is considered idle by the filter, false otherwise. - * - bucket - if idle, set to the index of the first matching idle class, undefined otherwise. + * @param data.idleClasses Array of class names to include. */ -GuiInterface.prototype.IdleUnitFilter = function(unit, idleClasses, excludeUnits) +GuiInterface.prototype.CountIdleUnits = function(player, data) { - let cmpUnitAI = Engine.QueryInterface(unit, IID_UnitAI); - if (!cmpUnitAI || !cmpUnitAI.IsIdle()) - return { "idle": false }; - - let cmpGarrisonable = Engine.QueryInterface(unit, IID_Garrisonable); - if (cmpGarrisonable && cmpGarrisonable.IsGarrisoned()) - return { "idle": false }; - - const cmpTurretable = Engine.QueryInterface(unit, IID_Turretable); - if (cmpTurretable && cmpTurretable.IsTurreted()) - return { "idle": false }; - - let cmpIdentity = Engine.QueryInterface(unit, IID_Identity); - if (!cmpIdentity) - return { "idle": false }; - - let bucket = idleClasses.findIndex(elem => MatchesClassList(cmpIdentity.GetClassesList(), elem)); - if (bucket == -1 || excludeUnits.indexOf(unit) > -1) - return { "idle": false }; - - return { "idle": true, "bucket": bucket }; + const cmpPlayer = QueryPlayerIDInterface(player); + return cmpPlayer ? cmpPlayer.CountIdleUnits(data.idleClasses) : false; }; GuiInterface.prototype.GetTradingRouteGain = function(player, data) @@ -2128,8 +2057,8 @@ "GetFoundationSnapData": 1, "PlaySound": 1, "PlaySoundForPlayer": 1, - "FindIdleUnits": 1, - "HasIdleUnits": 1, + "IdleUnitsByClass": 1, + "CountIdleUnits": 1, "GetTradingRouteGain": 1, "GetTradingDetails": 1, "CanAttack": 1, Index: binaries/data/mods/public/simulation/components/Player.js =================================================================== --- binaries/data/mods/public/simulation/components/Player.js +++ binaries/data/mods/public/simulation/components/Player.js @@ -82,6 +82,7 @@ "buy": clone(this.template.BarterMultiplier.Buy), "sell": clone(this.template.BarterMultiplier.Sell) }; + this.idleUnits = new Map(); // {ClassList: {"units": [Unit], "lastUpdate": 0, "idleTime": 0}; // Initial resources. let resCodes = Resources.GetCodes(); @@ -791,6 +792,8 @@ if (msg.from == this.playerID) { + this.IdleUnitChanged(msg.entity); + if (cmpCost) this.popUsed -= cmpCost.GetPopCost(); @@ -804,6 +807,8 @@ } if (msg.to == this.playerID) { + this.IdleUnitChanged(msg.entity); + if (cmpCost) this.popUsed += cmpCost.GetPopCost(); @@ -819,6 +824,125 @@ } }; + +Player.prototype.IsOwnedIdleUnit = function(entity) +{ + const cmpOwnership = Engine.QueryInterface(entity, IID_Ownership); + if (!cmpOwnership || cmpOwnership.GetOwner() != this.GetPlayerID()) + return false; + + const cmpUnitAI = Engine.QueryInterface(entity, IID_UnitAI); + if (!cmpUnitAI || !cmpUnitAI.IsIdle()) + return false; + + const cmpGarrisonable = Engine.QueryInterface(entity, IID_Garrisonable); + if (cmpGarrisonable && cmpGarrisonable.IsGarrisoned()) + return false; + + const cmpTurretable = Engine.QueryInterface(entity, IID_Turretable); + if (cmpTurretable && cmpTurretable.IsTurreted()) + return false; + + return true; +}; + +Player.prototype.IdleUnitsByClass = function(matchClasses) +{ + // Transform the string to an array + if (typeof matchClasses === "string") + matchClasses = matchClasses.split(/\s+/); + + const units = []; + + for (const unitType of this.idleUnits.values()) + for (let i = 0; i < matchClasses.length; ++i) + if(MatchesClassList(unitType.classes, [matchClasses[i]])) + { + units.push(...unitType.units); + break; + } + + return units; +}; + +Player.prototype.CountIdleUnits = function(matchClasses) +{ + let count = 0; + for (const unitType of this.idleUnits.values()) + if(MatchesClassList(unitType.classes, matchClasses)) + count += unitType.units.size; + + return count; +}; + +Player.prototype.GetIdleTime = function(matchClasses) +{ + const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + const time = cmpTimer.GetTime(); + let idleTime = 0; + for(const unitType of this.idleUnits.values()) + if(MatchesClassList(unitType.classes, matchClasses)) + { + unitType.idleTime += (time - unitType.lastUpdate) * unitType.units.size; + unitType.lastUpdate = time; + + idleTime += unitType.idleTime; + } + + return idleTime; +}; + +Player.prototype.IdleUnitChanged = function(entity) +{ + const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + const time = cmpTimer.GetTime(); + + const cmpIdentity = Engine.QueryInterface(entity, IID_Identity); + if (!cmpIdentity) + return; + const classesList = cmpIdentity.GetClassesList(); + const key = classesList.join(" "); + const idleUnitData = this.idleUnits.get(key); + + if(this.IsOwnedIdleUnit(entity)) + { + if(idleUnitData === undefined) + this.idleUnits.set(key, { + "units": new Set([entity]), + "lastUpdate": time, + "classes": classesList, + "idleTime": 0 + }); + else + { + idleUnitData.idleTime += (time - idleUnitData.lastUpdate) * idleUnitData.units.size; + idleUnitData.lastUpdate = time; + idleUnitData.units.add(entity); + } + } + else if(idleUnitData !== undefined && idleUnitData.units.has(entity)) + { + idleUnitData.idleTime += (time - idleUnitData.lastUpdate) * idleUnitData.units.size; + idleUnitData.lastUpdate = time; + idleUnitData.units.delete(entity); + } +}; + +Player.prototype.OnGlobalUnitIdleChanged = function(msg) +{ + this.IdleUnitChanged(msg.entity); +}; + +Player.prototype.OnGlobalGarrisonedStateChanged = function(msg) +{ + this.IdleUnitChanged(msg.entity); +}; + +Player.prototype.OnGlobalTurretedStateChanged= function(msg) +{ + this.IdleUnitChanged(msg.entity); +}; + Player.prototype.OnResearchFinished = function(msg) { if (msg.tech == this.template.SharedLosTech) Index: binaries/data/mods/public/simulation/components/StatisticsTracker.js =================================================================== --- binaries/data/mods/public/simulation/components/StatisticsTracker.js +++ binaries/data/mods/public/simulation/components/StatisticsTracker.js @@ -149,7 +149,8 @@ "peakPercentMapControlled": this.peakPercentMapControlled, "teamPeakPercentMapControlled": this.teamPeakPercentMapControlled, "successfulBribes": this.successfulBribes, - "failedBribes": this.failedBribes + "failedBribes": this.failedBribes, + "idleTime": this.GetIdleTime() }; }; @@ -522,6 +523,18 @@ this.teamPeakPercentMapControlled = Math.max(this.teamPeakPercentMapControlled, this.GetTeamPercentMapControlled()); }; +StatisticsTracker.prototype.GetIdleTime = function() +{ + const cmpPlayer = Engine.QueryInterface(this.entity, IID_Player); + return { + "worker": cmpPlayer.GetIdleTime(["Worker"]), + "femaleCitizen": cmpPlayer.GetIdleTime(["FemaleCitizen"]), + "trader": cmpPlayer.GetIdleTime(["Trader"]), + "fishingBoat": cmpPlayer.GetIdleTime(["FishingBoat"]), + "soldier": cmpPlayer.GetIdleTime(["Soldier"]), + }; +}; + /** * Adds the values of fromData to the end of the arrays of toData. * If toData misses the needed array, one will be created. Index: binaries/data/mods/public/simulation/components/Turretable.js =================================================================== --- binaries/data/mods/public/simulation/components/Turretable.js +++ binaries/data/mods/public/simulation/components/Turretable.js @@ -91,6 +91,7 @@ cmpObstruction.SetActive(false); Engine.PostMessage(this.entity, MT_TurretedStateChanged, { + "entity": this.entity, "oldHolder": INVALID_ENTITY, "holderID": target }); @@ -148,6 +149,7 @@ cmpObstruction.SetActive(true); Engine.PostMessage(this.entity, MT_TurretedStateChanged, { + "entity": this.entity, "oldHolder": this.holder, "holderID": INVALID_ENTITY }); Index: binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- binaries/data/mods/public/simulation/components/UnitAI.js +++ binaries/data/mods/public/simulation/components/UnitAI.js @@ -1612,7 +1612,7 @@ if (this.IsFormationMember()) Engine.QueryInterface(this.formationController, IID_Formation).UnsetIdleEntity(this.entity); this.isIdle = false; - Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); + Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle, "entity": this.entity }); } }, @@ -1683,7 +1683,7 @@ } this.isIdle = true; - Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); + Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle, "entity": this.entity }); } // Go linger first to prevent all roaming entities @@ -3504,7 +3504,7 @@ if (this.isIdle == shouldBeIdle) return; this.isIdle = shouldBeIdle; - Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); + Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle, "entity": this.entity }); }; UnitAI.prototype.SetGarrisoned = function() Index: binaries/data/mods/public/simulation/components/tests/test_Player.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Player.js +++ binaries/data/mods/public/simulation/components/tests/test_Player.js @@ -21,6 +21,7 @@ Engine.LoadComponentScript("interfaces/Cost.js"); Engine.LoadComponentScript("interfaces/Foundation.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.js"); +Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("Player.js"); var cmpPlayer = ConstructComponent(10, "Player", { @@ -74,23 +75,26 @@ }); AddMock(60, IID_Identity, { - "GetClassesList": () => {}, + "GetClassesList": () => [], "HasClass": (cl) => true }); AddMock(60, IID_Ownership); +AddMock(SYSTEM_ENTITY, IID_Timer, { + "GetTime": () => 0 +}); AddMock(60, IID_Foundation, {}); cmpPlayer.OnGlobalOwnershipChanged({ "entity": 60, "from": INVALID_PLAYER, "to": playerID }); TS_ASSERT(!cmpPlayer.CanBarter()); AddMock(61, IID_Identity, { - "GetClassesList": () => {}, + "GetClassesList": () => [], "HasClass": (cl) => false }); cmpPlayer.OnGlobalOwnershipChanged({ "entity": 61, "from": INVALID_PLAYER, "to": playerID }); TS_ASSERT(!cmpPlayer.CanBarter()); AddMock(62, IID_Identity, { - "GetClassesList": () => {}, + "GetClassesList": () => [], "HasClass": (cl) => true }); cmpPlayer.OnGlobalOwnershipChanged({ "entity": 62, "from": INVALID_PLAYER, "to": playerID });