Compare commits

...

6 commits

Author SHA1 Message Date
Vantha
2728c65a49
Remove some unused getters from components
Since __________, a bunch of getter on various components are now
unused. This patch removes three of them:
- `Identity.prototype.GetRankTechName`, because it is kinda redundant since
  there's also `Identity.prototype.GetRank`.
- `Auras.prototype.GetDescriptions` and `Upgrade.prototype.GetUpgrades`
  because they are long and perform additional logic (only duplicating
  some lines from globalscripts/Templates.js).

All of the other getters are simple and kept for now because they are
useful to have in general and might be handy in the future or for mods.
2026-08-14 12:47:18 +02:00
Vantha
0a7a6aac87
Deduplicate GetEntityState(clickedEntity) calls 2026-08-14 12:47:18 +02:00
Vantha
8c36cc4879
Simplify GetTemplateData calls
Previously it was made so that "template data" was a sort of super type
of "entity state" and that the former could always be replaced by the
latter. Using this we can simplify some code passages by removing
GetTemplateData calls and using the entity's state directly instead.
2026-08-14 12:47:18 +02:00
Vantha
60970320e3
Rename the formation field to formationController
This patch renames the `unitAI.formation` field in entity states to
`unitAI.formationController` for clarity (there's `unitAI.formations`
as well, which was confusing)
2026-08-14 12:47:17 +02:00
Vantha
072ad01816
Remove selectableStance from entity states
The array was wastefully created and passed to the GUI every single
turn for every single selected unit.
Since the GUI defines the (selectable) stances' names and tooltips, it
already knew them anyway, so they don't even need to be passed from the
simulation to it in the first place.
2026-08-14 12:47:17 +02:00
Vantha
0a3bd16cb5
Optimise GetEntityState
GetEntityState is a performance-critical function in the GUI when a
large number of units are selected. The goal of this patch is to
increase the efficiency of it without modifying the returned states
visible to rest of the GUI in any way (it renames a few properties of
entities states or moves them around, but the information they contain
and the way to access it remain the complete same)

As explained the comments, certain parts (the template data) of an
entity state are "predictable" and can 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-wide modifications, which apply to all entities owned
by that player. This includes stuff like bonuses from researched techs,
civs bonuses, team bonuses. And secondly, entity-local modifications,
which apply to individual entities. This includes buffs or debuffs from
status effects or auras (of other entities).

So we can construct a whole entity state by first just computing the
dynamic state (stuff like current hitpoints, which differs from entity
to entity) and then adding the (potentially already cached) template
data to it, which can be reused between entities with the same template
and owning player. And then overwrite the values affected by
entity-local modifications, which are usually just a few and even they
can be cached and reused for entities with the same owning player,
template, and entity-local modifications (identified by the
"modifications ID").

This saves the effort of retrieving/computing a ton of data each turn
and also saves the time it takes the engine to clone the data from the
simulation to the GUI (as the return value of the GUI interface call),
which previously took just as long as retrieving the entity states
themselves.

Since only the dynamic state is read from the components, a number of
small getter methods have become unused; they are kept (for now at
least) since they might be useful again in the future or for mods.
2026-08-14 12:47:04 +02:00
30 changed files with 1633 additions and 765 deletions

View file

@ -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,

View file

@ -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
});
}

View file

@ -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;

View file

