diff --git a/binaries/data/mods/public/globalscripts/Templates.js b/binaries/data/mods/public/globalscripts/Templates.js index b7186ac6be..b7143e3c98 100644 --- a/binaries/data/mods/public/globalscripts/Templates.js +++ b/binaries/data/mods/public/globalscripts/Templates.js @@ -204,10 +204,11 @@ class TemplateHelper _context = Object.seal({ "template": {}, "player": null, // null or a player id + "civ": "", "entity": null, // null or an entity id "auraTemplates": {}, "resources": {}, - "customModifiers": null, + "customModifiers": null, // null or an object defining modifications "applyValueModifications": () => 0 }); @@ -217,11 +218,12 @@ class TemplateHelper * @param {Object} template - A valid template as returned by the template loader. * @param {Object} auraTemplates - In the form of { key: { "auraName": "", "auraDescription": "" } }. * @param {Object} resources - An instance of the Resources class. + * @param {string} civ - The civ of which the template is part of. Does not have to match the template's identity's own civ. * @param {string[]} [components] - An array of components to process, if undefined all components are processed. */ - getBasicData(template, auraTemplates, resources, components) + getBasicData(template, auraTemplates, resources, civ, components) { - return this.computeDataFromModifiers(template, auraTemplates, resources, {}, components); + return this.computeDataFromModifiers(template, auraTemplates, resources, {}, civ, components); } /** @@ -230,13 +232,15 @@ class TemplateHelper * @param {Object} template - A valid template as returned by the template loader. * @param {Object} auraTemplates - In the form of { key: { "auraName": "", "auraDescription": "" } }. * @param {Object} resources - An instance of the Resources class. - * @param {Object} modifiers - Modifications to apply to the template, e.g. from auto-researched techs or unit upgrades + * @param {Object} modifiers - Modifications to apply to the template, e.g. from auto-researched techs or civ bonuses. + * @param {string} civ - The civ of which the template is part of. Does not have to match the template's identity's own civ. * @param {string[]} [components] - An array of components to process, if undefined all components are processed. */ - computeDataFromModifiers(template, auraTemplates, resources, modifiers, components) + computeDataFromModifiers(template, auraTemplates, resources, modifiers, civ, components) { this._context.template = template; this._context.player = null; + this._context.civ = civ; this._context.entity = null; this._context.auraTemplates = auraTemplates; this._context.resources = resources; @@ -254,12 +258,14 @@ class TemplateHelper * @param {Object} auraTemplates - In the form of { key: { "auraName": "", "auraDescription": "" } }. * @param {Object} resources - An instance of the Resources class. * @param {number} player - ID of the target player. + * @param {string} playerCiv - Civ of the target player. * @param {string[]} [components] - An array of components to process, if undefined all components are processed. */ - computeDataFromPlayer(template, auraTemplates, resources, player, components) + computeDataFromPlayer(template, auraTemplates, resources, player, playerCiv, components) { this._context.template = template; this._context.player = player; + this._context.civ = playerCiv; this._context.entity = null; this._context.auraTemplates = auraTemplates; this._context.resources = resources; @@ -269,6 +275,32 @@ class TemplateHelper return this._computeModifiedTemplateData(template, components); } + /** + * Pull basic information from a template and to it apply all modifiers registered (in the simulation) to a given entity or + * its owning player. + * Must only be called in simulation context. + * + * @param {Object} template - A valid template as returned by the template loader. + * @param {Object} auraTemplates - In the form of { key: { "auraName": "", "auraDescription": "" } }. + * @param {Object} resources - An instance of the Resources class. + * @param {number} entity - ID of the target entity. + * @param {string} ownerCiv - Civ of the target entity's owner. + * @param {undefined|string[]} components - An array of components to process, if undefined all components are processed. + */ + computeDataFromEntity(template, auraTemplates, resources, entity, ownerCiv, components) + { + this._context.template = template; + this._context.player = null; + this._context.civ = ownerCiv; + this._context.entity = entity; + this._context.auraTemplates = auraTemplates; + this._context.resources = resources; + this._context.customModifiers = null; + this._context.applyValueModifications = this._applyEntityModifications.bind(this); + + return this._computeModifiedTemplateData(template, components); + } + /** * Apply the manually-passed ("custom") modifiers to a given value. */ @@ -289,19 +321,29 @@ class TemplateHelper return ApplyValueModificationsToTemplate(modKey, currentValue, this._context.player, this._context.template); } + /** + * Apply all modifiers of the current entity and of its owner to a given value. + */ + _applyEntityModifications(currentValue, modKey) + { + return ApplyValueModificationsToEntity(modKey, currentValue, this._context.entity); + } + /** * Read a given value from the current template, and apply the current modifications to it. * Only call this for values that can theoretically be modified (i.e. the simulation supports it), like attack damge or max HP. * Static, unmodifiable values (like turret points or footprint size) are better read from the template directly. */ - _getModifiedValue(valuePath, modKey, defaultValue = 0) + _getModifiedValue(valuePath, modKey, isNumeric = true, defaultValue = 0) { let currentValue = this._context.template; for (const property of valuePath.split("/")) currentValue = currentValue[property] || defaultValue; + return isNumeric ? // Using .toFixed() to get around spidermonkey's treatment of numbers (3 * 1.1 = 3.3000000000000003 for instance). - return +this._context.applyValueModifications(+currentValue, modKey || valuePath).toFixed(8); + +this._context.applyValueModifications(+currentValue, modKey || valuePath).toFixed(8) : + this._context.applyValueModifications(currentValue, modKey || valuePath); } /** @@ -348,8 +390,14 @@ class TemplateHelper /** * The methods performing the core logic, pulling and processing the values of the template's individual * components. + * The structure of each component's data has to be coordinated with the one in EntityStateRetriever, so that they + * can be merged into one. */ _componentHandlers = { + "AlertRaiser": (alertRaiser, ret) => + { + ret.alertRaiser = { "classes": alertRaiser.List._string }; + }, "Attack": (attack, ret) => { ret.attack = {}; @@ -430,14 +478,23 @@ class TemplateHelper this._getModifiedValue("BuildRestrictions/Distance/MaxDistance"); } }, + "Builder": (builder, ret) => + { + ret.builder = true; + }, "BuildingAI": (buildingAI, ret) => { ret.buildingAI = { "defaultArrowCount": Math.round(this._getModifiedValue("BuildingAI/DefaultArrowCount")), + "maxArrowCount": Math.round(this._getModifiedValue("BuildingAI/MaxArrowCount")), "garrisonArrowMultiplier": this._getModifiedValue("BuildingAI/GarrisonArrowMultiplier"), - "maxArrowCount": Math.round(this._getModifiedValue("BuildingAI/MaxArrowCount")) + "garrisonArrowClasses": buildingAI.GarrisonArrowClasses?.split(/\s+/) }; }, + "Capturable": (capturable, ret) => + { + ret.maxCapturePoints = Math.round(this._getModifiedValue("Capturable/CapturePoints")); + }, "Cost": (cost, ret) => { ret.cost = {}; @@ -482,6 +539,7 @@ class TemplateHelper "GarrisonHolder": (garrisonHolder, ret) => { ret.garrisonHolder = { + "allowedClasses": this._getModifiedValue("GarrisonHolder/List/_string", null, false, ""), "buffHeal": this._getModifiedValue("GarrisonHolder/BuffHeal"), "capacity": this._getModifiedValue("GarrisonHolder/Max") }; @@ -491,7 +549,9 @@ class TemplateHelper ret.heal = { "health": this._getModifiedValue("Heal/Health"), "range": this._getModifiedValue("Heal/Range"), - "interval": this._getModifiedValue("Heal/Interval") + "interval": this._getModifiedValue("Heal/Interval"), + "unhealableClasses": heal.UnhealableClasses._string || "", + "healableClasses": heal.HealableClasses._string || "" }; }, "Health": (health, ret) => @@ -500,6 +560,7 @@ class TemplateHelper }, "Identity": (identity, ret) => { + const allClasses = GetIdentityClasses(identity); ret.selectionGroupName = identity.SelectionGroupName; ret.name = { "specific": (identity.SpecificName || identity.GenericName), @@ -507,10 +568,14 @@ class TemplateHelper }; ret.icon = identity.Icon; ret.tooltip = identity.Tooltip; + ret.identityClasses = allClasses; ret.visibleIdentityClasses = GetVisibleIdentityClasses(identity); ret.nativeCiv = identity.Civ; ret.requirements = identity.Requirements; ret.rank = identity.Rank; + ret.undeletable = identity.Undeletable; + ret.enablesBartering = allClasses.includes("Barter") && !this._context.template.Foundation; + }, "Loot": (loot, ret) => { @@ -520,6 +585,19 @@ class TemplateHelper ret.loot[type] = this._getModifiedValue("Loot/" + type); }, + "Market": (market, ret) => + { + const tradeTypes = market.TradeType.split(/\s+/); + + ret.market = { + "land": tradeTypes.includes("land"), + "naval": tradeTypes.includes("naval") + }; + }, + "Mirage": (mirage, ret) => + { + ret.mirage = true; + }, "Obstruction": (obstruction, ret) => { ret.obstruction = { @@ -565,6 +643,12 @@ class TemplateHelper "bonus": this._getModifiedValue("Population/Bonus") }; }, + "Promotion": (promotion, ret) => + { + ret.promotion = { + "req": this._getModifiedValue("Promotion/RequiredXp") + }; + }, "Researcher": (researcher, ret) => { ret.researcher = { @@ -573,7 +657,7 @@ class TemplateHelper for (const res of this._context.resources.GetCodes().concat(["time"])) ret.researcher.techCostMultiplier[res] = - this._getModifiedValue("Researcher/TechCostMultiplier/" + res, null, 1); + this._getModifiedValue("Researcher/TechCostMultiplier/" + res, null, true, 1); }, "Resistance": (resistance, ret) => { @@ -621,6 +705,20 @@ class TemplateHelper ret.resourceGatherRates[type] = this._getModifiedValue("ResourceGatherer/Rates/" + type) * baseSpeed; }, + "ResourceSupply": (resourceSupply, ret) => + { + const i = resourceSupply.Type.indexOf('.'); + ret.resourceSupply = { + "isInfinite": !isFinite(+resourceSupply.Max), + "max": this._getModifiedValue("ResourceSupply/Max"), + "type": { + "generic": resourceSupply.Type.slice(0, i), + "specific": resourceSupply.Type.substring(i + 1) + }, + "killBeforeGather": resourceSupply.KillBeforeGather === "true", + "maxGatherers": +resourceSupply.MaxGatherers + }; + }, "ResourceTrickle": (resourceTrickle, ret) => { ret.resourceTrickle = { @@ -649,10 +747,20 @@ class TemplateHelper ret.treasure.resources[resource] = this._getModifiedValue("Treasure/Resources/" + resource); }, + "TreasureCollector": (treasureCollector, ret) => + { + ret.treasureCollector = true; + }, "TurretHolder": (turretHolder, ret) => { ret.turretHolder = { - "turretPoints": turretHolder.TurretPoints + "numTurretPoints": Object.keys(turretHolder.TurretPoints).length + }; + }, + "UnitAI": (unitAI, ret) => + { + ret.unitAI = { + "formations": unitAI.Formations?._string?.split(/\s+/) || [] }; }, "UnitMotion": (unitMotion, ret) => @@ -681,7 +789,7 @@ class TemplateHelper }, "Upgrade": (upgrade, ret) => { - ret.upgrades = []; + ret.upgrade = { "options": [] }; for (const upgradeName in upgrade) { @@ -697,8 +805,10 @@ class TemplateHelper cost.time = this._getModifiedValue("Upgrade/" + upgradeName + "/Time", "Upgrade/Time"); - ret.upgrades.push({ - "entity": upgr.Entity, + ret.upgrade.options.push({ + "entity": upgr.Entity + .replace(/\{civ\}/g, this._context.civ) + .replace(/\{native\}/g, this._context.template.Identity.Civ), "tooltip": upgr.Tooltip, cost, "icon": upgr.Icon, diff --git a/binaries/data/mods/public/gui/common/tooltips.js b/binaries/data/mods/public/gui/common/tooltips.js index d36633f91c..26257e2baa 100644 --- a/binaries/data/mods/public/gui/common/tooltips.js +++ b/binaries/data/mods/public/gui/common/tooltips.js @@ -619,7 +619,7 @@ function getTurretsTooltip(template) return ""; return sprintf(translate("%(label)s: %(turretsLimit)s"), { "label": headerFont(translate("Turret Positions")), - "turretsLimit": Object.keys(template.turretHolder.turretPoints).length + "turretsLimit": template.turretHolder.numTurretPoints }); } diff --git a/binaries/data/mods/public/gui/reference/common/TemplateParser.js b/binaries/data/mods/public/gui/reference/common/TemplateParser.js index 4cef958195..4c1b3901c5 100644 --- a/binaries/data/mods/public/gui/reference/common/TemplateParser.js +++ b/binaries/data/mods/public/gui/reference/common/TemplateParser.js @@ -60,7 +60,8 @@ class TemplateParser return null; const template = this.TemplateLoader.loadEntityTemplate(templateName, civCode); - const parsed = g_TemplateHelper.computeDataFromModifiers(template, this.TemplateLoader.auraData, g_ResourceData, this.modifiers[civCode] || {}); + const parsed = g_TemplateHelper.computeDataFromModifiers(template, this.TemplateLoader.auraData, g_ResourceData, + this.modifiers[civCode] || {}, civCode); parsed.name.internal = templateName; parsed.history = template.Identity.History; @@ -103,15 +104,15 @@ class TemplateParser "amount": template.ResourceSupply.Max, }; - if (parsed.upgrades) - parsed.upgrades = this.getActualUpgradeData(parsed.upgrades, civCode); + if (parsed.upgrade) + parsed.upgrade.options = this.getActualUpgradeData(parsed.upgrade.options, civCode); if (parsed.wallSet) { parsed.wallset = {}; - if (!parsed.upgrades) - parsed.upgrades = []; + if (!parsed.upgrade) + parsed.upgrade = { "options": [] }; // Note: An assumption is made here that wall segments all have the same resistance and auras let struct = this.getEntity(parsed.wallSet.templates.long, civCode); @@ -135,8 +136,8 @@ class TemplateParser for (const research of wPart.production.techs) parsed.production.techs.push(research); - if (wPart.upgrades) - Array.prototype.push.apply(parsed.upgrades, wPart.upgrades); + if (wPart.upgrade) + Array.prototype.push.apply(parsed.upgrade.options, wPart.upgrade.options); if (["gate", "tower"].indexOf(wSegm) != -1) continue; @@ -262,9 +263,8 @@ class TemplateParser const newUpgrades = []; for (const upgrade of upgradesInfo) { - upgrade.entity = upgrade.entity.replace(/\{(civ|native)\}/g, civCode); - - const data = g_TemplateHelper.computeDataFromModifiers(this.TemplateLoader.loadEntityTemplate(upgrade.entity, civCode), this.TemplateLoader.auraData, g_ResourceData, this.modifiers[civCode] || {}); + const data = g_TemplateHelper.computeDataFromModifiers(this.TemplateLoader.loadEntityTemplate(upgrade.entity, civCode), + this.TemplateLoader.auraData, g_ResourceData, this.modifiers[civCode] || {}, civCode); data.name.internal = upgrade.entity; data.cost = upgrade.cost; data.icon = upgrade.icon || data.icon; diff --git a/binaries/data/mods/public/gui/reference/common/common.js b/binaries/data/mods/public/gui/reference/common/common.js index c290e84dbc..9fd0dc30fc 100644 --- a/binaries/data/mods/public/gui/reference/common/common.js +++ b/binaries/data/mods/public/gui/reference/common/common.js @@ -15,7 +15,8 @@ var g_Page; function GetTemplateData(templateName) { const template = g_Page.TemplateLoader.loadEntityTemplate(templateName, g_Page.activeCiv); - return g_TemplateHelper.computeDataFromModifiers(template, g_Page.TemplateLoader.auraData, g_ResourceData, g_Page.TemplateParser.getModifiers(g_Page.activeCiv)); + return g_TemplateHelper.computeDataFromModifiers(template, g_Page.TemplateLoader.auraData, g_ResourceData, + g_Page.TemplateParser.getModifiers(g_Page.activeCiv), g_Page.activeCiv); } /** diff --git a/binaries/data/mods/public/gui/reference/structree/Boxes/ProductionRowManager.js b/binaries/data/mods/public/gui/reference/structree/Boxes/ProductionRowManager.js index c1f9fc6ef1..28f8385229 100644 --- a/binaries/data/mods/public/gui/reference/structree/Boxes/ProductionRowManager.js +++ b/binaries/data/mods/public/gui/reference/structree/Boxes/ProductionRowManager.js @@ -50,8 +50,8 @@ class ProductionRowManager this.productionRows[rowIdx].drawIcon(prod, civCode); } - if (template.upgrades) - for (const upgrade of template.upgrades) + if (template.upgrade) + for (const upgrade of template.upgrade.options) { let pIdx = 0; if (this.sortProductionsByPhase) diff --git a/binaries/data/mods/public/gui/reference/structree/Sections/Trainer/TrainerSection.js b/binaries/data/mods/public/gui/reference/structree/Sections/Trainer/TrainerSection.js index 5cff40c2da..4e85921665 100644 --- a/binaries/data/mods/public/gui/reference/structree/Sections/Trainer/TrainerSection.js +++ b/binaries/data/mods/public/gui/reference/structree/Sections/Trainer/TrainerSection.js @@ -31,7 +31,7 @@ class TrainerSection for (const unitCode of units.keys()) { const unitTemplate = this.page.TemplateParser.getEntity(unitCode, civCode); - if (!unitTemplate.production.units.length && !unitTemplate.production.techs.length && !unitTemplate.upgrades) + if (!unitTemplate.production.units.length && !unitTemplate.production.techs.length && !unitTemplate.upgrade?.options) continue; if (count > this.trainerBoxes.length) diff --git a/binaries/data/mods/public/gui/reference/viewer/ViewerPage.js b/binaries/data/mods/public/gui/reference/viewer/ViewerPage.js index 331781bd93..210eed88d9 100644 --- a/binaries/data/mods/public/gui/reference/viewer/ViewerPage.js +++ b/binaries/data/mods/public/gui/reference/viewer/ViewerPage.js @@ -105,8 +105,8 @@ class ViewerPage extends ReferencePage } } - if (this.currentTemplate.upgrades) - this.currentTemplate.upgradeListOfNames = this.currentTemplate.upgrades.map(upgrade => + if (this.currentTemplate.upgrade) + this.currentTemplate.upgradeListOfNames = this.currentTemplate.upgrade.options.map(upgrade => getEntityNames(upgrade.name ? upgrade : this.TemplateParser.getEntity(upgrade.entity, this.activeCiv)) ); diff --git a/binaries/data/mods/public/gui/session/PanelEntity.js b/binaries/data/mods/public/gui/session/PanelEntity.js index a76770b4bd..f290bff402 100644 --- a/binaries/data/mods/public/gui/session/PanelEntity.js +++ b/binaries/data/mods/public/gui/session/PanelEntity.js @@ -30,7 +30,7 @@ class PanelEntity this.panelEntButton.hidden = false; const entityState = GetEntityState(entityID); - const template = GetTemplateData(entityState.template); + const template = GetTemplateData(entityState.templateName); this.nameTooltip = setStringTags(g_SpecificNamesPrimary ? template.name.specific : template.name.generic, this.NameTags) + "\n"; Engine.GetGUIObjectByName("panelEntityHealthSection[" + buttonID + "]").hidden = !entityState.hitpoints; diff --git a/binaries/data/mods/public/gui/session/PanelEntityManager.js b/binaries/data/mods/public/gui/session/PanelEntityManager.js index 0b2baedf24..9a8e1ddbbe 100644 --- a/binaries/data/mods/public/gui/session/PanelEntityManager.js +++ b/binaries/data/mods/public/gui/session/PanelEntityManager.js @@ -56,7 +56,7 @@ class PanelEntityManager const entityState = GetEntityState(entityID); const orderKey = this.entityOrder.findIndex(entClass => - entityState.identity.classes.indexOf(entClass) != -1); + entityState.identityClasses.indexOf(entClass) != -1); // Sort depending on given order const insertPos = this.handlers.reduce( diff --git a/binaries/data/mods/public/gui/session/developer_overlay/DeveloperOverlayEntityState.js b/binaries/data/mods/public/gui/session/developer_overlay/DeveloperOverlayEntityState.js index b298884ec0..14162f6211 100644 --- a/binaries/data/mods/public/gui/session/developer_overlay/DeveloperOverlayEntityState.js +++ b/binaries/data/mods/public/gui/session/developer_overlay/DeveloperOverlayEntityState.js @@ -38,7 +38,7 @@ class DeveloperOverlayEntityState const entState = GetEntityState(selection[0]); if (entState) { - const template = GetTemplateData(entState.template, entState.player); + const template = GetTemplateData(entState.templateName, entState.player); text += "\n\nentity: {\n"; for (const k in entState) text += " " + k + ":" + uneval(entState[k]) + "\n"; diff --git a/binaries/data/mods/public/gui/session/input.js b/binaries/data/mods/public/gui/session/input.js index 6dbca62723..a1e785d62b 100644 --- a/binaries/data/mods/public/gui/session/input.js +++ b/binaries/data/mods/public/gui/session/input.js @@ -1157,18 +1157,18 @@ function handleInputAfterGui(ev) if (ev.clicks == 2) { - templateToMatch = GetEntityState(clickedEntity).identity.selectionGroupName; + templateToMatch = GetEntityState(clickedEntity).selectionGroupName; if (templateToMatch) matchRank = false; else // No selection group name defined, so fall back to exact match. - templateToMatch = GetEntityState(clickedEntity).template; + templateToMatch = GetEntityState(clickedEntity).templateName; } else // Triple click // Select units matching exact template name (same rank). - templateToMatch = GetEntityState(clickedEntity).template; + templateToMatch = GetEntityState(clickedEntity).templateName; // TODO: Should we handle "control all units" here as well? ents = Engine.PickSimilarPlayerEntities(templateToMatch, showOffscreen, matchRank, false); @@ -1990,7 +1990,7 @@ function findIdleUnit(classes) Engine.CameraMoveTo(entityState.position.x, entityState.position.z); // Move the idle class index to the first class an idle unit was found for. - const indexChange = data.idleClasses.findIndex(elem => MatchesClassList(entityState.identity.classes, elem)); + const indexChange = data.idleClasses.findIndex(elem => MatchesClassList(entityState.identityClasses, elem)); currIdleClassIndex = (currIdleClassIndex + indexChange) % classes.length; } diff --git a/binaries/data/mods/public/gui/session/selection.js b/binaries/data/mods/public/gui/session/selection.js index 7a03c670cd..9f1a976e65 100644 --- a/binaries/data/mods/public/gui/session/selection.js +++ b/binaries/data/mods/public/gui/session/selection.js @@ -82,7 +82,7 @@ EntityGroups.prototype.add = function(ents) if (!entState) continue; - var templateName = entState.template; + var templateName = entState.templateName; var key = GetTemplateData(templateName, entState.player).selectionGroupName || templateName; // Group the ents by player and template @@ -235,7 +235,7 @@ EntitySelection.prototype.getTemplateNames = function() { const entState = GetEntityState(ent); if (entState) - templateNames.push(entState.template); + templateNames.push(entState.templateName); } return templateNames; }; diff --git a/binaries/data/mods/public/gui/session/selection_details.js b/binaries/data/mods/public/gui/session/selection_details.js index 156ccc559b..f5faa5fab1 100644 --- a/binaries/data/mods/public/gui/session/selection_details.js +++ b/binaries/data/mods/public/gui/session/selection_details.js @@ -48,7 +48,7 @@ function updateGarrisonHealthBar(entState, selection) // Fills out information that most entities have function displaySingle(entState) { - const template = GetTemplateData(entState.template); + const template = GetTemplateData(entState.templateName); const primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic; let secondaryName; @@ -75,13 +75,13 @@ function displaySingle(entState) playerName = sprintf(translate("\\[OFFLINE] %(player)s"), { "player": playerName }); // Rank - if (entState.identity && entState.identity.rank && entState.identity.classes) + if (entState.rank && entState.identityClasses) { - const rankObj = GetTechnologyData(entState.identity.rankTechName, playerState.civ); + const rankTooltip = (entState.rank && GetTechnologyData("unit_" + entState.rank.toLowerCase())?.tooltip) || ""; Engine.GetGUIObjectByName("rankIcon").tooltip = sprintf(translate("%(rank)s Rank"), { - "rank": translateWithContext("Rank", entState.identity.rank) - }) + (rankObj ? "\n" + rankObj.tooltip : ""); - Engine.GetGUIObjectByName("rankIcon").sprite = "stretched:session/icons/ranks/" + entState.identity.rank + ".png"; + "rank": translateWithContext("Rank", entState.rank) + }) + rankTooltip; + Engine.GetGUIObjectByName("rankIcon").sprite = "stretched:session/icons/ranks/" + entState.rank + ".png"; Engine.GetGUIObjectByName("rankIcon").hidden = false; } else @@ -339,7 +339,7 @@ function displaySingle(entState) iconBorder.onPressRight = () => { - showTemplateDetails(entState.template, playerState.civ); + showTemplateDetails(entState.templateName, playerState.civ); }; } diff --git a/binaries/data/mods/public/gui/session/selection_panels.js b/binaries/data/mods/public/gui/session/selection_panels.js index a157f5a261..23a3551397 100644 --- a/binaries/data/mods/public/gui/session/selection_panels.js +++ b/binaries/data/mods/public/gui/session/selection_panels.js @@ -103,7 +103,7 @@ g_SelectionPanels.Barter = { "getItems": function(unitEntStates) { // If more than `rowLength` resources, don't display icons. - if (unitEntStates.every(state => !state.isBarterMarket) || g_ResourceData.GetBarterableCodes().length > this.rowLength) + if (unitEntStates.every(state => !state.enablesBartering) || g_ResourceData.GetBarterableCodes().length > this.rowLength) return []; return g_ResourceData.GetBarterableCodes(); }, @@ -344,13 +344,13 @@ g_SelectionPanels.Garrison = { { const entState = GetEntityState(data.item.ents[0]); - const template = GetTemplateData(entState.template, entState.player); + const template = GetTemplateData(entState.templateName, entState.player); if (!template) return false; data.button.onPress = function() { - unloadTemplate(template.selectionGroupName || entState.template, entState.player); + unloadTemplate(template.selectionGroupName || entState.templateName, entState.player); }; data.countDisplay.caption = data.item.ents.length > 1 ? data.item.ents.length : ""; @@ -1048,7 +1048,7 @@ g_SelectionPanels.Research = { { showTemplateDetails( t, - GetTemplateData(baseData.unitEntStates.find(state => state.id == baseData.item.researchFacilityId).template, state.player).nativeCiv + GetTemplateData(baseData.unitEntStates.find(state => state.id == baseData.item.researchFacilityId).templateName, state.player).nativeCiv ); }); @@ -1114,7 +1114,7 @@ g_SelectionPanels.Selection = { "setupButton": function(data) { const entState = GetEntityState(data.item.ents[0]); - const template = GetTemplateData(entState.template, entState.player); + const template = GetTemplateData(entState.templateName, entState.player); if (!template) return false; @@ -1334,10 +1334,10 @@ g_SelectionPanels.Upgrade = { "getItems": function(unitEntStates) { // Interface becomes complicated with multiple different units and this is meant per-entity, so prevent it if the selection has multiple different units. - if (unitEntStates.some(state => state.template != unitEntStates[0].template)) + if (unitEntStates.some(state => state.templateName != unitEntStates[0].templateName)) return false; - return unitEntStates[0].upgrade && unitEntStates[0].upgrade.upgrades; + return unitEntStates[0].upgrade?.options; }, "setupButton": function(data) { @@ -1355,7 +1355,7 @@ g_SelectionPanels.Upgrade = { }); const limits = getEntityLimitAndCount(data.playerState, data.item.entity); - const upgradingEntStates = data.unitEntStates.filter(state => state.upgrade.template == data.item.entity); + const upgradingEntStates = data.unitEntStates.filter(state => state.upgrade.templateName == data.item.entity); const upgradableEntStates = data.unitEntStates.filter(state => !state.upgrade.progress && @@ -1422,7 +1422,7 @@ g_SelectionPanels.Upgrade = { }; if (!requirementsMet || limits.canBeAddedCount == 0 && - !upgradableEntStates.some(state => hasSameRestrictionCategory(data.item.entity, state.template, state.player))) + !upgradableEntStates.some(state => hasSameRestrictionCategory(data.item.entity, state.templateName, state.player))) { data.button.enabled = false; modifier = "color:0 0 0 127:grayscale:"; diff --git a/binaries/data/mods/public/gui/session/session.js b/binaries/data/mods/public/gui/session/session.js index 262020ca56..c92624ae60 100644 --- a/binaries/data/mods/public/gui/session/session.js +++ b/binaries/data/mods/public/gui/session/session.js @@ -126,8 +126,8 @@ var g_ShowAllStatusBars = false; * Cache of simulation state and template data (apart from TechnologyData, updated on every simulation update). */ var g_SimState; -var g_EntityStates = {}; -var g_TemplateData = {}; +var g_EntityStates = new Map(); +var g_TemplateData = new Map(); var g_TechnologyData = {}; var g_ResourceData = new Resources(); @@ -189,59 +189,149 @@ function GetSimState() return g_SimState; } -function GetMultipleEntityStates(ents) +/** + * Piece together the entire entity state, either from g_TemplateData or the data provided (one of which + * should always be possible) and then cache it in g_EntityStates. + * Intended for internal use only. + */ +function _processNewEntityState(data, entId) { - if (!ents.length) + if (!data?.dynamicState) + { + g_EntityStates.set(entId, null); return null; - const entityStates = Engine.GuiInterfaceCall("GetMultipleEntityStates", ents); - for (const item of entityStates) - g_EntityStates[item.entId] = item.state && deepfreeze(item.state); - return entityStates; + } + + // Certain parts (the template data) of an entity state are "predictable" and can therefore be reused between + // entities with the same template. What one has to account for, however, is that values of the template data + // can be modified. This happens on two different levels: + // - Firstly, player-global modifications, which apply to all entities owned by that player. This includes stuff + // like bonuses from researched techs, civs bonuses, team bonuses. + // - Secondly, entity-local modifications, which apply to individual entities. This includes buffs or debuffs + // from status effects or auras (of other entities). + // + // To take advantage of this, the whole entity state consists of three parts that we need to combine here: + // 1. The template data of the entity's template, which only has the player-global modifications applied. It is + // the same for all entities with the same template and owner, so it cached and reused when possible. + // 2. The modified template data of the entity, which can contain a subset of the template data's values, + // overwriting them. It has both the player-global and entity-local modifications applied, but only contains the + // values that actually differ from its template data. It is therefore the same for all entities with the same + // owner, template, and modifications ID, so it cached and reused when possible. + // 3. the dynamic state, which differs from entity to entity and therefore isn't cached. + + const state = data.dynamicState; + + // This is the only two-level-deep value of the dynamic state supposed to overwrite the corresponding value in the + // (modified) template data, so we have to treat it explicitly here, which is quite ugly. + const elevationAdaptedRange = state.attack?.Ranged?.elevationAdaptedRange; + + const playerCache = g_TemplateData.get(state.player); + let templateData = data.templateData; + if (templateData) + { + translateObjectKeys(templateData, ["specific", "generic", "tooltip"]); + playerCache.set(state.templateName, deepfreeze(templateData)); + } + else + templateData = playerCache.get(state.templateName); + + let modifiedTemplateData; + if (data.modificationsID) + { + const cacheKey = `${state.templateName}: ${data.modificationsID}`; + modifiedTemplateData = data.modifiedTemplateData; + if (modifiedTemplateData) + playerCache.set(cacheKey, deepfreeze(modifiedTemplateData)); + else + modifiedTemplateData = playerCache.get(cacheKey); + } + + for (const key in templateData) + { + const templateVal = modifiedTemplateData?.[key] || templateData[key]; + const stateVal = state[key]; + // Individual entries of dynamic state don't overlap with the ones in the (modified) template data, but they are + // organised into child objects. So we are only combining the two here, not actually overwriting anything. + // (elevationAdaptedRange is the only exception) + if (typeof stateVal == "object") + Object.assign(stateVal, templateVal); + else + state[key] = templateVal; + } + + if (elevationAdaptedRange) + state.attack.Ranged.elevationAdaptedRange = elevationAdaptedRange; + + g_EntityStates.set(entId, deepfreeze(state)); + return state; +} + +/** + * Pull states of multiple entities from the simulation and cache them. + * This is faster than doing it for each individually. + */ +function PreloadMultipleEntityStates(ents) +{ + const newEnts = ents.filter(ent => !g_EntityStates.has(ent)); + if (!newEnts.length) + return; + + const items = Engine.GuiInterfaceCall("GetMultipleEntityStates", newEnts); + for (let i = 0; i < items.length; i++) + _processNewEntityState(items[i], newEnts[i]); } /** * Get the current state of a given entity. The data is pulled from the simulation. + * + * An entity state is like a subtype of template data (returned by GetTemplateData), it contains + * all of the values present in the latter, plus some more. Substitutability is given, it can be + * used everywhere where a template is expected. + * However, even the values also in present template data could be modified entity-locally, by + * stuff like auras and status effects. For example, two spearman of the same player can have + * different attack damage with both different from the value in the spearman template data. + * To obtain the values that are consistent player-wide (which a new entity with that template of + * that player would have) call GetTemplateData(GetEntityState(entId).templateName) + * * The state is null, if the ID is undefined, invalid or no entity with the ID exists (anymore). + * + * Note about performance: Calling this the first time for an entity in a simulation turn is rather + * expensive, but every time after that extremely cheap, since the states are cached. */ function GetEntityState(entId) { if (!entId || entId == INVALID_ENTITY) return null; - if (!g_EntityStates[entId]) - { - const entityState = Engine.GuiInterfaceCall("GetEntityState", entId); - g_EntityStates[entId] = entityState && deepfreeze(entityState); - } + let state = g_EntityStates.get(entId); + // The cached state can very well be null, we only need to retrieve it from the simulation if it's undefined + // (i.e not yet present in the cache at all) + if (state === undefined) + state = _processNewEntityState(Engine.GuiInterfaceCall("GetEntityState", entId), entId); - return g_EntityStates[entId]; + return state; } /** - * Returns template data calling GetTemplateData defined in GuiInterface.js - * and deepfreezing returned object. - * @param {string} templateName - Data of this template will be returned. - * @param {number|undefined} player - Modifications of this player will be applied to the template. - * If undefined, id of player calling this method will be used. + * Returns template data of a given template accounting for player-wide modifications such + * as researched techs or team bonuses. + * @param {string} templateName - Name of the template. + * @param {number} [player] - The player whose modifications to apply to the data. + * If undefined, the currently viewed player is be used. */ function GetTemplateData(templateName, player) { - const targetPlayer = player || g_ViewedPlayer; - let cache = g_TemplateData[targetPlayer]; - if (!cache) - { - cache = {}; - g_TemplateData[targetPlayer] = cache; - } + const targetPlayer = player || (g_ViewedPlayer >= 0 ? g_ViewedPlayer : 0); + const playerCache = g_TemplateData.get(targetPlayer); - let templateData = cache[templateName]; + let templateData = playerCache.get(templateName); if (!templateData) { templateData = Engine.GuiInterfaceCall("GetTemplateData", { "templateName": templateName, "player": targetPlayer }); translateObjectKeys(templateData, ["specific", "generic", "tooltip"]); - deepfreeze(templateData); - cache[templateName] = templateData; + playerCache.set(templateName, deepfreeze(templateData)); } + return templateData; } @@ -287,6 +377,9 @@ async function init(initData, hotloadData) restoreSavedGameData(initData.savedGUIData); } + // Necessary when hotloading in order to repopulate g_TemplateData + Engine.GuiInterfaceCall("ResetEntityStateRetriever"); + if (g_InitAttributes.campaignData) g_CampaignSession = new CampaignSession(g_InitAttributes.campaignData); @@ -461,6 +554,10 @@ function updatePlayerData() "guid": undefined, // network guid for players controlled by hosts "offline": g_Players[i] && !!g_Players[i].offline }); + + // This is done here in order to theoretically support the number of players changing. + if (i >= g_TemplateData.size) + g_TemplateData.set(i, new Map()); } for (const guid in g_PlayerAssignments) @@ -641,7 +738,7 @@ function onTick() { g_Selection.dirty = false; // When selection changed, get the entityStates of new entities - GetMultipleEntityStates(g_Selection.filter(entId => !g_EntityStates[entId])); + PreloadMultipleEntityStates(g_Selection.toList()); for (const handler of g_EntitySelectionChangeHandlers) handler(); @@ -660,7 +757,7 @@ function onTick() function onSimulationUpdate() { - g_EntityStates = {}; + g_EntityStates.clear(); g_SimState = undefined; // Some changes may require re-rendering the selection. @@ -673,7 +770,7 @@ function onSimulationUpdate() if (!GetSimState()) return; - GetMultipleEntityStates(g_Selection.toList()); + PreloadMultipleEntityStates(g_Selection.toList()); for (const handler of g_SimulationUpdateHandlers) handler(); @@ -722,7 +819,7 @@ function updateGroups() const getCostSum = (ent) => { const entState = GetEntityState(ent); - const cost = GetTemplateData(entState.template, entState.player).cost; + const cost = GetTemplateData(entState.templateName, entState.player).cost; return cost ? Object.keys(cost).map(key => cost[key]).reduce((sum, cur) => sum + cur) : 0; }; @@ -744,7 +841,7 @@ function updateGroups() if (pre.ents.length == cur.ents.length) return getCostSum(pre.ents[0]) > getCostSum(cur.ents[0]) ? pre : cur; return pre.ents.length > cur.ents.length ? pre : cur; - }).ents[0]).template).icon; + }).ents[0]).templateName).icon; Engine.GetGUIObjectByName("unitGroupIcon[" + i + "]").sprite = icon ? ("stretched:session/portraits/" + icon) : "groupsIcon"; diff --git a/binaries/data/mods/public/gui/session/unit_actions.js b/binaries/data/mods/public/gui/session/unit_actions.js index 1962100915..043f078aff 100644 --- a/binaries/data/mods/public/gui/session/unit_actions.js +++ b/binaries/data/mods/public/gui/session/unit_actions.js @@ -386,11 +386,11 @@ var g_UnitActions = return false; const unhealableClasses = entState.heal.unhealableClasses; - if (MatchesClassList(targetState.identity.classes, unhealableClasses)) + if (MatchesClassList(targetState.identityClasses, unhealableClasses)) return false; const healableClasses = entState.heal.healableClasses; - if (!MatchesClassList(targetState.identity.classes, healableClasses)) + if (!MatchesClassList(targetState.identityClasses, healableClasses)) return false; return { "possible": true }; @@ -776,7 +776,7 @@ var g_UnitActions = return false; if (!targetState.turretHolder.turretPoints.find(point => - !point.allowedClasses || MatchesClassList(entState.identity.classes, point.allowedClasses))) + !point.allowedClasses || MatchesClassList(entState.identityClasses, point.allowedClasses))) return false; const occupiedTurrets = targetState.turretHolder.turretPoints.filter(point => point.entity != null); @@ -858,7 +858,7 @@ var g_UnitActions = if (targetState.garrisonHolder.occupiedSlots + extraCount > targetState.garrisonHolder.capacity) tooltip = coloredText(tooltip, "orange"); - if (!MatchesClassList(entState.identity.classes, targetState.garrisonHolder.allowedClasses)) + if (!MatchesClassList(entState.identityClasses, targetState.garrisonHolder.allowedClasses)) return false; return { @@ -1224,7 +1224,7 @@ var g_UnitActions = data.command = "gather-near-position"; data.resourceType = resourceType; - data.resourceTemplate = targetState.template; + data.resourceTemplate = targetState.templateName; if (!targetState.speed) { data.command = "gather"; @@ -1397,12 +1397,7 @@ var g_UnitActions = "actionCheck": function(target, selection) { // Only show this action if all entities are marked uncontrollable. - const playerState = g_SimState.players[g_ViewedPlayer]; - if (playerState && playerState.controlsAll || selection.some(ent => - { - const entState = GetEntityState(ent); - return entState && entState.identity && entState.identity.controllable; - })) + if (g_SimState.players[g_ViewedPlayer]?.controlsAll || selection.some(ent => GetEntityState(ent)?.controllable)) return false; return { @@ -1585,7 +1580,7 @@ var g_EntityCommands = "getInfo": function(entStates) { const classes = ["Soldier", "Warship", "Siege", "Healer"]; - if (entStates.every(entState => !MatchesClassList(entState.identity.classes, classes))) + if (entStates.every(entState => !MatchesClassList(entState.identityClasses, classes))) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.calltoarms") + @@ -1997,7 +1992,7 @@ function allowedPlayersCheck(entStates, validPlayers) function hasClass(entState, className) { // note: use the functions in globalscripts/Templates.js for more versatile matching - return entState.identity && entState.identity.classes.indexOf(className) != -1; + return entState?.identityClasses?.indexOf(className) != -1; } /** @@ -2015,7 +2010,7 @@ function isUndeletable(entState) if (entState.capturePoints && entState.capturePoints[entState.player] < entState.maxCapturePoints / 2) return translate("You cannot destroy this entity as you own less than half the capture points"); - if (!entState.identity.canDelete) + if (entState.undeletable) return translate("This entity is undeletable"); return false; @@ -2076,7 +2071,7 @@ function getActionInfo(action, target, selection) if (!entState) continue; - if (playerState && !playerState.controlsAll && !entState.identity.controllable) + if (playerState && !playerState.controlsAll && !entState.controllable) continue; if (g_UnitActions[action] && g_UnitActions[action].getActionInfo) diff --git a/binaries/data/mods/public/gui/session/unit_commands.js b/binaries/data/mods/public/gui/session/unit_commands.js index 815b6ac54d..7249f81456 100644 --- a/binaries/data/mods/public/gui/session/unit_commands.js +++ b/binaries/data/mods/public/gui/session/unit_commands.js @@ -136,8 +136,7 @@ function updateUnitCommands(entStates, supplementalDetailsPanel, commandsPanel) const playerState = playerStates[Engine.GetPlayerID()]; if (g_IsObserver || entStates.every(entState => - controlsPlayer(entState.player) && - (!entState.identity || entState.identity.controllable)) || + controlsPlayer(entState.player) && entState.controllable) || playerState.controlsAll) { for (const guiName of g_PanelsOrder) diff --git a/binaries/data/mods/public/maps/random/rmgen-common/wall_builder.js b/binaries/data/mods/public/maps/random/rmgen-common/wall_builder.js index 7d0c76b626..2f08c79246 100644 --- a/binaries/data/mods/public/maps/random/rmgen-common/wall_builder.js +++ b/binaries/data/mods/public/maps/random/rmgen-common/wall_builder.js @@ -48,7 +48,7 @@ function loadWallsetsFromCivData() function loadWallset(wallsetPath, civ) { const newWallset = { "curves": [] }; - const wallsetData = g_TemplateHelper.getBasicData(wallsetPath, {}, g_Resources, ["WallSet"]).wallSet; + const wallsetData = g_TemplateHelper.getBasicData(wallsetPath, {}, g_Resources, civ, ["WallSet"]).wallSet; for (const element in wallsetData.templates) if (element == "curves") @@ -271,7 +271,7 @@ function getWallElement(element, style) function readyWallElement(path, civCode) { path = path.replace(/\{civ\}/g, civCode); - const template = g_TemplateHelper.getBasicData(Engine.GetTemplate(path), null, g_Resources, ["WallPiece", "Obstruction"]); + const template = g_TemplateHelper.getBasicData(Engine.GetTemplate(path), null, g_Resources, civCode, ["WallPiece", "Obstruction"]); const length = template.wallPiece ? template.wallPiece.length : template.obstruction.shape.width; return deepfreeze({ diff --git a/binaries/data/mods/public/simulation/components/GuiInterface.js b/binaries/data/mods/public/simulation/components/GuiInterface.js index f7e8509785..340e983cbc 100644 --- a/binaries/data/mods/public/simulation/components/GuiInterface.js +++ b/binaries/data/mods/public/simulation/components/GuiInterface.js @@ -36,6 +36,7 @@ GuiInterface.prototype.Init = function() this.entsRallyPointsDisplayed = []; this.entsWithAuraAndStatusBars = new Set(); this.enabledVisualRangeOverlayTypes = {}; + this.entityStateRetriever = new EntityStateRetriever(); this.selectionDirty = {}; this.obstructionSnap = new ObstructionSnap(); }; @@ -249,377 +250,35 @@ GuiInterface.prototype.AddMiragedEntity = function(player, entity, mirage) this.miragedEntities[player].push({ "entity": entity, "newentity": mirage }); }; +GuiInterface.prototype.ResetEntityStateRetriever = function(player) +{ + this.entityStateRetriever.reset(); +}; + /** - * Get common entity info, often used in the gui. + * See helpers/EntityStateRetriever.js for details. + * Mustn't be invoked outside of GUI script calls, in order to guarantee completeness of the data returned in future + * calls. + * Everywhere else, read the data from the template instead, or use g_TemplateHelper.computeDataFromPlayer. + */ +GuiInterface.prototype.GetTemplateData = function(player, data) +{ + return this.entityStateRetriever.getTemplateData(data.player || player, data.templateName); +}; + +/** + * See helpers/EntityStateRetriever.js for details. + * Mustn't be invoked outside of GUI script calls, in order to guarantee completeness of the data returned in future + * calls. + * Everywhere else, read information directly from the components instead. */ GuiInterface.prototype.GetEntityState = function(player, ent) { - if (!ent) - return null; - - // All units must have a template; if not then it's a nonexistent entity id. - const template = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager).GetCurrentTemplateName(ent); - if (!template) - return null; - - const ret = { - "id": ent, - "player": INVALID_PLAYER, - "template": template - }; - - const cmpAuras = Engine.QueryInterface(ent, IID_Auras); - if (cmpAuras) - ret.auras = cmpAuras.GetDescriptions(); - - const cmpMirage = Engine.QueryInterface(ent, IID_Mirage); - if (cmpMirage) - ret.mirage = true; - - const cmpIdentity = Engine.QueryInterface(ent, IID_Identity); - if (cmpIdentity) - ret.identity = { - "rank": cmpIdentity.GetRank(), - "rankTechName": cmpIdentity.GetRankTechName(), - "classes": cmpIdentity.GetClassesList(), - "selectionGroupName": cmpIdentity.GetSelectionGroupName(), - "canDelete": !cmpIdentity.IsUndeletable(), - "controllable": cmpIdentity.IsControllable() - }; - - const cmpFormation = Engine.QueryInterface(ent, IID_Formation); - if (cmpFormation) - ret.formation = { - "members": cmpFormation.GetMembers() - }; - - const cmpPosition = Engine.QueryInterface(ent, IID_Position); - if (cmpPosition && cmpPosition.IsInWorld()) - ret.position = cmpPosition.GetPosition(); - - const cmpHealth = QueryMiragedInterface(ent, IID_Health); - if (cmpHealth) - { - ret.hitpoints = cmpHealth.GetHitpoints(); - ret.maxHitpoints = cmpHealth.GetMaxHitpoints(); - ret.needsRepair = cmpHealth.IsRepairable() && cmpHealth.IsInjured(); - ret.needsHeal = !cmpHealth.IsUnhealable(); - } - - const cmpCapturable = QueryMiragedInterface(ent, IID_Capturable); - if (cmpCapturable) - { - ret.capturePoints = cmpCapturable.GetCapturePoints(); - ret.maxCapturePoints = cmpCapturable.GetMaxCapturePoints(); - } - - const cmpBuilder = Engine.QueryInterface(ent, IID_Builder); - if (cmpBuilder) - ret.builder = true; - - const cmpMarket = QueryMiragedInterface(ent, IID_Market); - if (cmpMarket) - ret.market = { - "land": cmpMarket.HasType("land"), - "naval": cmpMarket.HasType("naval") - }; - - const cmpPack = Engine.QueryInterface(ent, IID_Pack); - if (cmpPack) - ret.pack = { - "packed": cmpPack.IsPacked(), - "progress": cmpPack.GetProgress() - }; - - const cmpPopulation = Engine.QueryInterface(ent, IID_Population); - if (cmpPopulation) - ret.population = { - "bonus": cmpPopulation.GetPopBonus() - }; - - const cmpUpgrade = Engine.QueryInterface(ent, IID_Upgrade); - if (cmpUpgrade) - ret.upgrade = { - "upgrades": cmpUpgrade.GetUpgrades(), - "progress": cmpUpgrade.GetProgress(), - "template": cmpUpgrade.GetUpgradingTo(), - "isUpgrading": cmpUpgrade.IsUpgrading() - }; - - const cmpResearcher = Engine.QueryInterface(ent, IID_Researcher); - if (cmpResearcher) - ret.researcher = { - "technologies": cmpResearcher.GetTechnologiesList(), - "techCostMultiplier": cmpResearcher.GetTechCostMultiplier() - }; - - const cmpStatusEffects = Engine.QueryInterface(ent, IID_StatusEffectsReceiver); - if (cmpStatusEffects) - ret.statusEffects = cmpStatusEffects.GetActiveStatuses(); - - const cmpProductionQueue = Engine.QueryInterface(ent, IID_ProductionQueue); - if (cmpProductionQueue) - ret.production = { - "queue": cmpProductionQueue.GetQueue(), - "autoqueue": cmpProductionQueue.IsAutoQueueing() - }; - - const cmpTrainer = Engine.QueryInterface(ent, IID_Trainer); - if (cmpTrainer) - ret.trainer = { - "entities": cmpTrainer.GetEntitiesList() - }; - - const cmpTrader = Engine.QueryInterface(ent, IID_Trader); - if (cmpTrader) - ret.trader = { - "goods": cmpTrader.GetGoods() - }; - - const cmpFoundation = QueryMiragedInterface(ent, IID_Foundation); - if (cmpFoundation) - ret.foundation = { - "numBuilders": cmpFoundation.GetNumBuilders(), - "buildTime": cmpFoundation.GetBuildTime() - }; - - const cmpRepairable = QueryMiragedInterface(ent, IID_Repairable); - if (cmpRepairable) - ret.repairable = { - "numBuilders": cmpRepairable.GetNumBuilders(), - "buildTime": cmpRepairable.GetBuildTime() - }; - - const cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); - if (cmpOwnership) - ret.player = cmpOwnership.GetOwner(); - - const cmpRallyPoint = Engine.QueryInterface(ent, IID_RallyPoint); - if (cmpRallyPoint) - ret.rallyPoint = { "position": cmpRallyPoint.GetPositions(player)[0] }; // undefined or {x,z} object - - const cmpGarrisonHolder = Engine.QueryInterface(ent, IID_GarrisonHolder); - if (cmpGarrisonHolder) - ret.garrisonHolder = { - "entities": cmpGarrisonHolder.GetEntities(), - "buffHeal": cmpGarrisonHolder.GetHealRate(), - "allowedClasses": cmpGarrisonHolder.GetAllowedClasses(), - "capacity": cmpGarrisonHolder.GetCapacity(), - "occupiedSlots": cmpGarrisonHolder.OccupiedSlots() - }; - - const cmpTurretHolder = Engine.QueryInterface(ent, IID_TurretHolder); - if (cmpTurretHolder) - ret.turretHolder = { - "turretPoints": cmpTurretHolder.GetTurretPoints() - }; - - const cmpTurretable = Engine.QueryInterface(ent, IID_Turretable); - if (cmpTurretable) - ret.turretable = { - "ejectable": cmpTurretable.IsEjectable(), - "holder": cmpTurretable.HolderID() - }; - - const cmpGarrisonable = Engine.QueryInterface(ent, IID_Garrisonable); - if (cmpGarrisonable) - ret.garrisonable = { - "holder": cmpGarrisonable.HolderID(), - "size": cmpGarrisonable.UnitSize() - }; - - const cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); - if (cmpUnitAI) - ret.unitAI = { - "state": cmpUnitAI.GetCurrentState(), - "orders": cmpUnitAI.GetOrders(), - "hasWorkOrders": cmpUnitAI.HasWorkOrders(), - "canGuard": cmpUnitAI.CanGuard(), - "isGuarding": cmpUnitAI.IsGuardOf(), - "canPatrol": cmpUnitAI.CanPatrol(), - "selectableStances": cmpUnitAI.GetSelectableStances(), - "isIdle": cmpUnitAI.IsIdle(), - "formations": cmpUnitAI.GetFormationsList(), - "formation": cmpUnitAI.GetFormationController() - }; - - const cmpGuard = Engine.QueryInterface(ent, IID_Guard); - if (cmpGuard) - ret.guard = { - "entities": cmpGuard.GetEntities() - }; - - const cmpResourceGatherer = Engine.QueryInterface(ent, IID_ResourceGatherer); - if (cmpResourceGatherer) - { - ret.resourceCarrying = cmpResourceGatherer.GetCarryingStatus(); - ret.resourceGatherRates = cmpResourceGatherer.GetGatherRates(); - } - - const cmpGate = Engine.QueryInterface(ent, IID_Gate); - if (cmpGate) - ret.gate = { - "locked": cmpGate.IsLocked() - }; - - const cmpAlertRaiser = Engine.QueryInterface(ent, IID_AlertRaiser); - if (cmpAlertRaiser) - ret.alertRaiser = { - "classes": cmpAlertRaiser.GetTargetClasses() - }; - - const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); - ret.visibility = cmpRangeManager.GetLosVisibility(ent, player); - - const cmpAttack = Engine.QueryInterface(ent, IID_Attack); - if (cmpAttack) - { - const types = cmpAttack.GetAttackTypes(); - if (types.length) - ret.attack = {}; - - for (const type of types) - { - ret.attack[type] = {}; - - Object.assign(ret.attack[type], cmpAttack.GetAttackEffectsData(type)); - - ret.attack[type].attackName = cmpAttack.GetAttackName(type); - - ret.attack[type].splash = cmpAttack.GetSplashData(type); - if (ret.attack[type].splash) - Object.assign(ret.attack[type].splash, cmpAttack.GetAttackEffectsData(type, true)); - - ret.attack[type].projectileCount = cmpAttack.GetProjectileCount(type); - - const range = cmpAttack.GetRange(type); - ret.attack[type].minRange = range.min; - ret.attack[type].maxRange = range.max; - ret.attack[type].yOrigin = cmpAttack.GetAttackYOrigin(type); - - const timers = cmpAttack.GetTimers(type); - ret.attack[type].prepareTime = timers.prepare; - ret.attack[type].repeatTime = timers.repeat; - - if (type != "Ranged") - { - ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; - continue; - } - - if (cmpPosition && cmpPosition.IsInWorld()) - // For units, take the range in front of it, no spread, so angle = 0, - // else, take the average elevation around it: angle = 2 * pi. - ret.attack[type].elevationAdaptedRange = cmpRangeManager.GetElevationAdaptedRange(cmpPosition.GetPosition(), cmpPosition.GetRotation(), range.max, ret.attack[type].yOrigin, cmpUnitAI ? 0 : 2 * Math.PI); - else - // Not in world, set a default? - ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; - } - } - - const cmpResistance = QueryMiragedInterface(ent, IID_Resistance); - if (cmpResistance) - ret.resistance = cmpResistance.GetResistanceOfForm(cmpFoundation ? "Foundation" : "Entity"); - - const cmpBuildingAI = Engine.QueryInterface(ent, IID_BuildingAI); - if (cmpBuildingAI) - ret.buildingAI = { - "defaultArrowCount": cmpBuildingAI.GetDefaultArrowCount(), - "maxArrowCount": cmpBuildingAI.GetMaxArrowCount(), - "garrisonArrowMultiplier": cmpBuildingAI.GetGarrisonArrowMultiplier(), - "garrisonArrowClasses": cmpBuildingAI.GetGarrisonArrowClasses(), - "arrowCount": cmpBuildingAI.GetArrowCount() - }; - - if (cmpPosition && cmpPosition.GetTurretParent() != INVALID_ENTITY) - ret.turretParent = cmpPosition.GetTurretParent(); - - const cmpResourceSupply = QueryMiragedInterface(ent, IID_ResourceSupply); - if (cmpResourceSupply) - ret.resourceSupply = { - "isInfinite": cmpResourceSupply.IsInfinite(), - "max": cmpResourceSupply.GetMaxAmount(), - "amount": cmpResourceSupply.GetCurrentAmount(), - "type": cmpResourceSupply.GetType(), - "killBeforeGather": cmpResourceSupply.GetKillBeforeGather(), - "maxGatherers": cmpResourceSupply.GetMaxGatherers(), - "numGatherers": cmpResourceSupply.GetNumGatherers() - }; - - const cmpResourceDropsite = Engine.QueryInterface(ent, IID_ResourceDropsite); - if (cmpResourceDropsite) - ret.resourceDropsite = { - "types": cmpResourceDropsite.GetTypes(), - "sharable": cmpResourceDropsite.IsSharable(), - "shared": cmpResourceDropsite.IsShared() - }; - - const cmpPromotion = Engine.QueryInterface(ent, IID_Promotion); - if (cmpPromotion) - ret.promotion = { - "curr": cmpPromotion.GetCurrentXp(), - "req": cmpPromotion.GetRequiredXp() - }; - - if (!cmpFoundation && cmpIdentity && cmpIdentity.HasClass("Barter")) - ret.isBarterMarket = true; - - const cmpHeal = Engine.QueryInterface(ent, IID_Heal); - if (cmpHeal) - ret.heal = { - "health": cmpHeal.GetHealth(), - "range": cmpHeal.GetRange().max, - "interval": cmpHeal.GetInterval(), - "unhealableClasses": cmpHeal.GetUnhealableClasses(), - "healableClasses": cmpHeal.GetHealableClasses() - }; - - const cmpLoot = Engine.QueryInterface(ent, IID_Loot); - if (cmpLoot) - { - ret.loot = cmpLoot.GetResources(); - ret.loot.xp = cmpLoot.GetXp(); - } - - const cmpResourceTrickle = Engine.QueryInterface(ent, IID_ResourceTrickle); - if (cmpResourceTrickle) - ret.resourceTrickle = { - "interval": cmpResourceTrickle.GetInterval(), - "rates": cmpResourceTrickle.GetRates() - }; - - const cmpTreasure = Engine.QueryInterface(ent, IID_Treasure); - if (cmpTreasure) - ret.treasure = { - "collectTime": cmpTreasure.CollectionTime(), - "resources": cmpTreasure.Resources() - }; - - const cmpTreasureCollector = Engine.QueryInterface(ent, IID_TreasureCollector); - if (cmpTreasureCollector) - ret.treasureCollector = true; - - const cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion); - if (cmpUnitMotion) - ret.speed = { - "walk": cmpUnitMotion.GetWalkSpeed(), - "run": cmpUnitMotion.GetWalkSpeed() * cmpUnitMotion.GetRunMultiplier(), - "acceleration": cmpUnitMotion.GetAcceleration() - }; - - const cmpUpkeep = Engine.QueryInterface(ent, IID_Upkeep); - if (cmpUpkeep) - ret.upkeep = { - "interval": cmpUpkeep.GetInterval(), - "rates": cmpUpkeep.GetRates() - }; - - return ret; + return this.entityStateRetriever.getEntityState(player, ent); }; - GuiInterface.prototype.GetMultipleEntityStates = function(player, ents) { - return ents.map(ent => ({ "entId": ent, "state": this.GetEntityState(player, ent) })); + return ents.map(ent => this.entityStateRetriever.getEntityState(player, ent)); }; GuiInterface.prototype.GetAverageRangeForBuildings = function(player, cmd) @@ -640,12 +299,6 @@ GuiInterface.prototype.GetAverageRangeForBuildings = function(player, cmd) return cmpRangeManager.GetElevationAdaptedRange(pos, rot, range, yOrigin, 2 * Math.PI); }; -GuiInterface.prototype.GetTemplateData = function(player, data) -{ - const template = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager)?.GetTemplate(data.templateName); - return template && g_TemplateHelper.computeDataFromPlayer(template, AuraTemplates.GetAll(), Resources, data.player || player); -}; - GuiInterface.prototype.AreRequirementsMet = function(player, data) { return !data.requirements || RequirementsHelper.AreRequirementsMet(data.requirements, @@ -707,15 +360,18 @@ GuiInterface.prototype.GetNeededResources = function(player, data) }; /** - * State of the templateData (player dependent): true when some template values have been modified - * and need to be reloaded by the gui. + * Some player-specific modifications have changed, so the GUI will have to reload all of player's templates. */ GuiInterface.prototype.OnTemplateModification = function(msg) { + // TODO: Don't reset everything, only the templates actually affected. + // And inside them, possible only the values affected. + this.PushNotification({ "players": [msg.player], "type": "templates-modified" }); + this.entityStateRetriever.onPlayerModificationsChanged(msg.player); this.selectionDirty[msg.player] = true; }; @@ -1333,7 +989,11 @@ GuiInterface.prototype.SetWallPlacementPreview = function(player, cmd) this.placementWallEntities[tpl] = { "numUsed": 0, "entities": [], - "templateData": this.GetTemplateData(player, { "templateName": tpl }), + "templateData": g_TemplateHelper.computeDataFromPlayer( + Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager)?.GetTemplate(tpl), + AuraTemplates.GetAll(), Resources, player, QueryPlayerIDInterface(player, IID_Identity).GetCiv(), + ["Cost", "WallPiece"] + ), }; if (!this.placementWallEntities[tpl].templateData.wallPiece) @@ -2071,6 +1731,7 @@ GuiInterface.prototype.exposedFunctions = { "GetCampaignGameEndData": 1, "GetRenamedEntities": 1, "ClearRenamedEntities": 1, + "ResetEntityStateRetriever": 1, "GetEntityState": 1, "GetMultipleEntityStates": 1, "GetAverageRangeForBuildings": 1, diff --git a/binaries/data/mods/public/simulation/components/ModifiersManager.js b/binaries/data/mods/public/simulation/components/ModifiersManager.js index fe58c301a7..9fb59bc01b 100644 --- a/binaries/data/mods/public/simulation/components/ModifiersManager.js +++ b/binaries/data/mods/public/simulation/components/ModifiersManager.js @@ -18,6 +18,10 @@ ModifiersManager.prototype.Init = function() // TODO: it might be worth keying by classes here. this.playerEntitiesCached = new Map(); // Keyed by player ID, property name, entity ID. + // Contains the modifier infos for each entity, since piecing them together from scratch it is a bit slow + // and they're very frequently retrieved. + this.cachedInfos = new Map(); + this.modifiersStorage = new MultiKeyMap(); // Keyed by property name, entity. this.modifiersStorage._OnItemModified = (prim, sec, itemID) => this.ModifiersChanged.apply(this, [prim, sec, itemID]); @@ -87,6 +91,7 @@ ModifiersManager.prototype.InvalidatePlayerEntCache = function(valueCache, prope ModifiersManager.prototype.InvalidateCache = function(propertyName, entity, playerCache) { + this.cachedInfos.delete(entity); const valueCache = this.cachedValues.get(propertyName); if (!valueCache) return; @@ -123,6 +128,45 @@ ModifiersManager.prototype.Cache = function(classesList, propertyName, originalV cache2.set(originalValue, newValue); }; +/** + * Get information about an entity's local modifications. + * This includes a list of modified components and a "modifications ID", which is consistent + * and uniquely identifies the entire set of modifiers registered specifically to the entity. + */ +ModifiersManager.prototype.GetModifiersInfo = function(entity) +{ + let info = this.cachedInfos.get(entity); + if (!info) + { + const modifications = this.modifiersStorage.GetAllItems(entity); + const components = new Set(); + let id = ""; + for (const value in modifications) + { + id += `${value}:`; + + const idx = value.indexOf("/"); + const component = idx > 0 ? value.slice(0, idx) : value; + components.add(component); + + // Sort the modifiers to ignore insertion order. + const modifiers = modifications[value].sort((a, b) => + a._ID > b._ID ? 1 : -1 // the IDs can't be the same + ); + for (const modifier of modifiers) + id += `${modifier._ID}(${modifier._count})`; + + id += ' '; + } + info = { + "modifiedComponents": components, + "modificationsID": id + }; + this.cachedInfos.set(entity, info); + } + return info; +}; + /** * Caching system in front of FetchModifiedProperty(), as calling that every time is quite slow. * This recomputes lazily. diff --git a/binaries/data/mods/public/simulation/components/tests/test_EntityStates.js b/binaries/data/mods/public/simulation/components/tests/test_EntityStates.js new file mode 100644 index 0000000000..70a1b259e5 --- /dev/null +++ b/binaries/data/mods/public/simulation/components/tests/test_EntityStates.js @@ -0,0 +1,737 @@ +Engine.LoadHelperScript("EntityStateRetriever.js"); +Engine.LoadHelperScript("ObstructionSnap.js"); +Engine.LoadHelperScript("Player.js"); +Engine.LoadHelperScript("ValueModification.js"); +Engine.LoadComponentScript("interfaces/BuildingAI.js"); +Engine.LoadComponentScript("interfaces/Capturable.js"); +Engine.LoadComponentScript("interfaces/Formation.js"); +Engine.LoadComponentScript("interfaces/Foundation.js"); +Engine.LoadComponentScript("interfaces/Garrisonable.js"); +Engine.LoadComponentScript("interfaces/GarrisonHolder.js"); +Engine.LoadComponentScript("interfaces/Gate.js"); +Engine.LoadComponentScript("interfaces/Guard.js"); +Engine.LoadComponentScript("interfaces/Heal.js"); +Engine.LoadComponentScript("interfaces/Health.js"); +Engine.LoadComponentScript("interfaces/Loot.js"); +Engine.LoadComponentScript("interfaces/Market.js"); +Engine.LoadComponentScript("interfaces/ModifiersManager.js"); +Engine.LoadComponentScript("interfaces/Pack.js"); +Engine.LoadComponentScript("interfaces/Population.js"); +Engine.LoadComponentScript("interfaces/ProductionQueue.js"); +Engine.LoadComponentScript("interfaces/Promotion.js"); +Engine.LoadComponentScript("interfaces/Repairable.js"); +Engine.LoadComponentScript("interfaces/Researcher.js"); +Engine.LoadComponentScript("interfaces/Resistance.js"); +Engine.LoadComponentScript("interfaces/ResourceDropsite.js"); +Engine.LoadComponentScript("interfaces/ResourceGatherer.js"); +Engine.LoadComponentScript("interfaces/ResourceTrickle.js"); +Engine.LoadComponentScript("interfaces/ResourceSupply.js"); +Engine.LoadComponentScript("interfaces/Trader.js"); +Engine.LoadComponentScript("interfaces/Trainer.js"); +Engine.LoadComponentScript("interfaces/TurretHolder.js"); +Engine.LoadComponentScript("interfaces/Turretable.js"); +Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); +Engine.LoadComponentScript("interfaces/UnitAI.js"); +Engine.LoadComponentScript("interfaces/Upgrade.js"); +Engine.LoadComponentScript("interfaces/Upkeep.js"); +Engine.LoadComponentScript("GuiInterface.js"); +Engine.LoadComponentScript("ModifiersManager.js"); +Engine.LoadComponentScript("RallyPoint.js"); + +// This script tests the GetEntityState and GetTemplateData methods of the GuiInterface component and the underlying +// logic of the EntityStateRetriever, ModifiersManager, and globalscripts/Templates.js. +// This data is not actually serialised, but passed to the GUI. However, it is essential that it is complete and +// crucial for performance that each call returns as little data as necessary, that nothing is computed twice. +// See helpers/EntityStateRetriever.js for details. + +const cmpGuiInterface = ConstructComponent(SYSTEM_ENTITY, "GuiInterface"); +const cmpModifiersManager = ConstructComponent(SYSTEM_ENTITY, "ModifiersManager"); + +const templateNames = {}; +const templates = {}; +AddMock(SYSTEM_ENTITY, IID_TemplateManager, { + "GetCurrentTemplateName": function(ent) { return templateNames[ent]; }, + "GetTemplate": function(name) { return templates[name]; } +}); + +templates.example_structure_template = { + "@parent": "parent_structure_emplate", + "AlertRaiser": { + "List": { "@datatype": "tokens", "_string": "UnitClass1 UnitClass2" }, + "RaiseAlertRange": 120, "EndOfAlertRange": 180, "SearchRange": 100 + }, + "Attack": { + "Ranged": { + "AttackName": "Bow", "Damage": { "Pierce": "8" }, + "MaxRange": "60", "PrepareTime": "400", + "PreferredClasses": { "@datatype": "tokens", "_string": "Human" }, + "Projectile": { + "FriendlyFire": "false", "Gravity": "50", + "LaunchPoint": { "@y": "0" }, + "Speed": "100", "Spread": "2" + }, + "RepeatTime": "4000" + } + }, + "Auras": { "@datatype": "tokens", "_string": "aura1" }, + "BuildingAI": { + "DefaultArrowCount": "6", "GarrisonArrowClasses": "UnitClass1", + "GarrisonArrowMultiplier": "1" + }, + "BuildRestrictions": { + "Territory": "own ally", "PlacementType": "shore", + "Category": "Structure1", + "Distance": { "FromClass": "StructureClass0", "MinDistance": 100 } + }, + "Capturable": { + "CapturePoints": "2500", "GarrisonRegenRate": "1.0", "RegenRate": "30" + }, + "Cost": { + "BuildTime": "500", "Population": "0", + "Resources": { "food": "0", "metal": "250", "stone": "300", "wood": "300" } + }, + "DeathDamage": { + "Shape": "Circular", "Range": 20, "FriendlyFire": "true", "Damage": { "Fire": 500 } + }, + "Decay": { + "Active": "false", "DelayTime": "0.0", "SinkAccel": "9.8", + "SinkProb": "1.0", "SinkRate": "3.0", "SinkingAnim": "false" + }, + "GarrisonHolder": { + "BuffHeal": "1", "EjectHealth": "0.1", + "EjectClassesOnDestroy": { "@datatype": "tokens", "_string": "Unit" }, + "List": { "@datatype": "tokens", "_string": "Support Infantry Cavalry" }, + "LoadingRange": "1", "Max": "20" + }, + "Health": { + "DamageVariants": { + "heavydamage": "0.35", "lightdamage": "0.85", "mediumdamage": "0.65" + }, + "DeathType": "corpse", "IdleRegenRate": "0", + "Max": "3000", "RegenRate": "0", + "SpawnEntityOnDeath": "decay|rubble/rubble_stone_6x6", + "Unhealable": "true" + }, + "Identity": { + "Civ": "civ1", + "Classes": { "@datatype": "tokens", "_string": "StructureClass1 StructureClass2 StructureClass3" }, + "GenericName": "Example Structure", + "Icon": "example_structure.png", + "SelectionGroupName": "example_structure", + "Tooltip": "This is an example structure.", + "Undeletable": "false", + "VisibleClasses": { "@datatype": "tokens", "_string": "StructureVisibleClass1 StructureVisibleClass2" } + }, + "Loot": { "food": "10", "metal": "50", "stone": "60", "wood": "60", "xp": "30" }, + "Population": { "Bonus": 5 }, + "Position": { + "Altitude": "0", "Anchor": "upright", "FloatDepth": "0", + "Floating": "false", "TurnRate": "6" + }, + "ProductionQueue": {}, + "Repairable": { "RepairTimeRatio": "2.0" }, + "Researcher": { + "Technologies": { "@datatype": "tokens", "_string": "tech1 tech2 tech3" } + }, + "Resistance": { + "Entity": { + "ApplyStatus": { "Poisoned": { "BlockChance": "1", "Duration": "0.0" } }, + "Damage": { "Crush": "3", "Hack": "29", "Pierce": "35" } + }, + "Foundation": { + "ApplyStatus": { "Poisoned": { "BlockChance": "1", "Duration": "0.0" } }, + "Damage": { "Crush": "1", "Hack": "1", "Pierce": "10" } + } + }, + "ResourceDropsite": { "Sharable": "true", "Types": "food stone metal" }, + "ResourceTrickle": { "Rates": { "food": 1.3 }, "Interval": 1500 }, + "TerritoryDecay": { "DecayRate": "20", "Territory": "neutral enemy" }, + "TerritoryInfluence": { "Radius": "140", "Root": "true", "Weight": "10000" }, + "Trainer": { + "BatchTimeModifier": "0.8", + "Entities": { "@datatype": "tokens", "_string": "template1 template2 template3" } + }, + "Visibility": { + "AlwaysVisible": "false", "Corpse": "false", + "Preview": "false", "RetainInFog": "true" + }, + "Vision": { "Range": "90" }, + "VisionSharing": { "Bribable": "false" } +}; +templates.example_unit_template = { + "@parent": "parent_unit_template", + "Attack": { + "Capture": { + "AttackName": "Capture", "Capture": "2.5", + "MaxRange": "4", "RepeatTime": "1000", + "RestrictedClasses": { "@datatype": "tokens", "_string": "RestrictedClass1" } + }, + "Melee": { + "AttackName": "Spear", "MaxRange": "4", + "Bonuses": { "BonusCavMelee": { "Classes": "Cavalry", "Multiplier": "2.5" } }, + "Damage": { "Hack": "4.5", "Pierce": "4" }, + "PreferredClasses": { "@datatype": "tokens", "_string": "PreferredClass1" }, + "PrepareTime": "500", "RepeatTime": "1000" + }, + "Slaughter": { + "AttackName": "Slaughter", "Damage": { "Hack": "1000" }, + "MaxRange": "2", "PrepareTime": "900", "RepeatTime": "1000", + } + }, + "Builder": { + "Entities": { + "@datatype": "tokens", + "_string": "buildable1 buildable2 buildable3 buildable4" + }, + "Rate": "1.0" + }, + "Cost": { + "BuildTime": "10", "Population": "1", + "Resources": { "food": "50", "metal": "0", "stone": "0", "wood": "50" } + }, + "Garrisonable": { "Size": "1" }, + "Heal": { + "Range": 20, "Health": 10, "Interval": 2500, + "UnhealableClasses": { "@datatype": "tokens", "_string": "" }, + "HealableClasses": { "@datatype": "tokens", "_string": "HealableClass1" }, + "LineTexture": "heal_line_texture.png", + "LineTextureMask": "heal_line_mask.png", + "LineThickness": 0.36 + }, + "Health": { + "DeathType": "corpse", "IdleRegenRate": "0", + "Max": "100", "RegenRate": "0", "Unhealable": "false" + }, + "Identity": { + "Civ": "civ2", "Lang": "greek", "Rank": "Basic", + "Classes": { "@datatype": "tokens", "_string": "UnitClass1 UnitClass2 UnitClass3" }, + "GenericName": "Example Unit", + "Icon": "example_unit.png", + "Phenotype": { "@datatype": "tokens", "_string": "male" }, + "SelectionGroupName": "example_unit", + "Tooltip": "This is an example unit.", + "Undeletable": "false", + "VisibleClasses": { + "@datatype": "tokens", + "_string": "VisibleUnitClass1 VisibleUnitClass2" + } + }, + "Loot": { "food": "5", "metal": "4", "stone": "3", "wood": "5", "xp": "100" }, + "Position": { + "Altitude": "0", "Anchor": "upright", + "FloatDepth": "0.0", "Floating": "false", + "TurnRate": "14" + }, + "Promotion": { "Entity": "promoted_unit_template", "RequiredXp": "100" }, + "Resistance": { + "Entity": { "Damage": { "Crush": "15", "Hack": "3", "Pierce": "3" } } + }, + "ResourceGatherer": { + "BaseSpeed": "1.0", + "Capacities": { "food": "10", "metal": "11", "stone": "12", "wood": "13" }, + "MaxDistance": "2.0", + "Rates": { + "food.fruit": "0.5", "food.grain": "0.6", "food.meat": "0.7", + "metal.ore": "0.8", "metal.ruins": "0.9", "stone.rock": "1.0", + "stone.ruins": "1.1", "wood.ruins": "1.2", "wood.tree": "1.3" + } + }, + "TreasureCollector": { "MaxDistance": "2" }, + "UnitAI": { + "CanGuard": "true", "CanPatrol": "true", + "CheeringTime": "2800", + "DefaultStance": "aggressive", + "FleeDistance": "12.0", + "FormationController": "false", + "Formations": { "@datatype": "tokens", "_string": "formation1 formation2 formation3" }, + "PatrolWaitTime": "1" + }, + "UnitMotion": { + "Acceleration": "35", "FormationController": "false", + "InstantTurnAngle": "1.5", + "PassabilityClass": "default", + "RunMultiplier": "2", + "WalkSpeed": "9.5", + "Weight": "10" + }, + "Visibility": { + "AlwaysVisible": "false", "Corpse": "false", + "Preview": "false", "RetainInFog": "false" + }, + "Vision": { "Range": "80" }, + "VisionSharing": { "Bribable": "false" } +}; + +const expectedTemplateData = { + "example_structure_template": { + "alertRaiser": { "classes": "UnitClass1 UnitClass2" }, + "attack": { + "Ranged": { + "Damage": { "Pierce": 8 }, "attackName": { "name": "Bow" }, + "minRange": 0, "maxRange": 60, "yOrigin": 0, "elevationAdaptedRange": 60, + "repeatTime": 4000, "projectileCount": 1, "friendlyFire": false + } + }, + "auras": { + "aura1": { + "name": { "generic": "Aura 1" }, "description": "Aura Description", + "modifications": { "value": "Resistance/Damage/Crush", "add": 1 }, "radius": 30 } + }, + "buildingAI": { + "defaultArrowCount": 6, "maxArrowCount": 0, + "garrisonArrowMultiplier": 1, "garrisonArrowClasses": ["UnitClass1"] + }, + "buildRestrictions": { + "placementType": "shore", "territory": "own ally", "category": "Structure1", + "distance": { "fromClass": "StructureClass0", "min": 100 } + }, + "maxCapturePoints": 2500, + "cost": { "food": 0, "metal": 250, "stone": 300, "wood": 300, "population": 0, "time": 500 }, + "deathDamage": { "Damage": { "Fire": 500 }, "friendlyFire": true }, + "garrisonHolder": { "allowedClasses": "Support Infantry Cavalry", "buffHeal": 1, "capacity": 20 }, + "maxHitpoints": 3000, + "selectionGroupName": "example_structure", + "name": { "specific": "Example Structure", "generic": "Example Structure" }, + "icon": "example_structure.png", + "tooltip": "This is an example structure.", + "identityClasses": ["StructureClass1", "StructureClass2", "StructureClass3", + "StructureVisibleClass1", "StructureVisibleClass2"], + "visibleIdentityClasses": ["StructureVisibleClass1", "StructureVisibleClass2"], + "nativeCiv": "civ1", + "requirements": undefined, + "rank": undefined, + "undeletable": "false", + "enablesBartering": false, + "loot": { "food": 10, "metal": 50, "stone": 60, "wood": 60, "xp": 30 }, + "population": { "bonus": 5 }, + "researcher": { + "techCostMultiplier": { "food": 1, "metal": 1, "stone": 1, "wood": 1, "time": 1 } + }, + "resistance": { + "Damage": { "Crush": 3, "Hack": 29, "Pierce": 35 }, + "ApplyStatus": { "Poisoned": { "blockChance": 1, "duration": 0 } } + }, + "resourceDropsite": { "types": ["food", "stone", "metal"] }, + "resourceTrickle": { "interval": 1500, "rates": { "food": 1.3 } } + }, + + "example_unit_template": { + "attack": { + "Capture": { + "Capture": 2.5, "attackName": { "name": "Capture" }, + "minRange": 0, "maxRange": 4, "yOrigin": 0, + "elevationAdaptedRange": 4, "repeatTime": 1000 + }, + "Melee": { + "Damage": { "Hack": 4.5, "Pierce": 4 }, "attackName": { "name": "Spear" }, + "minRange": 0, "maxRange": 4, "yOrigin": 0, + "elevationAdaptedRange": 4, "repeatTime": 1000 + }, + "Slaughter": { + "Damage": { "Hack": 1000 }, "attackName": { "name": "Slaughter" }, + "minRange": 0, "maxRange": 2, "yOrigin": 0, + "elevationAdaptedRange": 2, "repeatTime": 1000 + } + }, + "builder": true, + "cost": { "food": 50, "metal": 0, "stone": 0, "wood": 50, "population": 1, "time": 10 }, + "garrisonable": { "size": 1 }, + "heal": { + "health": 10, "range": 20, "interval": 2500, "unhealableClasses": "", "healableClasses": "HealableClass1" + }, + "maxHitpoints": 100, + "selectionGroupName": "example_unit", + "name": { "specific": "Example Unit", "generic": "Example Unit" }, + "icon": "example_unit.png", + "tooltip": "This is an example unit.", + "identityClasses": [ + "UnitClass1", "UnitClass2", "UnitClass3", + "VisibleUnitClass1", "VisibleUnitClass2", "Basic" + ], + "visibleIdentityClasses": ["VisibleUnitClass1", "VisibleUnitClass2"], + "nativeCiv": "civ2", + "requirements": undefined, + "rank": "Basic", + "undeletable": "false", + "enablesBartering": false, + "loot": { "food": 5, "metal": 4, "stone": 3, "wood": 5, "xp": 100 }, + "promotion": { "req": 100 }, + "resistance": { "Damage": { "Crush": 15, "Hack": 3, "Pierce": 3 } }, + "resourceGatherRates": { + "food.fruit": 0.5, "food.grain": 0.6, "food.meat": 0.7, + "metal.ore": 0.8, "metal.ruins": 0.9, + "stone.rock": 1, "stone.ruins": 1.1, + "wood.ruins": 1.2, "wood.tree": 1.3 + }, + "treasureCollector": true, + "unitAI": { "formations": ["formation1", "formation2", "formation3"] }, + "unitMotion": { "walk": 9.5, "run": 19, "acceleration": 35 } + } +}; + +global.AuraTemplates = { "GetAll": () => ({ + "aura1": { + "auraName": "Aura 1", + "auraDescription": "Aura Description", + "modifications": { "value": "Resistance/Damage/Crush", "add": 1 }, + "radius": 30 + } +}) }; + +Resources = { + "GetCodes": () => ["food", "metal", "stone", "wood"], + "GetNames": () => ({ + "food": "Food", + "metal": "Metal", + "stone": "Stone", + "wood": "Wood" + }), + "GetResource": resource => ({ + "aiAnalysisInfluenceGroup": + resource == "food" ? "ignore" : + resource == "wood" ? "abundant" : "sparse" + }) +}; + +const PLAYER_1_ENT_ID = 101; +const PLAYER_2_ENT_ID = 102; +cmpModifiersManager.OnGlobalPlayerEntityChanged({ "player": 1, "from": INVALID_PLAYER, "to": PLAYER_1_ENT_ID }); +cmpModifiersManager.OnGlobalPlayerEntityChanged({ "player": 2, "from": INVALID_PLAYER, "to": PLAYER_2_ENT_ID }); + +AddMock(SYSTEM_ENTITY, IID_PlayerManager, { + "GetPlayerByID": (id) => 100 + id +}); + +AddMock(PLAYER_1_ENT_ID, IID_Identity, { + "GetCiv": () => "civ1" +}); +AddMock(PLAYER_1_ENT_ID, IID_Player, { + "GetPlayerID": () => 1 +}); + +AddMock(PLAYER_2_ENT_ID, IID_Identity, { + "GetCiv": () => "civ1" +}); +AddMock(PLAYER_2_ENT_ID, IID_Player, { + "GetPlayerID": () => 2 +}); + +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetTemplateData(1, { "templateName": "example_structure_template" }), expectedTemplateData.example_structure_template); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetTemplateData(1, { "templateName": "example_unit_template" }), expectedTemplateData.example_unit_template); +const TEST_ENTITY_STRUCTURE = 11; +const TEST_ENTITY_UNIT_1 = 21; +const TEST_ENTITY_UNIT_2 = 22; +templateNames[TEST_ENTITY_STRUCTURE] = "example_structure_template"; +templateNames[TEST_ENTITY_UNIT_1] = "example_unit_template"; +templateNames[TEST_ENTITY_UNIT_2] = "example_unit_template"; + +const AddMockToEnts = (ents, iid, mock) => +{ + for (const ent of ents) + AddMock(ent, iid, mock); +}; + +AddMock(SYSTEM_ENTITY, IID_RangeManager, { + "GetLosVisibility": () => "visible", + "GetElevationAdaptedRange": () => 80, + "GetEntitiesByPlayer": (player) => player == 1 ? [TEST_ENTITY_STRUCTURE, TEST_ENTITY_UNIT_1, TEST_ENTITY_UNIT_2] : [] +}); +AddMockToEnts([TEST_ENTITY_STRUCTURE, TEST_ENTITY_UNIT_1, TEST_ENTITY_UNIT_2], IID_Ownership, { + "GetOwner": () => 1 +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_Position, { + "GetTurretParent": () => INVALID_ENTITY, + "GetPosition": () => new Vector3D(1, 2, 3), + "GetRotation": () => new Vector3D(0, 1, 0), + "IsInWorld": () => true +}); +AddMock(TEST_ENTITY_UNIT_1, IID_Position, { + "GetTurretParent": () => INVALID_ENTITY, + "GetPosition": () => new Vector3D(10, 12, 19), + "GetRotation": () => new Vector3D(0, 1, 0), + "IsInWorld": () => true +}); +AddMock(TEST_ENTITY_UNIT_2, IID_Position, { + "GetTurretParent": () => INVALID_ENTITY, + "GetPosition": () => new Vector3D(20, 22, 24), + "GetRotation": () => new Vector3D(0, 1, 1), + "IsInWorld": () => true +}); +const structureAttack = expectedTemplateData.example_structure_template.attack; +AddMock(TEST_ENTITY_STRUCTURE, IID_Attack, { + "GetAttackTypes": () => Object.keys(structureAttack), + "GetRange": (type) => ({ "min": structureAttack[type].minRange, "max": structureAttack[type].maxRange }), + "GetAttackYOrigin": (type) => structureAttack[type].yOrigin +}); +AddMockToEnts([TEST_ENTITY_UNIT_1, TEST_ENTITY_UNIT_2], IID_Attack, { + "GetAttackTypes": () => Object.keys(expectedTemplateData.example_unit_template.attack) +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_BuildingAI, { + "GetArrowCount": () => 10 +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_Capturable, { + "GetCapturePoints": () => 1567 +}); +AddMockToEnts([TEST_ENTITY_UNIT_1, TEST_ENTITY_UNIT_2], IID_Garrisonable, { + "HolderID": () => INVALID_ENTITY +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_GarrisonHolder, { + "GetEntities": () => [51, 52, 53], + "OccupiedSlots": () => 3 +}); +AddMock(TEST_ENTITY_UNIT_1, IID_Guard, { + "GetEntities": () => [TEST_ENTITY_STRUCTURE] +}); +AddMock(TEST_ENTITY_UNIT_2, IID_Guard, { + "GetEntities": () => [] +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_Health, { + "GetHitpoints": () => 2139, + "IsRepairable": () => true, + "IsInjured": () => true, + "IsUnhealable": () => true +}); +AddMock(TEST_ENTITY_UNIT_1, IID_Health, { + "GetHitpoints": () => 39, + "IsRepairable": () => false, + "IsInjured": () => true, + "IsUnhealable": () => false +}); +AddMock(TEST_ENTITY_UNIT_2, IID_Health, { + "GetHitpoints": () => 100, + "IsRepairable": () => false, + "IsInjured": () => false, + "IsUnhealable": () => false +}); +AddMockToEnts([TEST_ENTITY_UNIT_1, TEST_ENTITY_UNIT_2], IID_Identity, { + "GetClassesList": () => expectedTemplateData.example_unit_template.identityClasses, + "IsControllable": () => true +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_Identity, { + "GetClassesList": () => expectedTemplateData.example_structure_template.identityClasses, + "IsControllable": () => true +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_ProductionQueue, { + "GetQueue": () => [{ + "unitTemplate": "trainable1", "count": 2, "neededSlots": 0, "progress": 0.375, + "timeRemaining": 5000, "paused": undefined, "metadata": undefined, "id": 12 + }], + "IsAutoQueueing": () => true +}); +AddMock(TEST_ENTITY_UNIT_1, IID_Promotion, { + "GetCurrentXp": () => 89 +}); +AddMock(TEST_ENTITY_UNIT_2, IID_Promotion, { + "GetCurrentXp": () => 56 +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_RallyPoint, { + "GetPositions": () => [{ "x": 10, "y": 50 }, { "x": 20, "y": 40 }] +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_Repairable, { + "GetNumBuilders": () => 2, + "GetBuildTime": () => 234 +}); +AddMock(TEST_ENTITY_UNIT_1, IID_ResourceGatherer, { + "GetCarryingStatus": () => [ { "type": "wood", "amount": 7, "max": 13 } ] +}); +AddMock(TEST_ENTITY_UNIT_2, IID_ResourceGatherer, { + "GetCarryingStatus": () => [ { "type": "wood", "amount": 2, "max": 13 } ] +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_Researcher, { + "GetTechnologiesList": () => ["tech1", { "pair": ["tech2_1", "tech2_2"] }] +}); +AddMock(TEST_ENTITY_STRUCTURE, IID_Trainer, { + "GetEntitiesList": () => ["trainable1", "trainable2", "trainable3"] +}); +const FORMATION_CONTROLLER_ENT_ID = 43; +AddMockToEnts([TEST_ENTITY_UNIT_1, TEST_ENTITY_UNIT_2], IID_UnitAI, { + "GetCurrentState": () => "INDIVIDUAL.IDLE", + "GetOrders": () => [{ "type": "Walk", "data": { "x": 11, "z": 22, "force": true, "relaxed": true } }], + "HasWorkOrders": () => true, + "IsGuardOf": () => false, + "GetSelectableStances": () => ["stance1", "stance2", "stance3", "stance4", "stance5"], + "IsIdle": () => true, + "GetFormationController": () => FORMATION_CONTROLLER_ENT_ID +}); + +const expectedDynamicStates = { + [TEST_ENTITY_STRUCTURE]: { + "id": TEST_ENTITY_STRUCTURE, + "player": 1, + "templateName": "example_structure_template", + "attack": { "Ranged": { "elevationAdaptedRange": 80 } }, + "buildingAI": { "arrowCount": 10 }, + "capturePoints": 1567, + "controllable": true, + "garrisonHolder": { "entities": [51, 52, 53], "occupiedSlots": 3 }, + "hitpoints": 2139, + "needsRepair": true, + "needsHeal": false, + "position": { "x": 1, "y": 2, "z": 3 }, + "production": { + "queue": [{ + "unitTemplate": "trainable1", "count": 2, "neededSlots": 0, "progress": 0.375, + "timeRemaining": 5000, "paused": undefined, "metadata": undefined, "id": 12 + }], + "autoqueue": true + }, + "rallyPoint": { "position": { "x": 10, "y": 50 } }, + "repairable": { "numBuilders": 2, "buildTime": 234 }, + "researcher": { "technologies": ["tech1", { "pair": ["tech2_1", "tech2_2"] }] }, + "trainer": { "entities": ["trainable1", "trainable2", "trainable3"] }, + "visibility": "visible" + }, + [TEST_ENTITY_UNIT_1]: { + "id": TEST_ENTITY_UNIT_1, + "player": 1, + "templateName": "example_unit_template", + "controllable": true, + "garrisonable": { "holder": INVALID_ENTITY }, + "guard": { "entities": [TEST_ENTITY_STRUCTURE] }, + "hitpoints": 39, + "needsRepair": false, + "needsHeal": true, + "position": { "x": 10, "y": 12, "z": 19 }, + "promotion": { "curr": 89 }, + "resourceCarrying": [{ "type": "wood", "amount": 7, "max": 13 }], + "unitAI": { + "state": "INDIVIDUAL.IDLE", + "orders": [{ "type": "Walk", "data": { "x": 11, "z": 22, "force": true, "relaxed": true } }], + "hasWorkOrders": true, + "isGuarding": false, + "selectableStances": ["stance1", "stance2", "stance3", "stance4", "stance5"], + "isIdle": true, + "formation": FORMATION_CONTROLLER_ENT_ID + }, + "visibility": "visible" + }, + [TEST_ENTITY_UNIT_2]: { + "id": TEST_ENTITY_UNIT_2, + "player": 1, + "templateName": "example_unit_template", + "controllable": true, + "garrisonable": { "holder": INVALID_ENTITY }, + "guard": { "entities": [] }, + "hitpoints": 100, + "needsRepair": false, + "needsHeal": true, + "position": { "x": 20, "y": 22, "z": 24 }, + "promotion": { "curr": 56 }, + "resourceCarrying": [{ "type": "wood", "amount": 2, "max": 13 }], + "unitAI": { + "state": "INDIVIDUAL.IDLE", + "orders": [{ "type": "Walk", "data": { "x": 11, "z": 22, "force": true, "relaxed": true } }], + "hasWorkOrders": true, + "isGuarding": false, + "selectableStances": ["stance1", "stance2", "stance3", "stance4", "stance5"], + "isIdle": true, + "formation": FORMATION_CONTROLLER_ENT_ID + }, + "visibility": "visible" + } +}; + +// The template data of both templates has been retrieved already, so it shouldn't be computed again now. +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_STRUCTURE), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_STRUCTURE] +}); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_1), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_1] +}); + +Engine.PostMessage = (ent, mtid, msg) => +{ + if (mtid == MT_TemplateModification && ent == SYSTEM_ENTITY) + cmpGuiInterface.OnTemplateModification(msg); +}; + +// This makes the previously returned templateData out-of-date, so it should now be recomputed (once) the next time +// queried for it. +cmpModifiersManager.AddModifier( + "Attack/Ranged/Damage/Pierce", "DamageBuff", [{ "affects": ["StructureClass2"], "add": 1 }], PLAYER_1_ENT_ID +); + +expectedTemplateData.example_structure_template.attack.Ranged.Damage.Pierce += 1; + +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_STRUCTURE), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_STRUCTURE], + "templateData": expectedTemplateData.example_structure_template +}); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_1), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_1], + "templateData": expectedTemplateData.example_unit_template +}); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_2), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_2] +}); + +// On the other hand, changes of other playes' modifiers should be ignored. +cmpGuiInterface.OnTemplateModification({ "player": 2 }); +TS_ASSERT(!cmpGuiInterface.GetEntityState(1, TEST_ENTITY_STRUCTURE).templateData); + + +const increasedMaxHPModifiers = [{ "affects": ["UnitClass1", "StructureClass1"], "add": 50 }]; +cmpModifiersManager.AddModifier("Health/Max", "IncreasedMaxHP", increasedMaxHPModifiers, TEST_ENTITY_UNIT_1); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_1), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_1], + "modificationsID": "Health/Max:IncreasedMaxHP(1) ", + // New modificationsID, so modifiedTemplateData should be computed. Only for the affected components, though. + "modifiedTemplateData": { "maxHitpoints": 150 } +}); + +cmpModifiersManager.AddModifier("Health/Max", "IncreasedMaxHP", increasedMaxHPModifiers, TEST_ENTITY_UNIT_2); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_2), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_2], + "modificationsID": "Health/Max:IncreasedMaxHP(1) " + // modifiedTemplateData for owner 1, example_unit_template and the above modificationsID has been returned in the + // previous call already, so it shouldn't be computed again now. +}); + +cmpModifiersManager.AddModifier("Promotion/RequiredXp", "FasterPromotion", [{ "affects": ["UnitClass2"], "add": -10 }], TEST_ENTITY_UNIT_2, true); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_2), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_2], + "modificationsID": "Health/Max:IncreasedMaxHP(1) Promotion/RequiredXp:FasterPromotion(1) ", + // New modificationsID, so modifiedTemplateData should be computed again. + "modifiedTemplateData": { "maxHitpoints": 150, "promotion": { "req": 90 } } +}); + +cmpModifiersManager.AddModifier("Health/Max", "IncreasedMaxHP", increasedMaxHPModifiers, TEST_ENTITY_STRUCTURE); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_STRUCTURE), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_STRUCTURE], + "modificationsID": "Health/Max:IncreasedMaxHP(1) ", + // modifiedTemplateData for owner 1 and the above modificationsID has been returned in a previous call but only for + // different template (example_unit_template and not example_structure_template), so it should be computed again now. + "modifiedTemplateData": { "maxHitpoints": 3050 } +}); + + +// This makes any previously returned templateData and modifiedTemplateData out-of-date, so they should now be recomputed +// (once) the next time queried for them. +cmpModifiersManager.AddModifier("Health/Max", "DecreasedMaxHP", [{ "affects": ["UnitClass1"], "add": -1 }], PLAYER_1_ENT_ID); +expectedTemplateData.example_unit_template.maxHitpoints -= 1; +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_1), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_1], + "templateData": expectedTemplateData.example_unit_template, + "modificationsID": "Health/Max:IncreasedMaxHP(1) ", + "modifiedTemplateData": { "maxHitpoints": 149 } +}); +cmpModifiersManager.RemoveModifier("Promotion/RequiredXp", "FasterPromotion", TEST_ENTITY_UNIT_2); +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_2), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_2], + "modificationsID": "Health/Max:IncreasedMaxHP(1) ", + // modifiedTemplateData for owner 1, example_unit_template and the above modificationsID has been returned in the + // previous call already, so it shouldn't be computed again now. +}); + +AddMock(TEST_ENTITY_UNIT_2, IID_Ownership, { + "GetOwner": () => 2 +}); +expectedDynamicStates[TEST_ENTITY_UNIT_2].player = 2; +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetEntityState(1, TEST_ENTITY_UNIT_2), { + "dynamicState": expectedDynamicStates[TEST_ENTITY_UNIT_2], + "templateData": { ...expectedTemplateData.example_unit_template, "maxHitpoints": 100 }, + "modificationsID": "Health/Max:IncreasedMaxHP(1) ", + // modifiedTemplateData for example_unit_template and the above modificationsID has been returned in the previous + // call, but for a different owner (player 1, not player 2), so it should be computed again now. + "modifiedTemplateData": { "maxHitpoints": 150 } +}); diff --git a/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js b/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js index 63fc25aa74..4064888472 100644 --- a/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js +++ b/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js @@ -1,51 +1,15 @@ +Engine.LoadHelperScript("EntityStateRetriever.js"); Engine.LoadHelperScript("ObstructionSnap.js"); Engine.LoadHelperScript("Player.js"); -Engine.LoadComponentScript("interfaces/AlertRaiser.js"); -Engine.LoadComponentScript("interfaces/Auras.js"); Engine.LoadComponentScript("interfaces/Barter.js"); -Engine.LoadComponentScript("interfaces/Builder.js"); -Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/CeasefireManager.js"); -Engine.LoadComponentScript("interfaces/DeathDamage.js"); Engine.LoadComponentScript("interfaces/Diplomacy.js"); Engine.LoadComponentScript("interfaces/EndGameManager.js"); Engine.LoadComponentScript("interfaces/EntityLimits.js"); -Engine.LoadComponentScript("interfaces/Formation.js"); -Engine.LoadComponentScript("interfaces/Foundation.js"); -Engine.LoadComponentScript("interfaces/Garrisonable.js"); -Engine.LoadComponentScript("interfaces/GarrisonHolder.js"); -Engine.LoadComponentScript("interfaces/Gate.js"); -Engine.LoadComponentScript("interfaces/Guard.js"); -Engine.LoadComponentScript("interfaces/Heal.js"); -Engine.LoadComponentScript("interfaces/Health.js"); -Engine.LoadComponentScript("interfaces/Loot.js"); -Engine.LoadComponentScript("interfaces/Market.js"); -Engine.LoadComponentScript("interfaces/Pack.js"); -Engine.LoadComponentScript("interfaces/Population.js"); Engine.LoadComponentScript("interfaces/PopulationCapManager.js"); -Engine.LoadComponentScript("interfaces/ProductionQueue.js"); -Engine.LoadComponentScript("interfaces/Promotion.js"); -Engine.LoadComponentScript("interfaces/Repairable.js"); -Engine.LoadComponentScript("interfaces/Researcher.js"); -Engine.LoadComponentScript("interfaces/Resistance.js"); -Engine.LoadComponentScript("interfaces/ResourceDropsite.js"); -Engine.LoadComponentScript("interfaces/ResourceGatherer.js"); -Engine.LoadComponentScript("interfaces/ResourceTrickle.js"); -Engine.LoadComponentScript("interfaces/ResourceSupply.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); -Engine.LoadComponentScript("interfaces/Trader.js"); -Engine.LoadComponentScript("interfaces/Trainer.js"); -Engine.LoadComponentScript("interfaces/TurretHolder.js"); Engine.LoadComponentScript("interfaces/Timer.js"); -Engine.LoadComponentScript("interfaces/Treasure.js"); -Engine.LoadComponentScript("interfaces/TreasureCollector.js"); -Engine.LoadComponentScript("interfaces/Turretable.js"); Engine.LoadComponentScript("interfaces/StatisticsTracker.js"); -Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); -Engine.LoadComponentScript("interfaces/UnitAI.js"); -Engine.LoadComponentScript("interfaces/Upgrade.js"); -Engine.LoadComponentScript("interfaces/Upkeep.js"); -Engine.LoadComponentScript("interfaces/BuildingAI.js"); Engine.LoadComponentScript("GuiInterface.js"); Resources = { @@ -63,7 +27,7 @@ Resources = { }) }; -var cmp = ConstructComponent(SYSTEM_ENTITY, "GuiInterface"); +const cmpGuiInterface = ConstructComponent(SYSTEM_ENTITY, "GuiInterface"); AddMock(SYSTEM_ENTITY, IID_Barter, { @@ -88,13 +52,7 @@ AddMock(SYSTEM_ENTITY, IID_PlayerManager, { }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { - "GetLosVisibility": function(ent, player) { return "visible"; }, - "GetLosCircular": function() { return false; } -}); - -AddMock(SYSTEM_ENTITY, IID_TemplateManager, { - "GetCurrentTemplateName": function(ent) { return "example"; }, - "GetTemplate": function(name) { return ""; } + "GetLosCircular": function() { return false; }, }); AddMock(SYSTEM_ENTITY, IID_PopulationCapManager, { @@ -302,7 +260,7 @@ AddMock(101, IID_StatisticsTracker, { // 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. -TS_ASSERT_UNEVAL_EQUALS(cmp.GetSimulationState(), { +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetSimulationState(), { "players": [ { "name": "Player 1", @@ -413,7 +371,7 @@ TS_ASSERT_UNEVAL_EQUALS(cmp.GetSimulationState(), { "populationCap": 200 }); -TS_ASSERT_UNEVAL_EQUALS(cmp.GetExtendedSimulationState(), { +TS_ASSERT_UNEVAL_EQUALS(cmpGuiInterface.GetExtendedSimulationState(), { "players": [ { "name": "Player 1", @@ -569,72 +527,3 @@ TS_ASSERT_UNEVAL_EQUALS(cmp.GetExtendedSimulationState(), { "populationCapType": "player", "populationCap": 200 }); - - -AddMock(10, IID_Builder, { - "GetEntitiesList": function() - { - return ["test1", "test2"]; - }, -}); - -AddMock(10, IID_Health, { - "GetHitpoints": function() { return 50; }, - "GetMaxHitpoints": function() { return 60; }, - "IsRepairable": function() { return false; }, - "IsUnhealable": function() { return false; } -}); - -AddMock(10, IID_Identity, { - "GetClassesList": function() { return ["class1", "class2"]; }, - "GetRank": function() { return "foo"; }, - "GetSelectionGroupName": function() { return "Selection Group Name"; }, - "HasClass": function() { return true; }, - "IsUndeletable": function() { return false; }, - "IsControllable": function() { return true; } -}); - -AddMock(10, IID_Position, { - "GetTurretParent": function() { return INVALID_ENTITY; }, - "GetPosition": function() - { - return { "x": 1, "y": 2, "z": 3 }; - }, - "IsInWorld": function() - { - return true; - } -}); - -AddMock(10, IID_ResourceTrickle, { - "GetInterval": () => 1250, - "GetRates": () => ({ "food": 2, "wood": 3, "stone": 5, "metal": 9 }) -}); - -// 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. -TS_ASSERT_UNEVAL_EQUALS(cmp.GetEntityState(-1, 10), { - "id": 10, - "player": INVALID_PLAYER, - "template": "example", - "identity": { - "rank": "foo", - "classes": ["class1", "class2"], - "selectionGroupName": "Selection Group Name", - "canDelete": true, - "controllable": true, - }, - "position": { "x": 1, "y": 2, "z": 3 }, - "hitpoints": 50, - "maxHitpoints": 60, - "needsRepair": false, - "needsHeal": true, - "builder": true, - "visibility": "visible", - "isBarterMarket": true, - "resourceTrickle": { - "interval": 1250, - "rates": { "food": 2, "wood": 3, "stone": 5, "metal": 9 } - } -}); diff --git a/binaries/data/mods/public/simulation/components/tests/test_ModifiersManager.js b/binaries/data/mods/public/simulation/components/tests/test_ModifiersManager.js index bfc4ef644b..6a5aaacf03 100644 --- a/binaries/data/mods/public/simulation/components/tests/test_ModifiersManager.js +++ b/binaries/data/mods/public/simulation/components/tests/test_ModifiersManager.js @@ -248,3 +248,64 @@ TS_ASSERT_EQUALS(ApplyValueModificationsToEntity("Test_D", 10, 5), 16); Engine.PostMessage = oldPostMessage; Engine.BroadcastMessage = oldBroadcastMessage; })(); + + +(function Test_ModifiersInfo() +{ + const TEST_ENTITY_1 = 13; + const TEST_ENTITY_2 = 14; + + cmpModifiersManager = ConstructComponent(SYSTEM_ENTITY, "ModifiersManager", {}); + cmpModifiersManager.Init(); + + TS_ASSERT_UNEVAL_EQUALS(cmpModifiersManager.GetModifiersInfo(TEST_ENTITY_1), { + "modifiedComponents": new Set(), + "modificationsID": "" + }); + TS_ASSERT_UNEVAL_EQUALS(cmpModifiersManager.GetModifiersInfo(TEST_ENTITY_2), { + "modifiedComponents": new Set(), + "modificationsID": "" + }); + + cmpModifiersManager.AddModifier("Component1/Property1", "Modifier1", [{ "affects": ["Unit"], "add": 1 }], TEST_ENTITY_1, true); + cmpModifiersManager.AddModifier("Component1/Property1", "Modifier1", [{ "affects": ["Unit"], "add": 1 }], TEST_ENTITY_1, true); + TS_ASSERT_UNEVAL_EQUALS(cmpModifiersManager.GetModifiersInfo(TEST_ENTITY_1), { + "modifiedComponents": new Set(["Component1"]), + "modificationsID": "Component1/Property1:Modifier1(2) " + }); + + cmpModifiersManager.AddModifier("Component1/Property1", "Modifier2", [{ "affects": ["Unit"], "add": 2 }], TEST_ENTITY_1); + cmpModifiersManager.AddModifier("Component1/Property2", "Modifier3", [{ "affects": ["Unit"], "add": 3 }], TEST_ENTITY_1); + cmpModifiersManager.AddModifier("Component2/Property1", "Modifier1", [{ "affects": ["Unit"], "add": 10 }], TEST_ENTITY_1); + TS_ASSERT_UNEVAL_EQUALS(cmpModifiersManager.GetModifiersInfo(TEST_ENTITY_1), { + "modifiedComponents": new Set(["Component1", "Component2"]), + "modificationsID": "Component1/Property1:Modifier1(2)Modifier2(1) Component1/Property2:Modifier3(1) Component2/Property1:Modifier1(1) " + }); + cmpModifiersManager.RemoveModifier("Component1/Property2", "Modifier3", TEST_ENTITY_1); + TS_ASSERT_UNEVAL_EQUALS(cmpModifiersManager.GetModifiersInfo(TEST_ENTITY_1), { + "modifiedComponents": new Set(["Component1", "Component2"]), + "modificationsID": "Component1/Property1:Modifier1(2)Modifier2(1) Component2/Property1:Modifier1(1) " + }); + + const assertModificationsIDsEqual = equal => + { + let expected = cmpModifiersManager.GetModifiersInfo(TEST_ENTITY_1).modificationsID == + cmpModifiersManager.GetModifiersInfo(TEST_ENTITY_2).modificationsID; + if (!equal) + expected = !expected; + + TS_ASSERT(expected); + }; + + // Even if modifiers were added in different orders, two entities with the same one should have the same modifications ID. + cmpModifiersManager.AddModifier("Component2/Property1", "Modifier1", [{ "affects": ["Unit"], "add": 10 }], TEST_ENTITY_2); + cmpModifiersManager.AddModifier("Component1/Property1", "Modifier1", [{ "affects": ["Unit"], "add": 1 }], TEST_ENTITY_2, true); + cmpModifiersManager.AddModifier("Component1/Property1", "Modifier2", [{ "affects": ["Unit"], "add": 2 }], TEST_ENTITY_2); + assertModificationsIDsEqual(false); + cmpModifiersManager.AddModifier("Component1/Property1", "Modifier1", [{ "affects": ["Unit"], "add": 1 }], TEST_ENTITY_2, true); + assertModificationsIDsEqual(true); + cmpModifiersManager.RemoveModifier("Component1/Property1", "Modifier3", TEST_ENTITY_1); + assertModificationsIDsEqual(true); + cmpModifiersManager.RemoveModifier("Component2/Property1", "Modifier1", TEST_ENTITY_1); + assertModificationsIDsEqual(false); +})(); diff --git a/binaries/data/mods/public/simulation/components/tests/test_UpgradeModification.js b/binaries/data/mods/public/simulation/components/tests/test_UpgradeModification.js index b43cb3c769..a408855568 100644 --- a/binaries/data/mods/public/simulation/components/tests/test_UpgradeModification.js +++ b/binaries/data/mods/public/simulation/components/tests/test_UpgradeModification.js @@ -141,12 +141,12 @@ cmpUpgrade.OnOwnershipChanged({ "to": playerID }); * To start with, no techs are researched... */ // T1: Check the cost of the upgrade without accounting for any player modifications (as it would be in the structree). -let parsed_template = g_TemplateHelper.getBasicData(template, {}, Resources, ["Upgrade"]); -TS_ASSERT_UNEVAL_EQUALS(parsed_template.upgrades[0].cost, { "stone": 100, "wood": 50, "time": 100 }); +let parsed_template = g_TemplateHelper.getBasicData(template, {}, Resources, civCode, ["Upgrade"]); +TS_ASSERT_UNEVAL_EQUALS(parsed_template.upgrade.options[0].cost, { "stone": 100, "wood": 50, "time": 100 }); // T2: Check the value, this time accounting for player modifiers (as it would be in-session). -parsed_template = g_TemplateHelper.computeDataFromPlayer(template, {}, Resources, playerID, ["Upgrade"]); -TS_ASSERT_UNEVAL_EQUALS(parsed_template.upgrades[0].cost, { "stone": 100, "wood": 50, "time": 100 }); +parsed_template = g_TemplateHelper.computeDataFromPlayer(template, {}, Resources, playerID, civCode, ["Upgrade"]); +TS_ASSERT_UNEVAL_EQUALS(parsed_template.upgrade.options[0].cost, { "stone": 100, "wood": 50, "time": 100 }); // T3: Check that the value is correct within the Update Component. TS_ASSERT_UNEVAL_EQUALS(cmpUpgrade.GetUpgrades()[0].cost, { "stone": 100, "wood": 50, "time": 100 }); @@ -159,12 +159,12 @@ cmpUpgrade.Upgrade("structures/" + civCode + "/defense_tower"); isResearched = true; // T4: Check that the player-less value hasn't increased... -parsed_template = g_TemplateHelper.getBasicData(template, {}, Resources, ["Upgrade"]); -TS_ASSERT_UNEVAL_EQUALS(parsed_template.upgrades[0].cost, { "stone": 100, "wood": 50, "time": 100 }); +parsed_template = g_TemplateHelper.getBasicData(template, {}, Resources, civCode, ["Upgrade"]); +TS_ASSERT_UNEVAL_EQUALS(parsed_template.upgrade.options[0].cost, { "stone": 100, "wood": 50, "time": 100 }); // T5: ...but the player-backed value has. -parsed_template = g_TemplateHelper.computeDataFromPlayer(template, {}, Resources, playerID, ["Upgrade"]); -TS_ASSERT_UNEVAL_EQUALS(parsed_template.upgrades[0].cost, { "stone": 160, "wood": 25, "time": 90 }); +parsed_template = g_TemplateHelper.computeDataFromPlayer(template, {}, Resources, playerID, civCode, ["Upgrade"]); +TS_ASSERT_UNEVAL_EQUALS(parsed_template.upgrade.options[0].cost, { "stone": 160, "wood": 25, "time": 90 }); // T6: The upgrade component should still be using the old resource cost (but new time cost) for the upgrade in progress... TS_ASSERT_UNEVAL_EQUALS(cmpUpgrade.GetUpgrades()[0].cost, { "stone": 100, "wood": 50, "time": 90 }); diff --git a/binaries/data/mods/public/simulation/helpers/EntityStateRetriever.js b/binaries/data/mods/public/simulation/helpers/EntityStateRetriever.js new file mode 100644 index 0000000000..d4d79c4fc8 --- /dev/null +++ b/binaries/data/mods/public/simulation/helpers/EntityStateRetriever.js @@ -0,0 +1,365 @@ +/** + * Helper class for the GuiInterface for retrieving an information about an entity or a template. + * It assumes a certain level of caching on the receiving side (the GUI) and leverages it by only computing + * the absolutely necessary ("unpredictable") parts of the desired data and leaves it up to the receiver to + * put the whole data together by combining it with what has been returned previously already. + * + * It works like this: + * Certain parts (the template data) of an entity state are "predictable" and can therefore be reused between + * entities with the same template. What one has to account for, however, is that values of the template data + * can be modified. This happens on two different levels: + * - Firstly, player-global modifications, which apply to all entities owned by that player. This includes stuff + * like bonuses from researched techs, civs bonuses, team bonuses. + * - Secondly, entity-local modifications, which apply to individual entities. This includes buffs or debuffs + * from status effects or auras (of other entities). + * + * To take advantage of this, we separate the whole entity state into three parts: + * 1. The template data of the entity's template, which only has the player-global modifications applied. It is + * the same for all entities with the same template and owner. + * 2. The modified template data of the entity, which can contain a subset of the template data's values, + * overwriting them. It has both the player-global and entity-local modifications applied, but only contains the + * values that actually differ from its template data. It is therefore the same for all entities with the same + * owner, template, and modifications ID. + * 3. the dynamic state, which differs from entity to entity and has to always be computed from scratch. It should be kept + * as small as possible. + */ +class EntityStateRetriever +{ + _computedTemplateData = {}; + + /** + * Compute basic information about a given template accounting for all modifications registered to a given player. + * The returned data is (of course) consistent. So to avoid unnecessary work, this should never be called twice for the + * same player and template, unless the player's modifications changed in the meantime. It is the caller's responsibility + * to cache and reuse the results. + */ + getTemplateData(player, templateName) + { + if (this._hasComputedTemplateData(player, templateName)) + warn("GetTemplateData called multiple times for the template '" + templateName + "'and player " + player + ". The return value should have been cached in the GUI."); + + this._addComputedTemplateData(player, templateName); + + const template = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager)?.GetTemplate(templateName); + const civ = QueryPlayerIDInterface(player, IID_Identity).GetCiv(); + return template && g_TemplateHelper.computeDataFromPlayer(template, AuraTemplates.GetAll(), Resources, player, civ); + } + + /** + * Compute basic information about a given entity. This includes the template data, the modified template data, and + * the dynamic state, but only what hasn't been computed since the last time that the player modifications changed. + * The caller has to cache and reuse the data where possible. See the class description. + */ + getEntityState(player, ent) + { + if (!ent || ent == INVALID_ENTITY) + return null; + + const cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); + if (!cmpTemplateManager) + return null; + + const templateName = cmpTemplateManager.GetCurrentTemplateName(ent); + const template = templateName && cmpTemplateManager.GetTemplate(templateName); + const owner = Engine.QueryInterface(ent, IID_Ownership)?.GetOwner(); + // All entities must have a template and an owner; if not then it's a nonexistent entity id. + if (!template || owner === undefined) + return null; + + const ret = { + "dynamicState": this._computeDynamicState(owner, player, ent, templateName) + }; + + const civ = QueryPlayerIDInterface(owner, IID_Identity).GetCiv(); + if (!this._hasComputedTemplateData(owner, templateName)) + { + ret.templateData = g_TemplateHelper.computeDataFromPlayer(template, AuraTemplates.GetAll(), Resources, owner, civ); + this._addComputedTemplateData(owner, templateName); + } + + const info = Engine.QueryInterface(SYSTEM_ENTITY, IID_ModifiersManager)?.GetModifiersInfo(ent); + // A falsy modifications ID means the entity doesn't have any modifications applied locally, just the ones from its owner. + if (info.modificationsID) + { + ret.modificationsID = info.modificationsID; + if (!this._hasComputedTemplateData(owner, templateName, info.modificationsID)) + { + ret.modifiedTemplateData = g_TemplateHelper.computeDataFromEntity(template, AuraTemplates.GetAll(), Resources, ent, civ, info.modifiedComponents); + this._addComputedTemplateData(owner, templateName, info.modificationsID); + } + } + + return ret; + } + + onPlayerModificationsChanged(player) + { + this._computedTemplateData[player]?.clear(); + } + + reset() + { + for (const player in this._computedTemplateData) + this._computedTemplateData[player].clear(); + } + + _addComputedTemplateData(player, templateName, modificationsID = "") + { + let playerTable = this._computedTemplateData[player]; + if (!playerTable) + { + playerTable = new Set(); + this._computedTemplateData[player] = playerTable; + } + + playerTable.add(`${templateName} ${modificationsID}`); + } + + _hasComputedTemplateData(player, templateName, modificationsID = "") + { + return this._computedTemplateData[player]?.has(`${templateName} ${modificationsID}`); + } + + /** + * Get an entity's dynamic state, which is unpredictable and differs from entity to entity. + * The information is pulled from its components. + * The structure of each component's data has to be coordinated with the one in TemplateHelper, so that they + * can later be merged into one. + * @param {number} owner - The owner of the entity. + * @param {number} player - The player, for whom to calculate the state for. + * @param {number} ent - ID of the entity. + * @param {string} templateName - The entity's template. + */ + _computeDynamicState(owner, player, ent, templateName) + { + const ret = { + "id": ent, + // TODO: Should maybe be renamed to owner for clarity. + "player": owner, + "templateName": templateName + }; + + const cmpAttack = Engine.QueryInterface(ent, IID_Attack); + const cmpPosition = Engine.QueryInterface(ent, IID_Position); + const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); + const cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); + if (cmpPosition?.IsInWorld() && cmpAttack?.GetAttackTypes().includes("Ranged")) + { + ret.attack = { + "Ranged": { + // For units, take the range in front of it, no spread, so angle = 0, + // else, take the average elevation around it: angle = 2 * pi. + "elevationAdaptedRange": cmpRangeManager.GetElevationAdaptedRange( + cmpPosition.GetPosition(), cmpPosition.GetRotation(), cmpAttack.GetRange("Ranged").max, + cmpAttack.GetAttackYOrigin("Ranged"), cmpUnitAI ? 0 : 2 * Math.PI + ) + } + }; + } + + const cmpBuildingAI = Engine.QueryInterface(ent, IID_BuildingAI); + if (cmpBuildingAI) + ret.buildingAI = { + "arrowCount": cmpBuildingAI.GetArrowCount() + }; + + const cmpCapturable = QueryMiragedInterface(ent, IID_Capturable); + if (cmpCapturable) + ret.capturePoints = cmpCapturable.GetCapturePoints(); + + const cmpIdentity = Engine.QueryInterface(ent, IID_Identity); + ret.controllable = !cmpIdentity || cmpIdentity.IsControllable(); + + const cmpFormation = Engine.QueryInterface(ent, IID_Formation); + if (cmpFormation) + ret.formation = { + "members": cmpFormation.GetMembers() + }; + + const cmpFoundation = QueryMiragedInterface(ent, IID_Foundation); + if (cmpFoundation) + ret.foundation = { + "numBuilders": cmpFoundation.GetNumBuilders(), + "buildTime": cmpFoundation.GetBuildTime() + }; + + const cmpGarrisonable = Engine.QueryInterface(ent, IID_Garrisonable); + if (cmpGarrisonable) + ret.garrisonable = { + "holder": cmpGarrisonable.HolderID() + }; + + const cmpGarrisonHolder = Engine.QueryInterface(ent, IID_GarrisonHolder); + if (cmpGarrisonHolder) + ret.garrisonHolder = { + "entities": cmpGarrisonHolder.GetEntities(), + "occupiedSlots": cmpGarrisonHolder.OccupiedSlots() + }; + + const cmpGate = Engine.QueryInterface(ent, IID_Gate); + if (cmpGate) + ret.gate = { + "locked": cmpGate.IsLocked() + }; + + const cmpGuard = Engine.QueryInterface(ent, IID_Guard); + if (cmpGuard) + ret.guard = { + "entities": cmpGuard.GetEntities() + }; + + const cmpHealth = QueryMiragedInterface(ent, IID_Health); + if (cmpHealth) + { + ret.hitpoints = cmpHealth.GetHitpoints(); + ret.needsRepair = cmpHealth.IsRepairable() && cmpHealth.IsInjured(); + ret.needsHeal = !cmpHealth.IsUnhealable(); + } + + if (cmpPosition) + { + if (cmpPosition.IsInWorld()) + ret.position = cmpPosition.GetPosition(); + if (cmpPosition.GetTurretParent() != INVALID_ENTITY) + ret.turretParent = cmpPosition.GetTurretParent(); + } + + const cmpPack = Engine.QueryInterface(ent, IID_Pack); + if (cmpPack) + ret.pack = { + "packed": cmpPack.IsPacked(), + "progress": cmpPack.GetProgress() + }; + + const cmpProductionQueue = Engine.QueryInterface(ent, IID_ProductionQueue); + if (cmpProductionQueue) + ret.production = { + "queue": cmpProductionQueue.GetQueue(), + "autoqueue": cmpProductionQueue.IsAutoQueueing() + }; + + const cmpPromotion = Engine.QueryInterface(ent, IID_Promotion); + if (cmpPromotion) + ret.promotion = { + "curr": cmpPromotion.GetCurrentXp(), + }; + + const cmpRallyPoint = Engine.QueryInterface(ent, IID_RallyPoint); + if (cmpRallyPoint) + ret.rallyPoint = { "position": cmpRallyPoint.GetPositions()[0] }; // undefined or {x,z} object + + const cmpRepairable = QueryMiragedInterface(ent, IID_Repairable); + if (cmpRepairable) + ret.repairable = { + "numBuilders": cmpRepairable.GetNumBuilders(), + "buildTime": cmpRepairable.GetBuildTime() + }; + + const cmpResourceDropsite = Engine.QueryInterface(ent, IID_ResourceDropsite); + if (cmpResourceDropsite) + ret.resourceDropsite = { + "shared": cmpResourceDropsite.IsShared() + }; + + const cmpResearcher = Engine.QueryInterface(ent, IID_Researcher); + if (cmpResearcher) + ret.researcher = { + "technologies": cmpResearcher.GetTechnologiesList() + }; + + const cmpResourceGatherer = Engine.QueryInterface(ent, IID_ResourceGatherer); + if (cmpResourceGatherer) + ret.resourceCarrying = cmpResourceGatherer.GetCarryingStatus(); + + const cmpResourceSupply = QueryMiragedInterface(ent, IID_ResourceSupply); + if (cmpResourceSupply) + ret.resourceSupply = { + "amount": cmpResourceSupply.GetCurrentAmount(), + "numGatherers": cmpResourceSupply.GetNumGatherers() + }; + + const cmpStatusEffects = Engine.QueryInterface(ent, IID_StatusEffectsReceiver); + if (cmpStatusEffects) + ret.statusEffects = cmpStatusEffects.GetActiveStatuses(); + + const cmpTrader = Engine.QueryInterface(ent, IID_Trader); + if (cmpTrader) + ret.trader = { + "goods": cmpTrader.GetGoods() + }; + + const cmpTrainer = Engine.QueryInterface(ent, IID_Trainer); + if (cmpTrainer) + ret.trainer = { + // TODO: This is technically not "dynamic", since it only depends on the template and modifications, + // so it should be made part of the template data instead in some way without causing too much code + // duplication. + "entities": cmpTrainer.GetEntitiesList() + }; + + const cmpTurretable = Engine.QueryInterface(ent, IID_Turretable); + if (cmpTurretable) + ret.turretable = { + "ejectable": cmpTurretable.IsEjectable(), + "holder": cmpTurretable.HolderID() + }; + + const cmpTurretHolder = Engine.QueryInterface(ent, IID_TurretHolder); + if (cmpTurretHolder) + ret.turretHolder = { + "turretPoints": cmpTurretHolder.GetTurretPoints() + }; + + + if (cmpUnitAI) + ret.unitAI = { + "state": cmpUnitAI.GetCurrentState(), + "orders": cmpUnitAI.GetOrders(), + "hasWorkOrders": cmpUnitAI.HasWorkOrders(), + "isGuarding": cmpUnitAI.IsGuardOf(), + "selectableStances": cmpUnitAI.GetSelectableStances(), + "isIdle": cmpUnitAI.IsIdle(), + "formation": cmpUnitAI.GetFormationController() + }; + + const cmpUpgrade = Engine.QueryInterface(ent, IID_Upgrade); + if (cmpUpgrade) + ret.upgrade = { + "progress": cmpUpgrade.GetProgress(), + "template": cmpUpgrade.GetUpgradingTo(), + "isUpgrading": cmpUpgrade.IsUpgrading() + }; + + if (cmpRangeManager) + ret.visibility = cmpRangeManager.GetLosVisibility(ent, player); + + + // Because mirage entities mirage other entities' components, some values can't be statically read from their + // template. They therefore are "missing" from their template data, so we retrieve them from the components + // directly here and add them to the dynamic state instead, so that they're still present in the entire final + // entity state (the GUI expects them). + // This is somewhat hacky and it would be good to achieve this in another way. + const cmpMirage = Engine.QueryInterface(ent, IID_Mirage); + if (cmpMirage) + { + if (cmpCapturable) + ret.maxCapturePoints = cmpCapturable.GetMaxCapturePoints(); + + if (cmpHealth) + ret.maxHitpoints = cmpHealth.GetMaxHitpointes(); + + if (cmpResourceSupply) + { + ret.resourceSupply.isInfinite = cmpResourceSupply.IsInfinite(); + ret.resourceSupply.max = cmpResourceSupply.GetMaxAmount(); + ret.resourceSupply.type = cmpResourceSupply.GetType(); + ret.resourceSupply.killBeforeGather = cmpResourceSupply.GetKillBeforeGather(); + ret.resourceSupply.maxGatherers = cmpResourceSupply.GetMaxGatherers(); + } + } + + return ret; + } +} + +Engine.RegisterGlobal("EntityStateRetriever", EntityStateRetriever);