@ -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);
}
/**

View file

@ -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)

View file

@ -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)

View file

@ -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))
);

View file

@ -30,8 +30,7 @@ class PanelEntity
this.panelEntButton.hidden = false;
const entityState = GetEntityState(entityID);
const template = GetTemplateData(entityState.template);
this.nameTooltip = setStringTags(g_SpecificNamesPrimary ? template.name.specific : template.name.generic, this.NameTags) + "\n";
this.nameTooltip = setStringTags(g_SpecificNamesPrimary ? entityState.name.specific : entityState.name.generic, this.NameTags) + "\n";
Engine.GetGUIObjectByName("panelEntityHealthSection[" + buttonID + "]").hidden = !entityState.hitpoints;
@ -45,7 +44,7 @@ class PanelEntity
}
Engine.GetGUIObjectByName("panelEntityImage[" + buttonID + "]").sprite =
"stretched:" + this.PortraitDirectory + template.icon;
"stretched:" + this.PortraitDirectory + entityState.icon;
}
destroy()

View file

@ -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(

View file

@ -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";

View file

@ -1130,8 +1130,9 @@ function handleInputAfterGui(ev)
{
if (clickedEntity == INVALID_ENTITY)
clickedEntity = Engine.PickEntityAtPoint(ev.x, ev.y);
const clickedEntityState = GetEntityState(clickedEntity);
// Abort if we didn't click on an entity or if the entity was removed before the mousebuttonup event.
if (clickedEntity == INVALID_ENTITY || !GetEntityState(clickedEntity))
if (clickedEntity == INVALID_ENTITY || !clickedEntityState)
{
clickedEntity = INVALID_ENTITY;
if (!Engine.HotkeyIsPressed("selection.add") && !Engine.HotkeyIsPressed("selection.remove"))
@ -1157,18 +1158,18 @@ function handleInputAfterGui(ev)
if (ev.clicks == 2)
{
templateToMatch = GetEntityState(clickedEntity).identity.selectionGroupName;
templateToMatch = clickedEntityState.selectionGroupName;
if (templateToMatch)
matchRank = false;
else
// No selection group name defined, so fall back to exact match.
templateToMatch = GetEntityState(clickedEntity).template;
templateToMatch = clickedEntityState.templateName;
}
else
// Triple click
// Select units matching exact template name (same rank).
templateToMatch = GetEntityState(clickedEntity).template;
templateToMatch = clickedEntityState.templateName;
// TODO: Should we handle "control all units" here as well?
ents = Engine.PickSimilarPlayerEntities(templateToMatch, showOffscreen, matchRank, false);
@ -1990,7 +1991,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;
}

View file

@ -35,9 +35,9 @@ function _setMotionOverlay(ents, enabled, motionDebugOverlay, force = false)
{
resultSet.add(ent);
const entState = GetEntityState(ent);
if (entState?.unitAI?.formation)
if (entState?.unitAI?.formationController)
{
resultSet.add(entState.unitAI.formation);
resultSet.add(entState.unitAI.formationController);
}
}
@ -74,7 +74,7 @@ EntityGroups.prototype.add = function(ents)
{
if (this.ents[ent])
continue;
var entState = GetEntityState(ent);
const entState = GetEntityState(ent);
// When this function is called during group rebuild, deleted
// entities will not yet have been removed, so entities might
@ -82,8 +82,7 @@ EntityGroups.prototype.add = function(ents)
if (!entState)
continue;
var templateName = entState.template;
var key = GetTemplateData(templateName, entState.player).selectionGroupName || templateName;
let key = entState.selectionGroupName || entState.templateName;
// Group the ents by player and template
if (entState.player !== undefined)
@ -235,7 +234,7 @@ EntitySelection.prototype.getTemplateNames = function()
{
const entState = GetEntityState(ent);
if (entState)
templateNames.push(entState.template);
templateNames.push(entState.templateName);
}
return templateNames;
};
@ -519,8 +518,8 @@ EntitySelection.prototype.addFormationMembers = function(entities)
for (const entity of entities)
{
const entState = GetEntityState(+entity);
if (entState?.unitAI?.formation)
for (const member of GetEntityState(+entState.unitAI.formation).formation.members)
if (entState?.unitAI?.formationController)
for (const member of GetEntityState(+entState.unitAI.formationController).formation.members)
result.add(member);
}

View file

@ -48,15 +48,13 @@ function updateGarrisonHealthBar(entState, selection)
// Fills out information that most entities have
function displaySingle(entState)
{
const template = GetTemplateData(entState.template);
const primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic;
const primaryName = g_SpecificNamesPrimary ? entState.name.specific : entState.name.generic;
let secondaryName;
if (g_ShowSecondaryNames)
secondaryName = g_SpecificNamesPrimary ? template.name.generic : template.name.specific;
secondaryName = g_SpecificNamesPrimary ? entState.name.generic : entState.name.specific;
// If packed, add that to the generic name (reduces template clutter).
if (template.pack && template.pack.state == "packed")
if (entState.pack?.state === "packed")
{
if (secondaryName && g_ShowSecondaryNames)
secondaryName = sprintf(translate("%(secondaryName)s — Packed"), { "secondaryName": secondaryName });
@ -75,13 +73,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
@ -327,8 +325,8 @@ function displaySingle(entState)
}
// TODO: we should require all entities to have icons
Engine.GetGUIObjectByName("icon").sprite = template.icon ? ("stretched:session/portraits/" + template.icon) : "BackgroundBlack";
if (template.icon)
Engine.GetGUIObjectByName("icon").sprite = entState.icon ? ("stretched:session/portraits/" + entState.icon) : "BackgroundBlack";
if (entState.icon)
{
const iconBorder = Engine.GetGUIObjectByName("iconBorder");
@ -339,7 +337,7 @@ function displaySingle(entState)
iconBorder.onPressRight = () =>
{
showTemplateDetails(entState.template, playerState.civ);
showTemplateDetails(entState.templateName, playerState.civ);
};
}
@ -373,7 +371,7 @@ function displaySingle(entState)
getAurasTooltip,
getEntityTooltip,
getTreasureTooltip
].map(func => func(template)));
].map(func => func(entState)));
const leftClickTooltip = hasClass(entState, "Unit") ? getFollowOnLeftClickTooltip() : getFocusOnLeftClickTooltip();
iconTooltips.push(leftClickTooltip + " " + getTemplateViewerOnRightClickTooltip());

View file

@ -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();
},
@ -343,14 +343,12 @@ g_SelectionPanels.Garrison = {
"setupButton": function(data)
{
const entState = GetEntityState(data.item.ents[0]);
const template = GetTemplateData(entState.template, entState.player);
if (!template)
if (!entState)
return false;
data.button.onPress = function()
{
unloadTemplate(template.selectionGroupName || entState.template, entState.player);
unloadTemplate(entState.selectionGroupName || entState.templateName, entState.player);
};
data.countDisplay.caption = data.item.ents.length > 1 ? data.item.ents.length : "";
@ -360,9 +358,9 @@ g_SelectionPanels.Garrison = {
data.button.enabled = canUngarrison;
data.button.tooltip = (canUngarrison ?
sprintf(translate("Unload %(name)s"), { "name": getEntityNames(template) }) + "\n" +
sprintf(translate("Unload %(name)s"), { "name": getEntityNames(entState) }) + "\n" +
translate("Single-click to unload 1. Shift-click to unload all of this type.") :
getEntityNames(template)) + "\n" +
getEntityNames(entState)) + "\n" +
sprintf(translate("Player: %(playername)s"), {
"playername": g_Players[entState.player].name
});
@ -374,7 +372,7 @@ g_SelectionPanels.Garrison = {
// also appear disabled to the owner of the structure.
data.icon.sprite =
(canUngarrison || g_IsObserver ? "" : "grayscale:") +
"stretched:session/portraits/" + template.icon;
"stretched:session/portraits/" + entState.icon;
setPanelObjectPosition(data.button, data.i, data.rowLength);
@ -1048,7 +1046,7 @@ g_SelectionPanels.Research = {
{
showTemplateDetails(
t,
GetTemplateData(baseData.unitEntStates.find(state => state.id == baseData.item.researchFacilityId).template, state.player).nativeCiv
baseData.unitEntStates.find(state => state.id == baseData.item.researchFacilityId).nativeCiv
);
});
@ -1114,8 +1112,7 @@ g_SelectionPanels.Selection = {
"setupButton": function(data)
{
const entState = GetEntityState(data.item.ents[0]);
const template = GetTemplateData(entState.template, entState.player);
if (!template)
if (!entState)
return false;
for (const ent of data.item.ents)
@ -1152,7 +1149,7 @@ g_SelectionPanels.Selection = {
}
const unitOwner = GetEntityState(data.item.ents[0]).player;
let tooltip = getEntityNames(template);
let tooltip = getEntityNames(entState);
if (data.carried)
tooltip += "\n" + Object.keys(data.carried).map(res =>
resourceIcon(res) + data.carried[res]
@ -1177,8 +1174,8 @@ g_SelectionPanels.Selection = {
};
data.button.onPressRight = function() { removeFromSelectionGroup(data.item.key); };
if (template.icon)
data.icon.sprite = "stretched:session/portraits/" + template.icon;
if (entState.icon)
data.icon.sprite = "stretched:session/portraits/" + entState.icon;
setPanelObjectPosition(data.button, data.i, data.rowLength);
return true;
@ -1192,17 +1189,18 @@ g_SelectionPanels.Stance = {
},
"getItems": function(unitEntStates)
{
if (unitEntStates.some(state => !state.unitAI || !hasClass(state, "Unit") || hasClass(state, "Animal")))
if (unitEntStates.some(state => !state.unitAI || !hasClass(state, "Unit") || hasClass(state, "Animal")) ||
unitEntStates.every(state => state.turretable && state.turretable.holder !== INVALID_ENTITY))
return [];
return unitEntStates[0].unitAI.selectableStances;
return Object.keys(this.stancesData);
},
"setupButton": function(data)
{
const unitIds = data.unitEntStates.map(state => state.id);
data.button.onPress = function() { performStance(unitIds, data.item); };
data.button.tooltip = getStanceDisplayName(data.item) + "\n" + bodyFont(getStanceTooltip(data.item));
data.button.tooltip = this.stancesData[data.item].Name + "\n" + bodyFont(this.stancesData[data.item].Tooltip);
data.guiSelection.hidden = !Engine.GuiInterfaceCall("IsStanceSelected", {
"ents": unitIds,
@ -1213,6 +1211,28 @@ g_SelectionPanels.Stance = {
setPanelObjectPosition(data.button, data.i, data.rowLength);
return true;
},
"stancesData": {
"violent": {
"Name": translateWithContext("stance", "Violent"),
"Tooltip": translateWithContext("stance", "Attack nearby opponents, focus on attackers and chase while visible")
},
"aggressive": {
"Name": translateWithContext("stance", "Aggressive"),
"Tooltip": translateWithContext("stance", "Attack nearby opponents")
},
"defensive": {
"Name": translateWithContext("stance", "Defensive"),
"Tooltip": translateWithContext("stance", "Attack nearby opponents, chase a short distance and return to the original location")
},
"passive": {
"Name": translateWithContext("stance", "Passive"),
"Tooltip": translateWithContext("stance", "Flee if attacked")
},
"standground": {
"Name": translateWithContext("stance", "Standground"),
"Tooltip": translateWithContext("stance", "Attack opponents in range, but don't move")
}
}
};
@ -1334,10 +1354,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 +1375,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 +1442,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:";

View file

@ -45,45 +45,6 @@ function resourcesToAlphaMask(neededResources)
return "color:255 0 0 " + Math.min(125, Math.round(+totalCost / 10) + 50);
}
function getStanceDisplayName(name)
{
switch (name)
{
case "violent":
return translateWithContext("stance", "Violent");
case "aggressive":
return translateWithContext("stance", "Aggressive");
case "defensive":
return translateWithContext("stance", "Defensive");
case "passive":
return translateWithContext("stance", "Passive");
case "standground":
return translateWithContext("stance", "Standground");
default:
warn("Internationalization: Unexpected stance found: " + name);
return name;
}
}
function getStanceTooltip(name)
{
switch (name)
{
case "violent":
return translateWithContext("stance", "Attack nearby opponents, focus on attackers and chase while visible");
case "aggressive":
return translateWithContext("stance", "Attack nearby opponents");
case "defensive":
return translateWithContext("stance", "Attack nearby opponents, chase a short distance and return to the original location");
case "passive":
return translateWithContext("stance", "Flee if attacked");
case "standground":
return translateWithContext("stance", "Attack opponents in range, but don't move");
default:
return "";
}
}
/**
* Format entity count/limit message for the tooltip
*/

View file

@ -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,15 +757,14 @@ function onTick()
function onSimulationUpdate()
{
g_EntityStates = {};
g_EntityStates.clear();
g_SimState = undefined;
Engine.ProfileStart("sdf");
const info = Engine.GuiInterfaceCall("GetAndClearTemplateChanges");
// Templates change depending on technologies and auras, so they have to be reloaded after such a change.
// g_TechnologyData data never changes, so it shouldn't be cleared.
if (info.playersWithModifiedTemplates)
for (const player of info.playersWithModifiedTemplates)
if (info.playersWithChangedModifications)
for (const player of info.playersWithChangedModifications)
// Player-global modifications changed (e.g. by a technology), so the template data cache needs to be cleared.
g_TemplateData[player] = {};
// Some changes require reloading some cached values even if the selection itself didn't change.
@ -680,7 +776,7 @@ function onSimulationUpdate()
if (!GetSimState())
return;
GetMultipleEntityStates(g_Selection.toList());
PreloadMultipleEntityStates(g_Selection.toList());
for (const handler of g_SimulationUpdateHandlers)
handler();
@ -728,8 +824,7 @@ function updateGroups()
// Determine the sum of the costs of a given template
const getCostSum = (ent) =>
{
const entState = GetEntityState(ent);
const cost = GetTemplateData(entState.template, entState.player).cost;
const cost = GetEntityState(ent).cost;
return cost ? Object.keys(cost).map(key => cost[key]).reduce((sum, cur) => sum + cur) : 0;
};
@ -746,12 +841,12 @@ function updateGroups()
// Choose the icon of the most common template (or the most costly if it's not unique)
if (g_Groups.groups[i].getTotalCount() > 0)
{
const icon = GetTemplateData(GetEntityState(g_Groups.groups[i].getEntsGrouped().reduce((pre, cur) =>
const icon = GetEntityState(g_Groups.groups[i].getEntsGrouped().reduce((pre, cur) =>
{
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]).icon;
Engine.GetGUIObjectByName("unitGroupIcon[" + i + "]").sprite =
icon ? ("stretched:session/portraits/" + icon) : "groupsIcon";

View file

@ -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";
@ -1250,7 +1250,7 @@ var g_UnitActions =
let trader;
if (entState.trainer?.entities?.length)
for (let i = 0; i < entState.trainer.entities.length; ++i)
if ((trader = GetTemplateData(entState.trainer.entities[i]).trader))
if ((trader = entState.trainer.entities[i].trader))
break;
const traderData = {
@ -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)

View file

@ -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)

View file

@ -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({

View file

@ -26,23 +26,6 @@ Auras.prototype.GetModifierIdentifier = function(name)
return "aura/" + name;
};
Auras.prototype.GetDescriptions = function()
{
var ret = {};
for (const auraID of this.GetAuraNames())
{
const aura = AuraTemplates.Get(auraID);
ret[auraID] = {
"name": {
"generic": aura.auraName
},
"description": aura.auraDescription || null,
"radius": this.GetRange(auraID) || null
};
}
return ret;
};
Auras.prototype.GetAuraNames = function()
{
return this.template._string.split(/\s+/);

View file

@ -36,8 +36,9 @@ GuiInterface.prototype.Init = function()
this.entsRallyPointsDisplayed = [];
this.entsWithAuraAndStatusBars = new Set();
this.enabledVisualRangeOverlayTypes = {};
this.playersWithModifiedTemplates = new Set();
this.playersWithChangedModifications = new Set();
this.playersWithDirtySelection = new Set();
this.entityStateRetriever = new EntityStateRetriever();
this.obstructionSnap = new ObstructionSnap();
};
@ -250,377 +251,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)
@ -641,12 +300,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,
@ -709,14 +362,15 @@ GuiInterface.prototype.GetNeededResources = function(player, data)
GuiInterface.prototype.OnTemplateModification = function(msg)
{
// The GUI caches templates, which could become out-of-date now, so tell it
// to reload all of them for that player.
// TODO: Allow the GUI to do that more selectively by passing which values
// of which components have changed and letting it only reload the affected
// ones.
this.playersWithModifiedTemplates.add(msg.player);
// The modifiers of a player changed. Inform the GUI and EntityStateRetriever
// that they need to reload all templates of that player.
// TODO: Allow them to do that more selectively by telling them exactly
// which values of which components changes and letting them only reload the
// actually affected ones.
this.playersWithChangedModifications.add(msg.player);
this.playersWithDirtySelection.add(msg.player);
this.entityStateRetriever.onPlayerModificationsChanged(msg.player);
};
GuiInterface.prototype.OnDisabledTemplatesChanged = function(msg)
@ -741,13 +395,13 @@ GuiInterface.prototype.SetSelectionDirty = function(player)
GuiInterface.prototype.GetAndClearTemplateChanges = function(player)
{
const ret = {};
if (this.playersWithModifiedTemplates.size)
ret.playersWithModifiedTemplates = new Set(this.playersWithModifiedTemplates);
if (this.playersWithChangedModifications.size)
ret.playersWithChangedModifications = new Set(this.playersWithChangedModifications);
if (this.playersWithDirtySelection.has(player))
ret.selectionDirty = true;
this.playersWithModifiedTemplates.clear();
this.playersWithChangedModifications.clear();
this.playersWithDirtySelection.clear();
return ret;
@ -1338,7 +992,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)
@ -2076,6 +1734,7 @@ GuiInterface.prototype.exposedFunctions = {
"GetCampaignGameEndData": 1,
"GetRenamedEntities": 1,
"ClearRenamedEntities": 1,
"ResetEntityStateRetriever": 1,
"GetEntityState": 1,
"GetMultipleEntityStates": 1,
"GetAverageRangeForBuildings": 1,

View file

@ -218,11 +218,6 @@ Identity.prototype.GetRank = function()
return this.template.Rank || "";
};
Identity.prototype.GetRankTechName = function()
{
return this.template.Rank ? "unit_" + this.template.Rank.toLowerCase() : "";
};
Identity.prototype.GetClassesList = function()
{
return this.classesList;

View file

@ -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].toSorted((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.

View file

@ -86,8 +86,7 @@ var g_Stances = {
"respondChase": true,
"respondChaseBeyondVision": true,
"respondStandGround": false,
"respondHoldGround": false,
"selectable": true
"respondHoldGround": false
},
"aggressive": {
"targetVisibleEnemies": true,
@ -97,8 +96,7 @@ var g_Stances = {
"respondChase": true,
"respondChaseBeyondVision": false,
"respondStandGround": false,
"respondHoldGround": false,
"selectable": true
"respondHoldGround": false
},
"defensive": {
"targetVisibleEnemies": true,
@ -108,8 +106,7 @@ var g_Stances = {
"respondChase": false,
"respondChaseBeyondVision": false,
"respondStandGround": false,
"respondHoldGround": true,
"selectable": true
"respondHoldGround": true
},
"passive": {
"targetVisibleEnemies": false,
@ -119,8 +116,7 @@ var g_Stances = {
"respondChase": false,
"respondChaseBeyondVision": false,
"respondStandGround": false,
"respondHoldGround": false,
"selectable": true
"respondHoldGround": false
},
"standground": {
"targetVisibleEnemies": true,
@ -130,8 +126,7 @@ var g_Stances = {
"respondChase": false,
"respondChaseBeyondVision": false,
"respondStandGround": true,
"respondHoldGround": false,
"selectable": true
"respondHoldGround": false
},
"skittish": {
"targetVisibleEnemies": false,
@ -141,8 +136,7 @@ var g_Stances = {
"respondChase": false,
"respondChaseBeyondVision": false,
"respondStandGround": false,
"respondHoldGround": false,
"selectable": false
"respondHoldGround": false
},
"passive-defensive": {
"targetVisibleEnemies": false,
@ -152,8 +146,7 @@ var g_Stances = {
"respondChase": false,
"respondChaseBeyondVision": false,
"respondStandGround": false,
"respondHoldGround": true,
"selectable": false
"respondHoldGround": true
},
"none": {
// Only to be used by AI or trigger scripts
@ -164,8 +157,7 @@ var g_Stances = {
"respondChase": false,
"respondChaseBeyondVision": false,
"respondStandGround": false,
"respondHoldGround": false,
"selectable": false
"respondHoldGround": false
}
};
@ -6665,13 +6657,6 @@ UnitAI.prototype.GetStance = function()
return g_Stances[this.stance];
};
UnitAI.prototype.GetSelectableStances = function()
{
if (this.IsTurret())
return [];
return Object.keys(g_Stances).filter(key => g_Stances[key].selectable);
};
UnitAI.prototype.GetStanceName = function()
{
return this.stance;

View file

@ -128,33 +128,6 @@ Upgrade.prototype.CanUpgradeTo = function(template)
return this.upgradeTemplates[template] !== undefined;
};
Upgrade.prototype.GetUpgrades = function()
{
const ret = [];
for (const option in this.upgradeTemplates)
{
const choice = this.template[this.upgradeTemplates[option]];
let cost = {};
if (choice.Cost)
cost = this.GetResourceCosts(option);
if (choice.Time)
cost.time = this.GetUpgradeTime(option);
const hasCost = choice.Cost || choice.Time;
ret.push({
"entity": option,
"icon": choice.Icon || undefined,
"cost": hasCost ? cost : undefined,
"tooltip": choice.Tooltip || undefined,
"requirements": this.GetRequirements(option),
});
}
return ret;
};
Upgrade.prototype.CancelTimer = function()
{
if (!this.timer)

View file

@ -0,0 +1,734 @@
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,
"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,
"isIdle": true,
"formationController": 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,
"isIdle": true,
"formationController": 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 }
});

View file

@ -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 }
}
});

View file

@ -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);
})();

View file

@ -141,15 +141,17 @@ 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 });
// T3: Check that the value is correct within the Upgrade Component.
const entity = template.Upgrade.Tower.Entity.replace("{civ}", civCode);
TS_ASSERT_UNEVAL_EQUALS(cmpUpgrade.GetResourceCosts(entity), { "stone": 100, "wood": 50 });
TS_ASSERT_EQUALS(cmpUpgrade.GetUpgradeTime(entity), 100);
/**
* Tell the Upgrade component to start the Upgrade,
@ -159,16 +161,17 @@ 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 });
TS_ASSERT_UNEVAL_EQUALS(cmpUpgrade.GetResourceCosts(entity), { "stone": 100, "wood": 50 });
TS_ASSERT_EQUALS(cmpUpgrade.GetUpgradeTime(entity), 90);
// T7: ...but with the upgrade cancelled, it now uses the modified value.
cmpUpgrade.CancelUpgrade(playerID);
TS_ASSERT_UNEVAL_EQUALS(cmpUpgrade.GetUpgrades()[0].cost, { "stone": 160, "wood": 25, "time": 90 });
TS_ASSERT_UNEVAL_EQUALS(cmpUpgrade.GetResourceCosts(entity), { "stone": 160, "wood": 25 });

View file

@ -0,0 +1,364 @@
/**
* 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(),
"isIdle": cmpUnitAI.IsIdle(),
"formationController": 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.GetMaxHitpoints();
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);