Index: ps/trunk/binaries/data/mods/public/globalscripts/Templates.js
===================================================================
--- ps/trunk/binaries/data/mods/public/globalscripts/Templates.js (revision 25190)
+++ ps/trunk/binaries/data/mods/public/globalscripts/Templates.js (revision 25191)
@@ -1,614 +1,624 @@
/**
* Loads history and gameplay data of all civs.
*
* @param selectableOnly {boolean} - Only load civs that can be selected
* in the gamesetup. Scenario maps might set non-selectable civs.
*/
function loadCivFiles(selectableOnly)
{
let propertyNames = [
"Code", "Culture", "Name", "Emblem", "History", "Music", "CivBonuses", "StartEntities",
"Formations", "AINames", "SkirmishReplacements", "SelectableInGameSetup"];
let civData = {};
for (let filename of Engine.ListDirectoryFiles("simulation/data/civs/", "*.json", false))
{
let data = Engine.ReadJSONFile(filename);
for (let prop of propertyNames)
if (data[prop] === undefined)
throw new Error(filename + " doesn't contain " + prop);
if (!selectableOnly || data.SelectableInGameSetup)
civData[data.Code] = data;
}
return civData;
}
/**
* @return {string[]} - All the classes for this identity template.
*/
function GetIdentityClasses(template)
{
let classString = "";
if (template.Classes && template.Classes._string)
classString += " " + template.Classes._string;
if (template.VisibleClasses && template.VisibleClasses._string)
classString += " " + template.VisibleClasses._string;
if (template.Rank)
classString += " " + template.Rank;
return classString.length > 1 ? classString.substring(1).split(" ") : [];
}
/**
* Gets an array with all classes for this identity template
* that should be shown in the GUI
*/
function GetVisibleIdentityClasses(template)
{
return template.VisibleClasses && template.VisibleClasses._string ? template.VisibleClasses._string.split(" ") : [];
}
/**
* Check if a given list of classes matches another list of classes.
* Useful f.e. for checking identity classes.
*
* @param classes - List of the classes to check against.
* @param match - Either a string in the form
* "Class1 Class2+Class3"
* where spaces are handled as OR and '+'-signs as AND,
* and ! is handled as NOT, thus Class1+!Class2 = Class1 AND NOT Class2.
* Or a list in the form
* [["Class1"], ["Class2", "Class3"]]
* where the outer list is combined as OR, and the inner lists are AND-ed.
* Or a hybrid format containing a list of strings, where the list is
* combined as OR, and the strings are split by space and '+' and AND-ed.
*
* @return undefined if there are no classes or no match object
* true if the the logical combination in the match object matches the classes
* false otherwise.
*/
function MatchesClassList(classes, match)
{
if (!match || !classes)
return undefined;
// Transform the string to an array
if (typeof match == "string")
match = match.split(/\s+/);
for (let sublist of match)
{
// If the elements are still strings, split them by space or by '+'
if (typeof sublist == "string")
sublist = sublist.split(/[+\s]+/);
if (sublist.every(c => (c[0] == "!" && classes.indexOf(c.substr(1)) == -1) ||
(c[0] != "!" && classes.indexOf(c) != -1)))
return true;
}
return false;
}
/**
* Gets the value originating at the value_path as-is, with no modifiers applied.
*
* @param {Object} template - A valid template as returned from a template loader.
* @param {string} value_path - Route to value within the xml template structure.
* @return {number}
*/
function GetBaseTemplateDataValue(template, value_path)
{
let current_value = template;
for (let property of value_path.split("/"))
current_value = current_value[property] || 0;
return +current_value;
}
/**
* Gets the value originating at the value_path with the modifiers dictated by the mod_key applied.
*
* @param {Object} template - A valid template as returned from a template loader.
* @param {string} value_path - Route to value within the xml template structure.
* @param {string} mod_key - Tech modification key, if different from value_path.
* @param {number} player - Optional player id.
* @param {Object} modifiers - Value modifiers from auto-researched techs, unit upgrades,
* etc. Optional as only used if no player id provided.
* @return {number} Modifier altered value.
*/
function GetModifiedTemplateDataValue(template, value_path, mod_key, player, modifiers={})
{
let current_value = GetBaseTemplateDataValue(template, value_path);
mod_key = mod_key || value_path;
if (player)
current_value = ApplyValueModificationsToTemplate(mod_key, current_value, player, template);
else if (modifiers && modifiers[mod_key])
current_value = GetTechModifiedProperty(modifiers[mod_key], GetIdentityClasses(template.Identity), current_value);
// Using .toFixed() to get around spidermonkey's treatment of numbers (3 * 1.1 = 3.3000000000000003 for instance).
return +current_value.toFixed(8);
}
/**
* Get information about a template with or without technology modifications.
*
* NOTICE: The data returned here should have the same structure as
* the object returned by GetEntityState and GetExtendedEntityState!
*
* @param {Object} template - A valid template as returned by the template loader.
* @param {number} player - An optional player id to get the technology modifications
* of properties.
* @param {Object} auraTemplates - In the form of { key: { "auraName": "", "auraDescription": "" } }.
* @param {Object} modifiers - Modifications from auto-researched techs, unit upgrades
* etc. Optional as only used if there's no player
* id provided.
*/
function GetTemplateDataHelper(template, player, auraTemplates, modifiers = {})
{
// Return data either from template (in tech tree) or sim state (ingame).
// @param {string} value_path - Route to the value within the template.
// @param {string} mod_key - Modification key, if not the same as the value_path.
let getEntityValue = function(value_path, mod_key) {
return GetModifiedTemplateDataValue(template, value_path, mod_key, player, modifiers);
};
let ret = {};
if (template.Resistance)
{
// Don't show Foundation resistance.
ret.resistance = {};
if (template.Resistance.Entity)
{
if (template.Resistance.Entity.Damage)
{
ret.resistance.Damage = {};
for (let damageType in template.Resistance.Entity.Damage)
ret.resistance.Damage[damageType] = getEntityValue("Resistance/Entity/Damage/" + damageType);
}
if (template.Resistance.Entity.Capture)
ret.resistance.Capture = getEntityValue("Resistance/Entity/Capture");
if (template.Resistance.Entity.ApplyStatus)
{
ret.resistance.ApplyStatus = {};
for (let statusEffect in template.Resistance.Entity.ApplyStatus)
ret.resistance.ApplyStatus[statusEffect] = {
"blockChance": getEntityValue("Resistance/Entity/ApplyStatus/" + statusEffect + "/BlockChance"),
"duration": getEntityValue("Resistance/Entity/ApplyStatus/" + statusEffect + "/Duration")
};
}
}
}
let getAttackEffects = (temp, path) => {
let effects = {};
if (temp.Capture)
effects.Capture = getEntityValue(path + "/Capture");
if (temp.Damage)
{
effects.Damage = {};
for (let damageType in temp.Damage)
effects.Damage[damageType] = getEntityValue(path + "/Damage/" + damageType);
}
if (temp.ApplyStatus)
effects.ApplyStatus = temp.ApplyStatus;
return effects;
};
if (template.Attack)
{
ret.attack = {};
for (let type in template.Attack)
{
let getAttackStat = function(stat) {
return getEntityValue("Attack/" + type + "/" + stat);
};
ret.attack[type] = {
"attackName": {
"name": template.Attack[type].AttackName._string || template.Attack[type].AttackName,
"context": template.Attack[type].AttackName["@context"]
},
"minRange": getAttackStat("MinRange"),
"maxRange": getAttackStat("MaxRange"),
"elevationBonus": getAttackStat("ElevationBonus")
};
ret.attack[type].elevationAdaptedRange = Math.sqrt(ret.attack[type].maxRange *
(2 * ret.attack[type].elevationBonus + ret.attack[type].maxRange));
ret.attack[type].repeatTime = getAttackStat("RepeatTime");
if (template.Attack[type].Projectile)
ret.attack[type].friendlyFire = template.Attack[type].Projectile.FriendlyFire == "true";
Object.assign(ret.attack[type], getAttackEffects(template.Attack[type], "Attack/" + type));
if (template.Attack[type].Splash)
{
ret.attack[type].splash = {
"friendlyFire": template.Attack[type].Splash.FriendlyFire != "false",
"shape": template.Attack[type].Splash.Shape,
};
Object.assign(ret.attack[type].splash, getAttackEffects(template.Attack[type].Splash, "Attack/" + type + "/Splash"));
}
}
}
if (template.DeathDamage)
{
ret.deathDamage = {
"friendlyFire": template.DeathDamage.FriendlyFire != "false",
};
Object.assign(ret.deathDamage, getAttackEffects(template.DeathDamage, "DeathDamage"));
}
if (template.Auras && auraTemplates)
{
ret.auras = {};
for (let auraID of template.Auras._string.split(/\s+/))
ret.auras[auraID] = GetAuraDataHelper(auraTemplates[auraID]);
}
if (template.BuildingAI)
ret.buildingAI = {
"defaultArrowCount": Math.round(getEntityValue("BuildingAI/DefaultArrowCount")),
"garrisonArrowMultiplier": getEntityValue("BuildingAI/GarrisonArrowMultiplier"),
"maxArrowCount": Math.round(getEntityValue("BuildingAI/MaxArrowCount"))
};
if (template.BuildRestrictions)
{
// required properties
ret.buildRestrictions = {
"placementType": template.BuildRestrictions.PlacementType,
"territory": template.BuildRestrictions.Territory,
"category": template.BuildRestrictions.Category,
};
// optional properties
if (template.BuildRestrictions.Distance)
{
ret.buildRestrictions.distance = {
"fromClass": template.BuildRestrictions.Distance.FromClass,
};
if (template.BuildRestrictions.Distance.MinDistance)
ret.buildRestrictions.distance.min = getEntityValue("BuildRestrictions/Distance/MinDistance");
if (template.BuildRestrictions.Distance.MaxDistance)
ret.buildRestrictions.distance.max = getEntityValue("BuildRestrictions/Distance/MaxDistance");
}
}
if (template.TrainingRestrictions)
{
ret.trainingRestrictions = {
"category": template.TrainingRestrictions.Category
};
if (template.TrainingRestrictions.MatchLimit)
ret.trainingRestrictions.matchLimit = +template.TrainingRestrictions.MatchLimit;
}
if (template.Cost)
{
ret.cost = {};
for (let resCode in template.Cost.Resources)
ret.cost[resCode] = getEntityValue("Cost/Resources/" + resCode);
if (template.Cost.Population)
ret.cost.population = getEntityValue("Cost/Population");
if (template.Cost.BuildTime)
ret.cost.time = getEntityValue("Cost/BuildTime");
}
if (template.Footprint)
{
ret.footprint = { "height": template.Footprint.Height };
if (template.Footprint.Square)
ret.footprint.square = {
"width": +template.Footprint.Square["@width"],
"depth": +template.Footprint.Square["@depth"]
};
else if (template.Footprint.Circle)
ret.footprint.circle = { "radius": +template.Footprint.Circle["@radius"] };
else
warn("GetTemplateDataHelper(): Unrecognized Footprint type");
}
if (template.Garrisonable)
ret.garrisonable = {
"size": getEntityValue("Garrisonable/Size")
};
if (template.GarrisonHolder)
{
ret.garrisonHolder = {
"buffHeal": getEntityValue("GarrisonHolder/BuffHeal")
};
if (template.GarrisonHolder.Max)
ret.garrisonHolder.capacity = getEntityValue("GarrisonHolder/Max");
}
if (template.Heal)
ret.heal = {
"health": getEntityValue("Heal/Health"),
"range": getEntityValue("Heal/Range"),
"interval": getEntityValue("Heal/Interval")
};
if (template.ResourceGatherer)
{
ret.resourceGatherRates = {};
let baseSpeed = getEntityValue("ResourceGatherer/BaseSpeed");
for (let type in template.ResourceGatherer.Rates)
ret.resourceGatherRates[type] = getEntityValue("ResourceGatherer/Rates/"+ type) * baseSpeed;
}
if (template.ResourceDropsite)
ret.resourceDropsite = {
"types": template.ResourceDropsite.Types.split(" ")
};
if (template.ResourceTrickle)
{
ret.resourceTrickle = {
"interval": +template.ResourceTrickle.Interval,
"rates": {}
};
for (let type in template.ResourceTrickle.Rates)
ret.resourceTrickle.rates[type] = getEntityValue("ResourceTrickle/Rates/" + type);
}
if (template.Loot)
{
ret.loot = {};
for (let type in template.Loot)
ret.loot[type] = getEntityValue("Loot/"+ type);
}
if (template.Obstruction)
{
ret.obstruction = {
"active": ("" + template.Obstruction.Active == "true"),
"blockMovement": ("" + template.Obstruction.BlockMovement == "true"),
"blockPathfinding": ("" + template.Obstruction.BlockPathfinding == "true"),
"blockFoundation": ("" + template.Obstruction.BlockFoundation == "true"),
"blockConstruction": ("" + template.Obstruction.BlockConstruction == "true"),
"disableBlockMovement": ("" + template.Obstruction.DisableBlockMovement == "true"),
"disableBlockPathfinding": ("" + template.Obstruction.DisableBlockPathfinding == "true"),
"shape": {}
};
if (template.Obstruction.Static)
{
ret.obstruction.shape.type = "static";
ret.obstruction.shape.width = +template.Obstruction.Static["@width"];
ret.obstruction.shape.depth = +template.Obstruction.Static["@depth"];
}
else if (template.Obstruction.Unit)
{
ret.obstruction.shape.type = "unit";
ret.obstruction.shape.radius = +template.Obstruction.Unit["@radius"];
}
else
ret.obstruction.shape.type = "cluster";
}
if (template.Pack)
ret.pack = {
"state": template.Pack.State,
"time": getEntityValue("Pack/Time"),
};
if (template.Population && template.Population.Bonus)
ret.population = {
"bonus": getEntityValue("Population/Bonus")
};
if (template.Health)
ret.health = Math.round(getEntityValue("Health/Max"));
if (template.Identity)
{
ret.selectionGroupName = template.Identity.SelectionGroupName;
ret.name = {
"specific": (template.Identity.SpecificName || template.Identity.GenericName),
"generic": template.Identity.GenericName
};
ret.icon = template.Identity.Icon;
ret.tooltip = template.Identity.Tooltip;
ret.requiredTechnology = template.Identity.RequiredTechnology;
ret.visibleIdentityClasses = GetVisibleIdentityClasses(template.Identity);
ret.nativeCiv = template.Identity.Civ;
}
if (template.UnitMotion)
{
ret.speed = {
"walk": getEntityValue("UnitMotion/WalkSpeed"),
};
ret.speed.run = getEntityValue("UnitMotion/WalkSpeed");
if (template.UnitMotion.RunMultiplier)
ret.speed.run *= getEntityValue("UnitMotion/RunMultiplier");
}
if (template.Upgrade)
{
ret.upgrades = [];
for (let upgradeName in template.Upgrade)
{
let upgrade = template.Upgrade[upgradeName];
let cost = {};
if (upgrade.Cost)
for (let res in upgrade.Cost)
cost[res] = getEntityValue("Upgrade/" + upgradeName + "/Cost/" + res, "Upgrade/Cost/" + res);
if (upgrade.Time)
cost.time = getEntityValue("Upgrade/" + upgradeName + "/Time", "Upgrade/Time");
ret.upgrades.push({
"entity": upgrade.Entity,
"tooltip": upgrade.Tooltip,
"cost": cost,
"icon": upgrade.Icon || undefined,
"requiredTechnology": upgrade.RequiredTechnology || undefined
});
}
}
if (template.ProductionQueue)
{
ret.techCostMultiplier = {};
for (let res in template.ProductionQueue.TechCostMultiplier)
ret.techCostMultiplier[res] = getEntityValue("ProductionQueue/TechCostMultiplier/" + res);
}
if (template.Trader)
ret.trader = {
"GainMultiplier": getEntityValue("Trader/GainMultiplier")
};
if (template.Treasure)
{
ret.treasure = {
"collectTime": getEntityValue("Treasure/CollectTime"),
"resources": {}
};
for (let resource in template.Treasure.Resources)
ret.treasure.resources[resource] = getEntityValue("Treasure/Resources/" + resource);
}
if (template.TurretHolder)
ret.turretHolder = {
"turretPoints": template.TurretHolder.TurretPoints
};
+ if (template.Upkeep)
+ {
+ ret.upkeep = {
+ "interval": +template.Upkeep.Interval,
+ "rates": {}
+ };
+ for (let type in template.Upkeep.Rates)
+ ret.upkeep.rates[type] = getEntityValue("Upkeep/Rates/" + type);
+ }
+
if (template.WallSet)
{
ret.wallSet = {
"templates": {
"tower": template.WallSet.Templates.Tower,
"gate": template.WallSet.Templates.Gate,
"fort": template.WallSet.Templates.Fort || "structures/" + template.Identity.Civ + "/fortress",
"long": template.WallSet.Templates.WallLong,
"medium": template.WallSet.Templates.WallMedium,
"short": template.WallSet.Templates.WallShort
},
"maxTowerOverlap": +template.WallSet.MaxTowerOverlap,
"minTowerOverlap": +template.WallSet.MinTowerOverlap
};
if (template.WallSet.Templates.WallEnd)
ret.wallSet.templates.end = template.WallSet.Templates.WallEnd;
if (template.WallSet.Templates.WallCurves)
ret.wallSet.templates.curves = template.WallSet.Templates.WallCurves.split(/\s+/);
}
if (template.WallPiece)
ret.wallPiece = {
"length": +template.WallPiece.Length,
"angle": +(template.WallPiece.Orientation || 1) * Math.PI,
"indent": +(template.WallPiece.Indent || 0),
"bend": +(template.WallPiece.Bend || 0) * Math.PI
};
return ret;
}
/**
* Get basic information about a technology template.
* @param {Object} template - A valid template as obtained by loading the tech JSON file.
* @param {string} civ - Civilization for which the tech requirements should be calculated.
*/
function GetTechnologyBasicDataHelper(template, civ)
{
return {
"name": {
"generic": template.genericName
},
"icon": template.icon ? "technologies/" + template.icon : undefined,
"description": template.description,
"reqs": DeriveTechnologyRequirements(template, civ),
"modifications": template.modifications,
"affects": template.affects,
"replaces": template.replaces
};
}
/**
* Get information about a technology template.
* @param {Object} template - A valid template as obtained by loading the tech JSON file.
* @param {string} civ - Civilization for which the specific name and tech requirements should be returned.
*/
function GetTechnologyDataHelper(template, civ, resources)
{
let ret = GetTechnologyBasicDataHelper(template, civ);
if (template.specificName)
ret.name.specific = template.specificName[civ] || template.specificName.generic;
ret.cost = { "time": template.researchTime ? +template.researchTime : 0 };
for (let type of resources.GetCodes())
ret.cost[type] = +(template.cost && template.cost[type] || 0);
ret.tooltip = template.tooltip;
ret.requirementsTooltip = template.requirementsTooltip || "";
return ret;
}
/**
* Get information about an aura template.
* @param {object} template - A valid template as obtained by loading the aura JSON file.
*/
function GetAuraDataHelper(template)
{
return {
"name": {
"generic": template.auraName,
},
"description": template.auraDescription || null,
"modifications": template.modifications,
"radius": template.radius || null,
};
}
function calculateCarriedResources(carriedResources, tradingGoods)
{
var resources = {};
if (carriedResources)
for (let resource of carriedResources)
resources[resource.type] = (resources[resource.type] || 0) + resource.amount;
if (tradingGoods && tradingGoods.amount)
resources[tradingGoods.type] =
(resources[tradingGoods.type] || 0) +
(tradingGoods.amount.traderGain || 0) +
(tradingGoods.amount.market1Gain || 0) +
(tradingGoods.amount.market2Gain || 0);
return resources;
}
/**
* Remove filter prefix (mirage, corpse, etc) from template name.
*
* ie. filter|dir/to/template -> dir/to/template
*/
function removeFiltersFromTemplateName(templateName)
{
return templateName.split("|").pop();
}
Index: ps/trunk/binaries/data/mods/public/gui/common/tooltips.js
===================================================================
--- ps/trunk/binaries/data/mods/public/gui/common/tooltips.js (revision 25190)
+++ ps/trunk/binaries/data/mods/public/gui/common/tooltips.js (revision 25191)
@@ -1,1188 +1,1212 @@
var g_TooltipTextFormats = {
"unit": { "font": "sans-10", "color": "orange" },
"header": { "font": "sans-bold-13" },
"body": { "font": "sans-13" },
"comma": { "font": "sans-12" },
"namePrimaryBig": { "font": "sans-bold-16" },
"namePrimarySmall": { "font": "sans-bold-12" },
"nameSecondary": { "font": "sans-bold-16" }
};
var g_SpecificNamesPrimary = Engine.ConfigDB_GetValue("user", "gui.session.howtoshownames") == 0 || Engine.ConfigDB_GetValue("user", "gui.session.howtoshownames") == 2;
var g_ShowSecondaryNames = Engine.ConfigDB_GetValue("user", "gui.session.howtoshownames") == 0 || Engine.ConfigDB_GetValue("user", "gui.session.howtoshownames") == 1;
function initDisplayedNames()
{
registerConfigChangeHandler(changes => {
if (changes.has("gui.session.howtoshownames"))
updateDisplayedNames();
});
}
/**
* String of four spaces to be used as indentation in gui strings.
*/
var g_Indent = " ";
var g_DamageTypesMetadata = new DamageTypesMetadata();
var g_StatusEffectsMetadata = new StatusEffectsMetadata();
/**
* If true, always shows whether the splash damage deals friendly fire.
* Otherwise display the friendly fire tooltip only if it does.
*/
var g_AlwaysDisplayFriendlyFire = false;
function getCostTypes()
{
return g_ResourceData.GetCodes().concat(["population", "time"]);
}
function resourceIcon(resource)
{
return '[icon="icon_' + resource + '"]';
}
function resourceNameFirstWord(type)
{
return translateWithContext("firstWord", g_ResourceData.GetNames()[type]);
}
function resourceNameWithinSentence(type)
{
return translateWithContext("withinSentence", g_ResourceData.GetNames()[type]);
}
/**
* Format resource amounts to proper english and translate (for example: "200 food, 100 wood and 300 metal").
*/
function getLocalizedResourceAmounts(resources)
{
let amounts = g_ResourceData.GetCodes()
.filter(type => !!resources[type])
.map(type => sprintf(translate("%(amount)s %(resourceType)s"), {
"amount": resources[type],
"resourceType": resourceNameWithinSentence(type)
}));
if (amounts.length < 2)
return amounts.join();
let lastAmount = amounts.pop();
return sprintf(translate("%(previousAmounts)s and %(lastAmount)s"), {
// Translation: This comma is used for separating first to penultimate elements in an enumeration.
"previousAmounts": amounts.join(translate(", ")),
"lastAmount": lastAmount
});
}
function bodyFont(text)
{
return setStringTags(text, g_TooltipTextFormats.body);
}
function headerFont(text)
{
return setStringTags(text, g_TooltipTextFormats.header);
}
function unitFont(text)
{
return setStringTags(text, g_TooltipTextFormats.unit);
}
function commaFont(text)
{
return setStringTags(text, g_TooltipTextFormats.comma);
}
function getSecondsString(seconds)
{
return sprintf(translatePlural("%(time)s %(second)s", "%(time)s %(second)s", seconds), {
"time": seconds,
"second": unitFont(translatePlural("second", "seconds", seconds))
});
}
/**
* Entity templates have a `Tooltip` tag in the Identity component.
* (The contents of which are copied to a `tooltip` attribute in globalscripts.)
*
* Technologies have a `tooltip` attribute.
*/
function getEntityTooltip(template)
{
if (!template.tooltip)
return "";
return bodyFont(template.tooltip);
}
/**
* Technologies have a `description` attribute, and Auras have an `auraDescription`
* attribute, which becomes `description`.
*
* (For technologies, this happens in globalscripts.)
*
* (For auras, this happens either in the Auras component (for session gui) or
* reference/common/load.js (for Reference Suite gui))
*/
function getDescriptionTooltip(template)
{
if (!template.description)
return "";
return bodyFont(template.description);
}
/**
* Entity templates have a `History` tag in the Identity component.
* (The contents of which are copied to a `history` attribute in globalscripts.)
*/
function getHistoryTooltip(template)
{
if (!template.history)
return "";
return bodyFont(template.history);
}
function getHealthTooltip(template)
{
if (!template.health)
return "";
return sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Health:")),
"details": template.health
});
}
function getCurrentHealthTooltip(entState, label)
{
if (!entState.maxHitpoints)
return "";
return sprintf(translate("%(healthLabel)s %(current)s / %(max)s"), {
"healthLabel": headerFont(label || translate("Health:")),
"current": Math.round(entState.hitpoints),
"max": Math.round(entState.maxHitpoints)
});
}
function getCurrentCaptureTooltip(entState, label)
{
if (!entState.maxCapturePoints)
return "";
return sprintf(translate("%(captureLabel)s %(current)s / %(max)s"), {
"captureLabel": headerFont(label || translate("Capture points:")),
"current": Math.round(entState.capturePoints[entState.player]),
"max": Math.round(entState.maxCapturePoints)
});
}
/**
* Converts an resistance level into the actual reduction percentage.
*/
function resistanceLevelToPercentageString(level)
{
return sprintf(translate("%(percentage)s%%"), {
"percentage": (100 - Math.round(Math.pow(0.9, level) * 100))
});
}
function getResistanceTooltip(template)
{
if (!template.resistance)
return "";
let details = [];
if (template.resistance.Damage)
details.push(getDamageResistanceTooltip(template.resistance.Damage));
if (template.resistance.Capture)
details.push(getCaptureResistanceTooltip(template.resistance.Capture));
if (template.resistance.ApplyStatus)
details.push(getStatusEffectsResistanceTooltip(template.resistance.ApplyStatus));
return details.length ? sprintf(translate("%(label)s\n%(details)s"), {
"label": headerFont(translate("Resistance:")),
"details": g_Indent + details.join("\n" + g_Indent)
}) : "";
}
function getDamageResistanceTooltip(resistanceTypeTemplate)
{
if (!resistanceTypeTemplate)
return "";
return sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Damage:")),
"details":
g_DamageTypesMetadata.sort(Object.keys(resistanceTypeTemplate)).map(
dmgType => sprintf(translate("%(damage)s %(damageType)s %(resistancePercentage)s"), {
"damage": resistanceTypeTemplate[dmgType].toFixed(1),
"damageType": unitFont(translateWithContext("damage type", g_DamageTypesMetadata.getName(dmgType))),
"resistancePercentage":
'[font="sans-10"]' +
sprintf(translate("(%(resistancePercentage)s)"), {
"resistancePercentage": resistanceLevelToPercentageString(resistanceTypeTemplate[dmgType])
}) + '[/font]'
})
).join(commaFont(translate(", ")))
});
}
function getCaptureResistanceTooltip(resistanceTypeTemplate)
{
if (!resistanceTypeTemplate)
return "";
return sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Capture:")),
"details":
sprintf(translate("%(damage)s %(damageType)s %(resistancePercentage)s"), {
"damage": resistanceTypeTemplate.toFixed(1),
"damageType": unitFont(translateWithContext("damage type", "Capture")),
"resistancePercentage":
'[font="sans-10"]' +
sprintf(translate("(%(resistancePercentage)s)"), {
"resistancePercentage": resistanceLevelToPercentageString(resistanceTypeTemplate)
}) + '[/font]'
})
});
}
function getStatusEffectsResistanceTooltip(resistanceTypeTemplate)
{
if (!resistanceTypeTemplate)
return "";
return sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Status Effects:")),
"details":
Object.keys(resistanceTypeTemplate).map(
statusEffect => {
if (resistanceTypeTemplate[statusEffect].blockChance == 1)
return sprintf(translate("Blocks %(name)s"), {
"name": unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(statusEffect)))
});
if (resistanceTypeTemplate[statusEffect].blockChance == 0)
return sprintf(translate("%(name)s %(details)s"), {
"name": unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(statusEffect))),
"details": sprintf(translate("Duration reduction: %(durationReduction)s%%"), {
"durationReduction": (100 - resistanceTypeTemplate[statusEffect].duration * 100)
})
});
if (resistanceTypeTemplate[statusEffect].duration == 1)
return sprintf(translate("%(name)s %(details)s"), {
"name": unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(statusEffect))),
"details": sprintf(translate("Blocks: %(blockPercentage)s%%"), {
"blockPercentage": resistanceTypeTemplate[statusEffect].blockChance * 100
})
});
return sprintf(translate("%(name)s %(details)s"), {
"name": unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(statusEffect))),
"details": sprintf(translate("Blocks: %(blockPercentage)s%%, Duration reduction: %(durationReduction)s%%"), {
"blockPercentage": resistanceTypeTemplate[statusEffect].blockChance * 100,
"durationReduction": (100 - resistanceTypeTemplate[statusEffect].duration * 100)
})
});
}
).join(commaFont(translate(", ")))
});
}
function attackRateDetails(interval, projectiles)
{
if (!interval)
return "";
if (projectiles === 0)
return translate("Garrison to fire arrows");
let attackRateString = getSecondsString(interval / 1000);
let header = headerFont(translate("Interval:"));
if (projectiles && +projectiles > 1)
{
header = headerFont(translate("Rate:"));
let projectileString = sprintf(translatePlural("%(projectileCount)s %(projectileName)s", "%(projectileCount)s %(projectileName)s", projectiles), {
"projectileCount": projectiles,
"projectileName": unitFont(translatePlural("arrow", "arrows", projectiles))
});
attackRateString = sprintf(translate("%(projectileString)s / %(attackRateString)s"), {
"projectileString": projectileString,
"attackRateString": attackRateString
});
}
return sprintf(translate("%(label)s %(details)s"), {
"label": header,
"details": attackRateString
});
}
function rangeDetails(attackTypeTemplate)
{
if (!attackTypeTemplate.maxRange)
return "";
let rangeTooltipString = {
"relative": {
// Translation: For example: Range: 2 to 10 (+2) meters
"minRange": translate("%(rangeLabel)s %(minRange)s to %(maxRange)s (%(relativeRange)s) %(rangeUnit)s"),
// Translation: For example: Range: 10 (+2) meters
"no-minRange": translate("%(rangeLabel)s %(maxRange)s (%(relativeRange)s) %(rangeUnit)s"),
},
"non-relative": {
// Translation: For example: Range: 2 to 10 meters
"minRange": translate("%(rangeLabel)s %(minRange)s to %(maxRange)s %(rangeUnit)s"),
// Translation: For example: Range: 10 meters
"no-minRange": translate("%(rangeLabel)s %(maxRange)s %(rangeUnit)s"),
}
};
let minRange = Math.round(attackTypeTemplate.minRange);
let maxRange = Math.round(attackTypeTemplate.maxRange);
let realRange = attackTypeTemplate.elevationAdaptedRange;
let relativeRange = realRange ? Math.round(realRange - maxRange) : 0;
return sprintf(rangeTooltipString[relativeRange ? "relative" : "non-relative"][minRange ? "minRange" : "no-minRange"], {
"rangeLabel": headerFont(translate("Range:")),
"minRange": minRange,
"maxRange": maxRange,
"relativeRange": relativeRange > 0 ? sprintf(translate("+%(number)s"), { "number": relativeRange }) : relativeRange,
"rangeUnit":
unitFont(minRange || relativeRange ?
// Translation: For example "0.5 to 1 meters", "1 (+1) meters" or "1 to 2 (+3) meters"
translate("meters") :
translatePlural("meter", "meters", maxRange))
});
}
function damageDetails(damageTemplate)
{
if (!damageTemplate)
return "";
return g_DamageTypesMetadata.sort(Object.keys(damageTemplate).filter(dmgType => damageTemplate[dmgType])).map(
dmgType => sprintf(translate("%(damage)s %(damageType)s"), {
"damage": (+damageTemplate[dmgType]).toFixed(1),
"damageType": unitFont(translateWithContext("damage type", g_DamageTypesMetadata.getName(dmgType)))
})).join(commaFont(translate(", ")));
}
function captureDetails(captureTemplate)
{
if (!captureTemplate)
return "";
return sprintf(translate("%(amount)s %(name)s"), {
"amount": (+captureTemplate).toFixed(1),
"name": unitFont(translateWithContext("damage type", "Capture"))
});
}
function splashDetails(splashTemplate)
{
let splashLabel = sprintf(headerFont(translate("%(splashShape)s Splash")), {
"splashShape": splashTemplate.shape
});
let splashDamageTooltip = sprintf(translate("%(label)s: %(effects)s"), {
"label": splashLabel,
"effects": attackEffectsDetails(splashTemplate)
});
if (g_AlwaysDisplayFriendlyFire || splashTemplate.friendlyFire)
splashDamageTooltip += commaFont(translate(", ")) + sprintf(translate("Friendly Fire: %(enabled)s"), {
"enabled": splashTemplate.friendlyFire ? translate("Yes") : translate("No")
});
return splashDamageTooltip;
}
function applyStatusDetails(applyStatusTemplate)
{
if (!applyStatusTemplate)
return "";
return sprintf(translate("gives %(name)s"), {
"name": Object.keys(applyStatusTemplate).map(x =>
unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(x)))
).join(commaFont(translate(", "))),
});
}
function attackEffectsDetails(attackTypeTemplate)
{
if (!attackTypeTemplate)
return "";
let effects = [
captureDetails(attackTypeTemplate.Capture || undefined),
damageDetails(attackTypeTemplate.Damage || undefined),
applyStatusDetails(attackTypeTemplate.ApplyStatus || undefined)
];
return effects.filter(effect => effect).join(commaFont(translate(", ")));
}
function getAttackTooltip(template)
{
if (!template.attack)
return "";
let tooltips = [];
for (let attackType in template.attack)
{
// Slaughter is used to kill animals, so do not show it.
if (attackType == "Slaughter")
continue;
let attackTypeTemplate = template.attack[attackType];
let attackLabel = sprintf(headerFont(translate("%(attackType)s")), {
"attackType": translateWithContext(attackTypeTemplate.attackName.context || "Name of an attack, usually the weapon.", attackTypeTemplate.attackName.name)
});
let projectiles;
// Use either current rate from simulation or default count if the sim is not running.
// TODO: This ought to be extended to include units which fire multiple projectiles.
if (template.buildingAI)
projectiles = template.buildingAI.arrowCount || template.buildingAI.defaultArrowCount;
let splashTemplate = attackTypeTemplate.splash;
// Show the effects of status effects below.
let statusEffectsDetails = [];
if (attackTypeTemplate.ApplyStatus)
for (let status in attackTypeTemplate.ApplyStatus)
statusEffectsDetails.push("\n" + g_Indent + g_Indent + getStatusEffectsTooltip(status, attackTypeTemplate.ApplyStatus[status], true));
statusEffectsDetails = statusEffectsDetails.join("");
tooltips.push(sprintf(translate("%(attackLabel)s: %(effects)s, %(range)s, %(rate)s%(statusEffects)s%(splash)s"), {
"attackLabel": attackLabel,
"effects": attackEffectsDetails(attackTypeTemplate),
"range": rangeDetails(attackTypeTemplate),
"rate": attackRateDetails(attackTypeTemplate.repeatTime, projectiles),
"splash": splashTemplate ? "\n" + g_Indent + g_Indent + splashDetails(splashTemplate) : "",
"statusEffects": statusEffectsDetails
}));
}
return sprintf(translate("%(label)s\n%(details)s"), {
"label": headerFont(translate("Attack:")),
"details": g_Indent + tooltips.join("\n" + g_Indent)
});
}
/**
* @param applier - if true, return the tooltip for the Applier. If false, Receiver is returned.
*/
function getStatusEffectsTooltip(statusCode, template, applier)
{
let tooltipAttributes = [];
let statusData = g_StatusEffectsMetadata.getData(statusCode);
if (template.Damage || template.Capture)
tooltipAttributes.push(attackEffectsDetails(template));
if (template.Interval)
tooltipAttributes.push(attackRateDetails(+template.Interval));
if (template.Duration)
tooltipAttributes.push(getStatusEffectDurationTooltip(template));
if (applier && statusData.applierTooltip)
tooltipAttributes.push(translateWithContext("status effect", statusData.applierTooltip));
else if (!applier && statusData.receiverTooltip)
tooltipAttributes.push(translateWithContext("status effect", statusData.receiverTooltip));
if (applier)
return sprintf(translate("%(statusName)s: %(statusInfo)s %(stackability)s"), {
"statusName": headerFont(translateWithContext("status effect", statusData.statusName)),
"statusInfo": tooltipAttributes.join(commaFont(translate(", "))),
"stackability": getStatusEffectStackabilityTooltip(template)
});
return sprintf(translate("%(statusName)s: %(statusInfo)s"), {
"statusName": headerFont(translateWithContext("status effect", statusData.statusName)),
"statusInfo": tooltipAttributes.join(commaFont(translate(", ")))
});
}
function getStatusEffectDurationTooltip(template)
{
if (!template.Duration)
return "";
return sprintf(translate("%(durName)s: %(duration)s"), {
"durName": headerFont(translate("Duration")),
"duration": getSecondsString((template._timeElapsed ?
+template.Duration - template._timeElapsed :
+template.Duration) / 1000)
});
}
function getStatusEffectStackabilityTooltip(template)
{
if (!template.Stackability || template.Stackability == "Ignore")
return "";
let stackabilityString = "";
if (template.Stackability === "Extend")
stackabilityString = translateWithContext("status effect stackability", "(extends)");
else if (template.Stackability === "Replace")
stackabilityString = translateWithContext("status effect stackability", "(replaces)");
else if (template.Stackability === "Stack")
stackabilityString = translateWithContext("status effect stackability", "(stacks)");
return sprintf(translate("%(stackability)s"), {
"stackability": stackabilityString
});
}
function getGarrisonTooltip(template)
{
let tooltips = [];
if (template.garrisonHolder)
{
tooltips.push (
sprintf(translate("%(label)s: %(garrisonLimit)s"), {
"label": headerFont(translate("Garrison Limit")),
"garrisonLimit": template.garrisonHolder.capacity
})
);
if (template.garrisonHolder.buffHeal)
tooltips.push(
sprintf(translate("%(healRateLabel)s %(value)s %(health)s / %(second)s"), {
"healRateLabel": headerFont(translate("Heal:")),
"value": Math.round(template.garrisonHolder.buffHeal),
"health": unitFont(translate("Health")),
"second": unitFont(translate("second")),
})
);
tooltips.join(commaFont(translate(", ")));
}
if (template.garrisonable)
{
let extraSize;
if (template.garrisonHolder)
extraSize = template.garrisonHolder.occupiedSlots;
if (template.garrisonable.size > 1 || extraSize)
tooltips.push (
sprintf(translate("%(label)s: %(garrisonSize)s %(extraSize)s"), {
"label": headerFont(translate("Garrison Size")),
"garrisonSize": template.garrisonable.size,
"extraSize": extraSize ?
translateWithContext("nested garrison", "+ ") + extraSize : ""
})
);
}
return tooltips.join("\n");
}
function getTurretsTooltip(template)
{
if (!template.turretHolder)
return "";
return sprintf(translate("%(label)s: %(turretsLimit)s"), {
"label": headerFont(translate("Turret Positions")),
"turretsLimit": Object.keys(template.turretHolder.turretPoints).length
});
}
function getProjectilesTooltip(template)
{
if (!template.garrisonHolder || !template.buildingAI)
return "";
let limit = Math.min(
template.buildingAI.maxArrowCount || Infinity,
template.buildingAI.defaultArrowCount +
Math.round(template.buildingAI.garrisonArrowMultiplier *
template.garrisonHolder.capacity)
);
if (!limit)
return "";
return [
sprintf(translate("%(label)s: %(value)s"), {
"label": headerFont(translate("Projectile Limit")),
"value": limit
}),
sprintf(translate("%(label)s: %(value)s"), {
"label": headerFont(translateWithContext("projectiles", "Default")),
"value": template.buildingAI.defaultArrowCount
}),
sprintf(translate("%(label)s: %(value)s"), {
"label": headerFont(translateWithContext("projectiles", "Per Unit")),
"value": +template.buildingAI.garrisonArrowMultiplier.toFixed(2)
})
].join(commaFont(translate(", ")));
}
function getRepairTimeTooltip(entState)
{
let result = [];
result.push(sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Number of repairers:")),
"details": entState.repairable.numBuilders
}));
if (entState.repairable.numBuilders)
{
result.push(sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Remaining repair time:")),
"details": getSecondsString(Math.floor(entState.repairable.buildTime.timeRemaining))
}));
let timeReduction = Math.round(entState.repairable.buildTime.timeRemaining - entState.repairable.buildTime.timeRemainingNew);
result.push(sprintf(translatePlural(
"Add another worker to speed up the repairs by %(second)s second.",
"Add another worker to speed up the repairs by %(second)s seconds.",
timeReduction),
{
"second": timeReduction
}));
}
else
result.push(sprintf(translatePlural(
"Add a worker to finish the repairs in %(second)s second.",
"Add a worker to finish the repairs in %(second)s seconds.",
Math.round(entState.repairable.buildTime.timeRemainingNew)),
{
"second": Math.round(entState.repairable.buildTime.timeRemainingNew)
}));
return result.join("\n");
}
function getBuildTimeTooltip(entState)
{
let result = [];
result.push(sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Number of builders:")),
"details": entState.foundation.numBuilders
}));
if (entState.foundation.numBuilders)
{
result.push(sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Remaining build time:")),
"details": getSecondsString(Math.floor(entState.foundation.buildTime.timeRemaining))
}));
let timeReduction = Math.round(entState.foundation.buildTime.timeRemaining - entState.foundation.buildTime.timeRemainingNew);
result.push(sprintf(translatePlural(
"Add another worker to speed up the construction by %(second)s second.",
"Add another worker to speed up the construction by %(second)s seconds.",
timeReduction),
{
"second": timeReduction
}));
}
else
result.push(sprintf(translatePlural(
"Add a worker to finish the construction in %(second)s second.",
"Add a worker to finish the construction in %(second)s seconds.",
Math.round(entState.foundation.buildTime.timeRemainingNew)),
{
"second": Math.round(entState.foundation.buildTime.timeRemainingNew)
}));
return result.join("\n");
}
/**
* Multiplies the costs for a template by a given batch size.
*/
function multiplyEntityCosts(template, trainNum)
{
let totalCosts = {};
for (let r of getCostTypes())
if (template.cost[r])
totalCosts[r] = Math.floor(template.cost[r] * trainNum);
return totalCosts;
}
/**
* Helper function for getEntityCostTooltip.
*/
function getEntityCostComponentsTooltipString(template, entity, buildingsCountToTrainFullBatch = 1, fullBatchSize = 1, remainderBatch = 0)
{
if (!template.cost)
return [];
let totalCosts = multiplyEntityCosts(template, buildingsCountToTrainFullBatch * fullBatchSize + remainderBatch);
if (template.cost.time)
totalCosts.time = Math.ceil(template.cost.time * (entity ? Engine.GuiInterfaceCall("GetBatchTime", {
"entity": entity,
"batchSize": buildingsCountToTrainFullBatch > 0 ? fullBatchSize : remainderBatch
}) : 1));
let costs = [];
for (let type of getCostTypes())
if (totalCosts[type])
costs.push(sprintf(translate("%(component)s %(cost)s"), {
"component": resourceIcon(type),
"cost": totalCosts[type]
}));
return costs;
}
function getGatherTooltip(template)
{
if (!template.resourceGatherRates)
return "";
let rates = {};
for (let resource of g_ResourceData.GetResources())
{
let types = [resource.code];
for (let subtype in resource.subtypes)
{
// We ignore ruins as those are not that common
if (subtype == "ruins")
continue;
let rate = template.resourceGatherRates[resource.code + "." + subtype];
if (rate > 0)
rates[resource.code + "_" + subtype] = rate;
}
}
if (!Object.keys(rates).length)
return "";
return sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Gather Rates:")),
"details":
Object.keys(rates).map(
type => sprintf(translate("%(resourceIcon)s %(rate)s"), {
"resourceIcon": resourceIcon(type),
"rate": rates[type].toFixed(2)
})
).join(" ")
});
}
/**
* Returns the resources this entity supplies in the specified entity's tooltip
*/
function getResourceSupplyTooltip(template)
{
if (!template.supply)
return "";
let supply = template.supply;
// Translation: Label in tooltip showing the resource type and quantity of a given resource supply.
return sprintf(translate("%(label)s %(component)s %(amount)s"), {
"label": headerFont(translate("Resource Supply:")),
"component": resourceIcon(supply.type[0]),
// Translation: Marks that a resource supply entity has an unending, infinite, supply of its resource.
"amount": Number.isFinite(+supply.amount) ? supply.amount : translate("∞")
});
}
/**
* @param {Object} template - The entity's template.
* @return {string} - The resources this entity rewards to a collecter.
*/
function getTreasureTooltip(template)
{
if (!template.treasure)
return "";
let resources = {};
for (let resource of g_ResourceData.GetResources())
{
let type = resource.code;
if (template.treasure.resources[type])
resources[type] = template.treasure.resources[type];
}
let resourceNames = Object.keys(resources);
if (!resourceNames.length)
return "";
return sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Reward:")),
"details":
resourceNames.map(
type => sprintf(translate("%(resourceIcon)s %(reward)s"), {
"resourceIcon": resourceIcon(type),
"reward": resources[type]
})
).join(" ")
});
}
function getResourceTrickleTooltip(template)
{
if (!template.resourceTrickle)
return "";
let resCodes = g_ResourceData.GetCodes().filter(res => !!template.resourceTrickle.rates[res]);
if (!resCodes.length)
return "";
return sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Resource Trickle:")),
"details": sprintf(translate("%(resources)s / %(time)s"), {
"resources":
resCodes.map(
res => sprintf(translate("%(resourceIcon)s %(rate)s"), {
"resourceIcon": resourceIcon(res),
"rate": template.resourceTrickle.rates[res]
})
).join(" "),
"time": getSecondsString(template.resourceTrickle.interval / 1000)
})
});
}
+function getUpkeepTooltip(template)
+{
+ if (!template.upkeep)
+ return "";
+
+ let resCodes = g_ResourceData.GetCodes().filter(res => !!template.upkeep.rates[res]);
+ if (!resCodes.length)
+ return "";
+
+ return sprintf(translate("%(label)s %(details)s"), {
+ "label": headerFont(translate("Upkeep:")),
+ "details": sprintf(translate("%(resources)s / %(time)s"), {
+ "resources":
+ resCodes.map(
+ res => sprintf(translate("%(resourceIcon)s %(rate)s"), {
+ "resourceIcon": resourceIcon(res),
+ "rate": template.upkeep.rates[res]
+ })
+ ).join(" "),
+ "time": getSecondsString(template.upkeep.interval / 1000)
+ })
+ });
+}
+
/**
* Returns an array of strings for a set of wall pieces. If the pieces share
* resource type requirements, output will be of the form '10 to 30 Stone',
* otherwise output will be, e.g. '10 Stone, 20 Stone, 30 Stone'.
*/
function getWallPieceTooltip(wallTypes)
{
let out = [];
let resourceCount = {};
for (let resource of getCostTypes())
if (wallTypes[0].cost[resource])
resourceCount[resource] = [wallTypes[0].cost[resource]];
let sameTypes = true;
for (let i = 1; i < wallTypes.length; ++i)
{
for (let resource in wallTypes[i].cost)
// Break out of the same-type mode if this wall requires
// resource types that the first didn't.
if (wallTypes[i].cost[resource] && !resourceCount[resource])
{
sameTypes = false;
break;
}
for (let resource in resourceCount)
if (wallTypes[i].cost[resource])
resourceCount[resource].push(wallTypes[i].cost[resource]);
else
{
sameTypes = false;
break;
}
}
if (sameTypes)
for (let resource in resourceCount)
// Translation: This string is part of the resources cost string on
// the tooltip for wall structures.
out.push(sprintf(translate("%(resourceIcon)s %(minimum)s to %(resourceIcon)s %(maximum)s"), {
"resourceIcon": resourceIcon(resource),
"minimum": Math.min.apply(Math, resourceCount[resource]),
"maximum": Math.max.apply(Math, resourceCount[resource])
}));
else
for (let i = 0; i < wallTypes.length; ++i)
out.push(getEntityCostComponentsTooltipString(wallTypes[i]).join(", "));
return out;
}
/**
* Returns the cost information to display in the specified entity's construction button tooltip.
*/
function getEntityCostTooltip(template, player, entity, buildingsCountToTrainFullBatch, fullBatchSize, remainderBatch)
{
// Entities with a wallset component are proxies for initiating wall placement and as such do not have a cost of
// their own; the individual wall pieces within it do.
if (template.wallSet)
{
let templateLong = GetTemplateData(template.wallSet.templates.long, player);
let templateMedium = GetTemplateData(template.wallSet.templates.medium, player);
let templateShort = GetTemplateData(template.wallSet.templates.short, player);
let templateTower = GetTemplateData(template.wallSet.templates.tower, player);
let wallCosts = getWallPieceTooltip([templateShort, templateMedium, templateLong]);
let towerCosts = getEntityCostComponentsTooltipString(templateTower);
return sprintf(translate("Walls: %(costs)s"), { "costs": wallCosts.join(" ") }) + "\n" +
sprintf(translate("Towers: %(costs)s"), { "costs": towerCosts.join(" ") });
}
if (template.cost)
{
let costs = getEntityCostComponentsTooltipString(template, entity, buildingsCountToTrainFullBatch, fullBatchSize, remainderBatch).join(" ");
if (costs)
// Translation: Label in tooltip showing cost of a unit, structure or technology.
return sprintf(translate("%(label)s %(costs)s"), {
"label": headerFont(translate("Cost:")),
"costs": costs
});
}
return "";
}
function getRequiredTechnologyTooltip(technologyEnabled, requiredTechnology, civ)
{
if (technologyEnabled)
return "";
return sprintf(translate("Requires %(technology)s"), {
"technology": getEntityNames(GetTechnologyData(requiredTechnology, civ))
});
}
/**
* Returns the population bonus information to display in the specified entity's construction button tooltip.
*/
function getPopulationBonusTooltip(template)
{
if (!template.population || !template.population.bonus)
return "";
return sprintf(translate("%(label)s %(bonus)s"), {
"label": headerFont(translate("Population Bonus:")),
"bonus": template.population.bonus
});
}
/**
* Returns a message with the amount of each resource needed to create an entity.
*/
function getNeededResourcesTooltip(resources)
{
if (!resources)
return "";
let formatted = [];
for (let resource in resources)
formatted.push(sprintf(translate("%(component)s %(cost)s"), {
"component": '[font="sans-12"]' + resourceIcon(resource) + '[/font]',
"cost": resources[resource]
}));
return coloredText(
'[font="sans-bold-13"]' + translate("Insufficient resources:") + '[/font]',
"red") + " " +
formatted.join(" ");
}
function getSpeedTooltip(template)
{
if (!template.speed)
return "";
let walk = template.speed.walk.toFixed(1);
let run = template.speed.run.toFixed(1);
if (walk == 0 && run == 0)
return "";
return sprintf(translate("%(label)s %(speeds)s"), {
"label": headerFont(translate("Speed:")),
"speeds":
sprintf(translate("%(speed)s %(movementType)s"), {
"speed": walk,
"movementType": unitFont(translate("Walk"))
}) +
commaFont(translate(", ")) +
sprintf(translate("%(speed)s %(movementType)s"), {
"speed": run,
"movementType": unitFont(translate("Run"))
})
});
}
function getHealerTooltip(template)
{
if (!template.heal)
return "";
let health = +(template.heal.health.toFixed(1));
let range = +(template.heal.range.toFixed(0));
let interval = +((template.heal.interval / 1000).toFixed(1));
return [
sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", health), {
"label": headerFont(translate("Heal:")),
"val": health,
"unit": unitFont(translatePlural("Health", "Health", health))
}),
sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", range), {
"label": headerFont(translate("Range:")),
"val": range,
"unit": unitFont(translatePlural("meter", "meters", range))
}),
sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", interval), {
"label": headerFont(translate("Interval:")),
"val": interval,
"unit": unitFont(translatePlural("second", "seconds", interval))
})
].join(translate(", "));
}
function getAurasTooltip(template)
{
let auras = template.auras || template.wallSet && GetTemplateData(template.wallSet.templates.long).auras;
if (!auras)
return "";
let tooltips = [];
for (let auraID in auras)
{
let tooltip = sprintf(translate("%(auralabel)s %(aurainfo)s"), {
"auralabel": headerFont(sprintf(translate("%(auraname)s:"), {
"auraname": getEntityNames(auras[auraID])
})),
"aurainfo": bodyFont(translate(auras[auraID].description))
});
let radius = +auras[auraID].radius;
if (radius)
tooltip += " " + sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", radius), {
"label": translateWithContext("aura", "Range:"),
"val": radius,
"unit": unitFont(translatePlural("meter", "meters", radius))
});
tooltips.push(tooltip);
}
return tooltips.join("\n");
}
function getEntityNames(template)
{
if (!template.name.specific)
return template.name.generic;
if (template.name.specific == template.name.generic)
return template.name.specific;
let primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic;
let secondaryName;
if (g_ShowSecondaryNames)
secondaryName = g_SpecificNamesPrimary ? template.name.generic : template.name.specific;
if (secondaryName)
return sprintf(translate("%(primaryName)s (%(secondaryName)s)"), {
"primaryName": primaryName,
"secondaryName": secondaryName
});
return sprintf(translate("%(primaryName)s"), {
"primaryName": primaryName
});
}
function getEntityNamesFormatted(template)
{
if (!template.name.specific)
return setStringTags(template.name.generic, g_TooltipTextFormats.namePrimaryBig);
let primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic;
let secondaryName;
if (g_ShowSecondaryNames)
secondaryName = g_SpecificNamesPrimary ? template.name.generic : template.name.specific;
if (!secondaryName || primaryName == secondaryName)
return sprintf(translate("%(primaryName)s"), {
"primaryName":
setStringTags(primaryName[0], g_TooltipTextFormats.namePrimaryBig) +
setStringTags(primaryName.slice(1).toUpperCase(), g_TooltipTextFormats.namePrimarySmall)
});
// Translation: Example: "Epibátēs Athēnaîos [font="sans-bold-16"](Athenian Marine)[/font]"
return sprintf(translate("%(primaryName)s (%(secondaryName)s)"), {
"primaryName":
setStringTags(primaryName[0], g_TooltipTextFormats.namePrimaryBig) +
setStringTags(primaryName.slice(1).toUpperCase(), g_TooltipTextFormats.namePrimarySmall),
"secondaryName": setStringTags(secondaryName, g_TooltipTextFormats.nameSecondary)
});
}
function getEntityPrimaryNameFormatted(template)
{
let primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic;
if (!primaryName)
return setStringTags(g_SpecificNamesPrimary ? template.name.generic : template.name.specific, g_TooltipTextFormats.namePrimaryBig);
return setStringTags(primaryName[0], g_TooltipTextFormats.namePrimaryBig) +
setStringTags(primaryName.slice(1).toUpperCase(), g_TooltipTextFormats.namePrimarySmall);
}
function getVisibleEntityClassesFormatted(template)
{
if (!template.visibleIdentityClasses || !template.visibleIdentityClasses.length)
return "";
return headerFont(translate("Classes:")) + ' ' +
bodyFont(template.visibleIdentityClasses.map(c => translate(c)).join(translate(", ")));
}
function getLootTooltip(template)
{
if (!template.loot && !template.resourceCarrying)
return "";
let resourcesCarried = [];
if (template.resourceCarrying)
resourcesCarried = calculateCarriedResources(
template.resourceCarrying,
template.trader && template.trader.goods
);
let lootLabels = [];
for (let type of g_ResourceData.GetCodes().concat(["xp"]))
{
let loot =
(template.loot && template.loot[type] || 0) +
(resourcesCarried[type] || 0);
if (!loot)
continue;
// Translation: %(component) will be the icon for the loot type and %(loot) will be the value.
lootLabels.push(sprintf(translate("%(component)s %(loot)s"), {
"component": resourceIcon(type),
"loot": loot
}));
}
if (!lootLabels.length)
return "";
return sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Loot:")),
"details": lootLabels.join(" ")
});
}
function getResourceDropsiteTooltip(template)
{
if (!template || !template.resourceDropsite || !template.resourceDropsite.types)
return "";
return sprintf(translate("%(label)s %(icons)s"), {
"label": headerFont(translate("Dropsite for:")),
"icons": template.resourceDropsite.types.map(type => resourceIcon(type)).join(" ")
});
}
function showTemplateViewerOnRightClickTooltip()
{
// Translation: Appears in a tooltip to indicate that right-clicking the corresponding GUI element will open the Template Details GUI page.
return translate("Right-click to view more information.");
}
function showTemplateViewerOnClickTooltip()
{
// Translation: Appears in a tooltip to indicate that clicking the corresponding GUI element will open the Template Details GUI page.
return translate("Click to view more information.");
}
Index: ps/trunk/binaries/data/mods/public/gui/reference/common/ReferencePage.js
===================================================================
--- ps/trunk/binaries/data/mods/public/gui/reference/common/ReferencePage.js (revision 25190)
+++ ps/trunk/binaries/data/mods/public/gui/reference/common/ReferencePage.js (revision 25191)
@@ -1,69 +1,70 @@
/**
* This class contains code common to the Structure Tree, Template Viewer, and any other "Reference Page" that may be added in the future.
*/
class ReferencePage
{
constructor()
{
this.civData = loadCivData(true, false);
this.TemplateLoader = new TemplateLoader();
this.TemplateLister = new TemplateLister(this.TemplateLoader);
this.TemplateParser = new TemplateParser(this.TemplateLoader);
this.activeCiv = this.TemplateLoader.DefaultCiv;
this.currentTemplateLists = {};
}
setActiveCiv(civCode)
{
if (civCode == this.TemplateLoader.DefaultCiv)
return;
this.activeCiv = civCode;
this.currentTemplateLists = this.TemplateLister.compileTemplateLists(this.activeCiv, this.civData);
this.TemplateParser.deriveModifications(this.activeCiv);
this.TemplateParser.derivePhaseList(this.currentTemplateLists.techs.keys(), this.activeCiv);
}
/**
* Concatanates the return values of the array of passed functions.
*
* @param {Object} template
* @param {array} textFunctions
* @param {string} joiner
* @return {string} The built text.
*/
static buildText(template, textFunctions=[], joiner="\n")
{
return textFunctions.map(func => func(template)).filter(tip => tip).join(joiner);
}
}
ReferencePage.prototype.IconPath = "session/portraits/";
/**
* List of functions that get the statistics of any template or entity,
* formatted in such a way as to appear in a tooltip.
*
* The functions listed are defined in gui/common/tooltips.js
*/
ReferencePage.prototype.StatsFunctions = [
getResourceDropsiteTooltip,
getHealthTooltip,
getAttackTooltip,
getHealerTooltip,
getResistanceTooltip,
getGarrisonTooltip,
getTurretsTooltip,
getProjectilesTooltip,
getSpeedTooltip,
getGatherTooltip,
getResourceSupplyTooltip,
getTreasureTooltip,
getPopulationBonusTooltip,
getResourceTrickleTooltip,
+ getUpkeepTooltip,
getLootTooltip
];
Index: ps/trunk/binaries/data/mods/public/gui/session/selection_details.js
===================================================================
--- ps/trunk/binaries/data/mods/public/gui/session/selection_details.js (revision 25190)
+++ ps/trunk/binaries/data/mods/public/gui/session/selection_details.js (revision 25191)
@@ -1,571 +1,572 @@
function layoutSelectionSingle()
{
Engine.GetGUIObjectByName("detailsAreaSingle").hidden = false;
Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = true;
}
function layoutSelectionMultiple()
{
Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = false;
Engine.GetGUIObjectByName("detailsAreaSingle").hidden = true;
}
// Updates the health bar of garrisoned units
function updateGarrisonHealthBar(entState, selection)
{
if (!entState.garrisonHolder)
return;
// Summing up the Health of every single unit
let totalGarrisonHealth = 0;
let maxGarrisonHealth = 0;
for (let selEnt of selection)
{
let selEntState = GetEntityState(selEnt);
if (selEntState.garrisonHolder)
for (let ent of selEntState.garrisonHolder.entities)
{
let state = GetEntityState(ent);
totalGarrisonHealth += state.hitpoints || 0;
maxGarrisonHealth += state.maxHitpoints || 0;
}
}
// Configuring the health bar
let healthGarrison = Engine.GetGUIObjectByName("healthGarrison");
healthGarrison.hidden = totalGarrisonHealth <= 0;
if (totalGarrisonHealth > 0)
{
let healthBarGarrison = Engine.GetGUIObjectByName("healthBarGarrison");
let healthSize = healthBarGarrison.size;
healthSize.rtop = 100 - 100 * Math.max(0, Math.min(1, totalGarrisonHealth / maxGarrisonHealth));
healthBarGarrison.size = healthSize;
healthGarrison.tooltip = getCurrentHealthTooltip({
"hitpoints": totalGarrisonHealth,
"maxHitpoints": maxGarrisonHealth
});
}
}
// Fills out information that most entities have
function displaySingle(entState)
{
let template = GetTemplateData(entState.template);
let primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic;
let secondaryName;
if (g_ShowSecondaryNames)
secondaryName = g_SpecificNamesPrimary ? template.name.generic : template.name.specific;
// If packed, add that to the generic name (reduces template clutter).
if (template.pack && template.pack.state == "packed")
{
if (secondaryName && g_ShowSecondaryNames)
secondaryName = sprintf(translate("%(secondaryName)s — Packed"), { "secondaryName": secondaryName });
else
secondaryName = sprintf(translate("Packed"));
}
let playerState = g_Players[entState.player];
let civName = g_CivData[playerState.civ].Name;
let civEmblem = g_CivData[playerState.civ].Emblem;
let playerName = playerState.name;
// Indicate disconnected players by prefixing their name
if (g_Players[entState.player].offline)
playerName = sprintf(translate("\\[OFFLINE] %(player)s"), { "player": playerName });
// Rank
if (entState.identity && entState.identity.rank && entState.identity.classes)
{
Engine.GetGUIObjectByName("rankIcon").tooltip = sprintf(translate("%(rank)s Rank"), {
"rank": translateWithContext("Rank", entState.identity.rank)
});
Engine.GetGUIObjectByName("rankIcon").sprite = "stretched:session/icons/ranks/" + entState.identity.rank + ".png";
Engine.GetGUIObjectByName("rankIcon").hidden = false;
}
else
{
Engine.GetGUIObjectByName("rankIcon").hidden = true;
Engine.GetGUIObjectByName("rankIcon").tooltip = "";
}
if (entState.statusEffects)
{
let statusEffectsSection = Engine.GetGUIObjectByName("statusEffectsIcons");
statusEffectsSection.hidden = false;
let statusIcons = statusEffectsSection.children;
let i = 0;
for (let effectCode in entState.statusEffects)
{
let effect = entState.statusEffects[effectCode];
statusIcons[i].hidden = false;
statusIcons[i].sprite = "stretched:session/icons/status_effects/" + g_StatusEffectsMetadata.getIcon(effect.baseCode) + ".png";
statusIcons[i].tooltip = getStatusEffectsTooltip(effect.baseCode, effect, false);
let size = statusIcons[i].size;
size.top = i * 18;
size.bottom = i * 18 + 16;
statusIcons[i].size = size;
if (++i >= statusIcons.length)
break;
}
for (; i < statusIcons.length; ++i)
statusIcons[i].hidden = true;
}
else
Engine.GetGUIObjectByName("statusEffectsIcons").hidden = true;
let showHealth = entState.hitpoints;
let showResource = entState.resourceSupply;
let showCapture = entState.capturePoints;
let healthSection = Engine.GetGUIObjectByName("healthSection");
let captureSection = Engine.GetGUIObjectByName("captureSection");
let resourceSection = Engine.GetGUIObjectByName("resourceSection");
let sectionPosTop = Engine.GetGUIObjectByName("sectionPosTop");
let sectionPosMiddle = Engine.GetGUIObjectByName("sectionPosMiddle");
let sectionPosBottom = Engine.GetGUIObjectByName("sectionPosBottom");
// Hitpoints
healthSection.hidden = !showHealth;
if (showHealth)
{
let unitHealthBar = Engine.GetGUIObjectByName("healthBar");
let healthSize = unitHealthBar.size;
healthSize.rright = 100 * Math.max(0, Math.min(1, entState.hitpoints / entState.maxHitpoints));
unitHealthBar.size = healthSize;
Engine.GetGUIObjectByName("healthStats").caption = sprintf(translate("%(hitpoints)s / %(maxHitpoints)s"), {
"hitpoints": Math.ceil(entState.hitpoints),
"maxHitpoints": Math.ceil(entState.maxHitpoints)
});
healthSection.size = sectionPosTop.size;
captureSection.size = showResource ? sectionPosMiddle.size : sectionPosBottom.size;
resourceSection.size = showResource ? sectionPosBottom.size : sectionPosMiddle.size;
}
else if (showResource)
{
captureSection.size = sectionPosBottom.size;
resourceSection.size = sectionPosTop.size;
}
else if (showCapture)
captureSection.size = sectionPosTop.size;
// CapturePoints
captureSection.hidden = !entState.capturePoints;
if (entState.capturePoints)
{
let setCaptureBarPart = function(playerID, startSize) {
let unitCaptureBar = Engine.GetGUIObjectByName("captureBar[" + playerID + "]");
let sizeObj = unitCaptureBar.size;
sizeObj.rleft = startSize;
let size = 100 * Math.max(0, Math.min(1, entState.capturePoints[playerID] / entState.maxCapturePoints));
sizeObj.rright = startSize + size;
unitCaptureBar.size = sizeObj;
unitCaptureBar.sprite = "color:" + g_DiplomacyColors.getPlayerColor(playerID, 128);
unitCaptureBar.hidden = false;
return startSize + size;
};
// first handle the owner's points, to keep those points on the left for clarity
let size = setCaptureBarPart(entState.player, 0);
for (let i in entState.capturePoints)
if (i != entState.player)
size = setCaptureBarPart(i, size);
let captureText = sprintf(translate("%(capturePoints)s / %(maxCapturePoints)s"), {
"capturePoints": Math.ceil(entState.capturePoints[entState.player]),
"maxCapturePoints": Math.ceil(entState.maxCapturePoints)
});
let showSmallCapture = showResource && showHealth;
Engine.GetGUIObjectByName("captureStats").caption = showSmallCapture ? "" : captureText;
Engine.GetGUIObjectByName("capture").tooltip = showSmallCapture ? captureText : "";
}
// Experience
Engine.GetGUIObjectByName("experience").hidden = !entState.promotion;
if (entState.promotion)
{
let experienceBar = Engine.GetGUIObjectByName("experienceBar");
let experienceSize = experienceBar.size;
experienceSize.rtop = 100 - (100 * Math.max(0, Math.min(1, 1.0 * +entState.promotion.curr / +entState.promotion.req)));
experienceBar.size = experienceSize;
if (entState.promotion.curr < entState.promotion.req)
Engine.GetGUIObjectByName("experience").tooltip = sprintf(translate("%(experience)s %(current)s / %(required)s"), {
"experience": "[font=\"sans-bold-13\"]" + translate("Experience:") + "[/font]",
"current": Math.floor(entState.promotion.curr),
"required": entState.promotion.req
});
else
Engine.GetGUIObjectByName("experience").tooltip = sprintf(translate("%(experience)s %(current)s"), {
"experience": "[font=\"sans-bold-13\"]" + translate("Experience:") + "[/font]",
"current": Math.floor(entState.promotion.curr)
});
}
// Resource stats
resourceSection.hidden = !showResource;
if (entState.resourceSupply)
{
let resources = entState.resourceSupply.isInfinite ? translate("∞") : // Infinity symbol
sprintf(translate("%(amount)s / %(max)s"), {
"amount": Math.ceil(+entState.resourceSupply.amount),
"max": entState.resourceSupply.max
});
let unitResourceBar = Engine.GetGUIObjectByName("resourceBar");
let resourceSize = unitResourceBar.size;
resourceSize.rright = entState.resourceSupply.isInfinite ? 100 :
100 * Math.max(0, Math.min(1, +entState.resourceSupply.amount / +entState.resourceSupply.max));
unitResourceBar.size = resourceSize;
Engine.GetGUIObjectByName("resourceLabel").caption = sprintf(translate("%(resource)s:"), {
"resource": resourceNameFirstWord(entState.resourceSupply.type.generic)
});
Engine.GetGUIObjectByName("resourceStats").caption = resources;
}
let resourceCarryingIcon = Engine.GetGUIObjectByName("resourceCarryingIcon");
let resourceCarryingText = Engine.GetGUIObjectByName("resourceCarryingText");
resourceCarryingIcon.hidden = false;
resourceCarryingText.hidden = false;
// Resource carrying
if (entState.resourceCarrying && entState.resourceCarrying.length)
{
// We should only be carrying one resource type at once, so just display the first
let carried = entState.resourceCarrying[0];
resourceCarryingIcon.sprite = "stretched:session/icons/resources/" + carried.type + ".png";
resourceCarryingText.caption = sprintf(translate("%(amount)s / %(max)s"), { "amount": carried.amount, "max": carried.max });
resourceCarryingIcon.tooltip = "";
}
// Use the same indicators for traders
else if (entState.trader && entState.trader.goods.amount)
{
resourceCarryingIcon.sprite = "stretched:session/icons/resources/" + entState.trader.goods.type + ".png";
let totalGain = entState.trader.goods.amount.traderGain;
if (entState.trader.goods.amount.market1Gain)
totalGain += entState.trader.goods.amount.market1Gain;
if (entState.trader.goods.amount.market2Gain)
totalGain += entState.trader.goods.amount.market2Gain;
resourceCarryingText.caption = totalGain;
resourceCarryingIcon.tooltip = sprintf(translate("Gain: %(gain)s"), {
"gain": getTradingTooltip(entState.trader.goods.amount)
});
}
// And for number of workers
else if (entState.foundation)
{
resourceCarryingIcon.sprite = "stretched:session/icons/repair.png";
resourceCarryingIcon.tooltip = getBuildTimeTooltip(entState);
resourceCarryingText.caption = entState.foundation.numBuilders ? sprintf(translate("(%(number)s)\n%(time)s"), {
"number": entState.foundation.numBuilders,
"time": Engine.FormatMillisecondsIntoDateStringGMT(entState.foundation.buildTime.timeRemaining * 1000, translateWithContext("countdown format", "m:ss"))
}) : "";
}
else if (entState.resourceSupply && (!entState.resourceSupply.killBeforeGather || !entState.hitpoints))
{
resourceCarryingIcon.sprite = "stretched:session/icons/repair.png";
resourceCarryingText.caption = sprintf(translate("%(amount)s / %(max)s"), {
"amount": entState.resourceSupply.numGatherers,
"max": entState.resourceSupply.maxGatherers
});
Engine.GetGUIObjectByName("resourceCarryingIcon").tooltip = translate("Current/max gatherers");
}
else if (entState.repairable && entState.needsRepair)
{
resourceCarryingIcon.sprite = "stretched:session/icons/repair.png";
resourceCarryingIcon.tooltip = getRepairTimeTooltip(entState);
resourceCarryingText.caption = entState.repairable.numBuilders ? sprintf(translate("(%(number)s)\n%(time)s"), {
"number": entState.repairable.numBuilders,
"time": Engine.FormatMillisecondsIntoDateStringGMT(entState.repairable.buildTime.timeRemaining * 1000, translateWithContext("countdown format", "m:ss"))
}) : "";
}
else
{
resourceCarryingIcon.hidden = true;
resourceCarryingText.hidden = true;
}
Engine.GetGUIObjectByName("player").caption = playerName;
Engine.GetGUIObjectByName("playerColorBackground").sprite =
"color:" + g_DiplomacyColors.getPlayerColor(entState.player, 128);
Engine.GetGUIObjectByName("primary").caption = primaryName;
Engine.GetGUIObjectByName("secondary").caption = !secondaryName || primaryName == secondaryName ? "" :
sprintf(translate("(%(secondaryName)s)"), {
"secondaryName": secondaryName
});
let isGaia = playerState.civ == "gaia";
Engine.GetGUIObjectByName("playerCivIcon").sprite = isGaia ? "" : "stretched:grayscale:" + civEmblem;
Engine.GetGUIObjectByName("player").tooltip = isGaia ? "" : civName;
// 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("iconBorder").onPressRight = () => {
showTemplateDetails(entState.template, playerState.civ);
};
let detailedTooltip = [
getAttackTooltip,
getHealerTooltip,
getResistanceTooltip,
getGatherTooltip,
getSpeedTooltip,
getGarrisonTooltip,
getTurretsTooltip,
getPopulationBonusTooltip,
getProjectilesTooltip,
getResourceTrickleTooltip,
+ getUpkeepTooltip,
getLootTooltip
].map(func => func(entState)).filter(tip => tip).join("\n");
if (detailedTooltip)
{
Engine.GetGUIObjectByName("attackAndResistanceStats").hidden = false;
Engine.GetGUIObjectByName("attackAndResistanceStats").tooltip = detailedTooltip;
}
else
Engine.GetGUIObjectByName("attackAndResistanceStats").hidden = true;
let iconTooltips = [];
iconTooltips.push(setStringTags(primaryName, g_TooltipTextFormats.namePrimaryBig));
iconTooltips = iconTooltips.concat([
getVisibleEntityClassesFormatted,
getAurasTooltip,
getEntityTooltip,
getTreasureTooltip,
showTemplateViewerOnRightClickTooltip
].map(func => func(template)));
Engine.GetGUIObjectByName("iconBorder").tooltip = iconTooltips.filter(tip => tip).join("\n");
Engine.GetGUIObjectByName("detailsAreaSingle").hidden = false;
Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = true;
}
// Fills out information for multiple entities
function displayMultiple(entStates)
{
let averageHealth = 0;
let maxHealth = 0;
let maxCapturePoints = 0;
let capturePoints = (new Array(g_MaxPlayers + 1)).fill(0);
let playerID = 0;
let totalCarrying = {};
let totalLoot = {};
let garrisonSize = 0;
for (let entState of entStates)
{
playerID = entState.player; // trust that all selected entities have the same owner
if (entState.hitpoints)
{
averageHealth += entState.hitpoints;
maxHealth += entState.maxHitpoints;
}
if (entState.capturePoints)
{
maxCapturePoints += entState.maxCapturePoints;
capturePoints = entState.capturePoints.map((v, i) => v + capturePoints[i]);
}
let carrying = calculateCarriedResources(
entState.resourceCarrying || null,
entState.trader && entState.trader.goods
);
if (entState.loot)
for (let type in entState.loot)
totalLoot[type] = (totalLoot[type] || 0) + entState.loot[type];
for (let type in carrying)
{
totalCarrying[type] = (totalCarrying[type] || 0) + carrying[type];
totalLoot[type] = (totalLoot[type] || 0) + carrying[type];
}
if (entState.garrisonable)
garrisonSize += entState.garrisonable.size;
if (entState.garrisonHolder)
garrisonSize += entState.garrisonHolder.occupiedSlots;
}
Engine.GetGUIObjectByName("healthMultiple").hidden = averageHealth <= 0;
if (averageHealth > 0)
{
let unitHealthBar = Engine.GetGUIObjectByName("healthBarMultiple");
let healthSize = unitHealthBar.size;
healthSize.rtop = 100 - 100 * Math.max(0, Math.min(1, averageHealth / maxHealth));
unitHealthBar.size = healthSize;
Engine.GetGUIObjectByName("healthMultiple").tooltip = getCurrentHealthTooltip({
"hitpoints": averageHealth,
"maxHitpoints": maxHealth
});
}
Engine.GetGUIObjectByName("captureMultiple").hidden = maxCapturePoints <= 0;
if (maxCapturePoints > 0)
{
let setCaptureBarPart = function(pID, startSize)
{
let unitCaptureBar = Engine.GetGUIObjectByName("captureBarMultiple[" + pID + "]");
let sizeObj = unitCaptureBar.size;
sizeObj.rtop = startSize;
let size = 100 * Math.max(0, Math.min(1, capturePoints[pID] / maxCapturePoints));
sizeObj.rbottom = startSize + size;
unitCaptureBar.size = sizeObj;
unitCaptureBar.sprite = "color:" + g_DiplomacyColors.getPlayerColor(pID, 128);
unitCaptureBar.hidden = false;
return startSize + size;
};
let size = 0;
for (let i in capturePoints)
if (i != playerID)
size = setCaptureBarPart(i, size);
// last handle the owner's points, to keep those points on the bottom for clarity
setCaptureBarPart(playerID, size);
Engine.GetGUIObjectByName("captureMultiple").tooltip = getCurrentHealthTooltip(
{
"hitpoints": capturePoints[playerID],
"maxHitpoints": maxCapturePoints
},
translate("Capture Points:"));
}
let numberOfUnits = Engine.GetGUIObjectByName("numberOfUnits");
numberOfUnits.caption = entStates.length;
numberOfUnits.tooltip = "";
if (garrisonSize)
numberOfUnits.tooltip = sprintf(translate("%(label)s: %(details)s\n"), {
"label": headerFont(translate("Garrison Size")),
"details": bodyFont(garrisonSize)
});
if (Object.keys(totalCarrying).length)
numberOfUnits.tooltip = sprintf(translate("%(label)s %(details)s\n"), {
"label": headerFont(translate("Carrying:")),
"details": bodyFont(Object.keys(totalCarrying).filter(
res => totalCarrying[res] != 0).map(
res => sprintf(translate("%(type)s %(amount)s"),
{ "type": resourceIcon(res), "amount": totalCarrying[res] })).join(" "))
});
if (Object.keys(totalLoot).length)
numberOfUnits.tooltip += sprintf(translate("%(label)s %(details)s"), {
"label": headerFont(translate("Loot:")),
"details": bodyFont(Object.keys(totalLoot).filter(
res => totalLoot[res] != 0).map(
res => sprintf(translate("%(type)s %(amount)s"),
{ "type": resourceIcon(res), "amount": totalLoot[res] })).join(" "))
});
// Unhide Details Area
Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = false;
Engine.GetGUIObjectByName("detailsAreaSingle").hidden = true;
}
// Updates middle entity Selection Details Panel and left Unit Commands Panel
function updateSelectionDetails()
{
let supplementalDetailsPanel = Engine.GetGUIObjectByName("supplementalSelectionDetails");
let detailsPanel = Engine.GetGUIObjectByName("selectionDetails");
let commandsPanel = Engine.GetGUIObjectByName("unitCommands");
let entStates = [];
for (let sel of g_Selection.toList())
{
let entState = GetEntityState(sel);
if (!entState)
continue;
entStates.push(entState);
}
if (entStates.length == 0)
{
Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = true;
Engine.GetGUIObjectByName("detailsAreaSingle").hidden = true;
hideUnitCommands();
supplementalDetailsPanel.hidden = true;
detailsPanel.hidden = true;
commandsPanel.hidden = true;
return;
}
// Fill out general info and display it
if (entStates.length == 1)
displaySingle(entStates[0]);
else
displayMultiple(entStates);
// Show basic details.
detailsPanel.hidden = false;
// Fill out commands panel for specific unit selected (or first unit of primary group)
updateUnitCommands(entStates, supplementalDetailsPanel, commandsPanel);
// Show health bar for garrisoned units if the garrison panel is visible
if (Engine.GetGUIObjectByName("unitGarrisonPanel") && !Engine.GetGUIObjectByName("unitGarrisonPanel").hidden)
updateGarrisonHealthBar(entStates[0], g_Selection.toList());
}
function tradingGainString(gain, owner)
{
// Translation: Used in the trading gain tooltip
return sprintf(translate("%(gain)s (%(player)s)"), {
"gain": gain,
"player": GetSimState().players[owner].name
});
}
/**
* Returns a message with the details of the trade gain.
*/
function getTradingTooltip(gain)
{
if (!gain)
return "";
let markets = [
{ "gain": gain.market1Gain, "owner": gain.market1Owner },
{ "gain": gain.market2Gain, "owner": gain.market2Owner }
];
let primaryGain = gain.traderGain;
for (let market of markets)
if (market.gain && market.owner == gain.traderOwner)
// Translation: Used in the trading gain tooltip to concatenate profits of different players
primaryGain += translate("+") + market.gain;
let tooltip = tradingGainString(primaryGain, gain.traderOwner);
for (let market of markets)
if (market.gain && market.owner != gain.traderOwner)
tooltip +=
translateWithContext("Separation mark in an enumeration", ", ") +
tradingGainString(market.gain, market.owner);
return tooltip;
}
Index: ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js
===================================================================
--- ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js (revision 25190)
+++ ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js (revision 25191)
@@ -1,2114 +1,2121 @@
function GuiInterface() {}
GuiInterface.prototype.Schema =
"";
GuiInterface.prototype.Serialize = function()
{
// This component isn't network-synchronized for the biggest part,
// so most of the attributes shouldn't be serialized.
// Return an object with a small selection of deterministic data.
return {
"timeNotifications": this.timeNotifications,
"timeNotificationID": this.timeNotificationID
};
};
GuiInterface.prototype.Deserialize = function(data)
{
this.Init();
this.timeNotifications = data.timeNotifications;
this.timeNotificationID = data.timeNotificationID;
};
GuiInterface.prototype.Init = function()
{
this.placementEntity = undefined; // = undefined or [templateName, entityID]
this.placementWallEntities = undefined;
this.placementWallLastAngle = 0;
this.notifications = [];
this.renamedEntities = [];
this.miragedEntities = [];
this.timeNotificationID = 1;
this.timeNotifications = [];
this.entsRallyPointsDisplayed = [];
this.entsWithAuraAndStatusBars = new Set();
this.enabledVisualRangeOverlayTypes = {};
this.templateModified = {};
this.selectionDirty = {};
this.obstructionSnap = new ObstructionSnap();
};
/*
* All of the functions defined below are called via Engine.GuiInterfaceCall(name, arg)
* from GUI scripts, and executed here with arguments (player, arg).
*
* CAUTION: The input to the functions in this module is not network-synchronised, so it
* mustn't affect the simulation state (i.e. the data that is serialised and can affect
* the behaviour of the rest of the simulation) else it'll cause out-of-sync errors.
*/
/**
* Returns global information about the current game state.
* This is used by the GUI and also by AI scripts.
*/
GuiInterface.prototype.GetSimulationState = function()
{
let ret = {
"players": []
};
let cmpPlayerManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager);
let numPlayers = cmpPlayerManager.GetNumPlayers();
for (let i = 0; i < numPlayers; ++i)
{
let cmpPlayer = QueryPlayerIDInterface(i);
let cmpPlayerEntityLimits = QueryPlayerIDInterface(i, IID_EntityLimits);
// Work out which phase we are in.
let phase = "";
let cmpTechnologyManager = QueryPlayerIDInterface(i, IID_TechnologyManager);
if (cmpTechnologyManager)
{
if (cmpTechnologyManager.IsTechnologyResearched("phase_city"))
phase = "city";
else if (cmpTechnologyManager.IsTechnologyResearched("phase_town"))
phase = "town";
else if (cmpTechnologyManager.IsTechnologyResearched("phase_village"))
phase = "village";
}
let allies = [];
let mutualAllies = [];
let neutrals = [];
let enemies = [];
for (let j = 0; j < numPlayers; ++j)
{
allies[j] = cmpPlayer.IsAlly(j);
mutualAllies[j] = cmpPlayer.IsMutualAlly(j);
neutrals[j] = cmpPlayer.IsNeutral(j);
enemies[j] = cmpPlayer.IsEnemy(j);
}
ret.players.push({
"name": cmpPlayer.GetName(),
"civ": cmpPlayer.GetCiv(),
"color": cmpPlayer.GetColor(),
"controlsAll": cmpPlayer.CanControlAllUnits(),
"popCount": cmpPlayer.GetPopulationCount(),
"popLimit": cmpPlayer.GetPopulationLimit(),
"popMax": cmpPlayer.GetMaxPopulation(),
"panelEntities": cmpPlayer.GetPanelEntities(),
"resourceCounts": cmpPlayer.GetResourceCounts(),
"resourceGatherers": cmpPlayer.GetResourceGatherers(),
"trainingBlocked": cmpPlayer.IsTrainingBlocked(),
"state": cmpPlayer.GetState(),
"team": cmpPlayer.GetTeam(),
"teamsLocked": cmpPlayer.GetLockTeams(),
"cheatsEnabled": cmpPlayer.GetCheatsEnabled(),
"disabledTemplates": cmpPlayer.GetDisabledTemplates(),
"disabledTechnologies": cmpPlayer.GetDisabledTechnologies(),
"hasSharedDropsites": cmpPlayer.HasSharedDropsites(),
"hasSharedLos": cmpPlayer.HasSharedLos(),
"spyCostMultiplier": cmpPlayer.GetSpyCostMultiplier(),
"phase": phase,
"isAlly": allies,
"isMutualAlly": mutualAllies,
"isNeutral": neutrals,
"isEnemy": enemies,
"entityLimits": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetLimits() : null,
"entityCounts": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetCounts() : null,
"matchEntityCounts": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetMatchCounts() : null,
"entityLimitChangers": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetLimitChangers() : null,
"researchQueued": cmpTechnologyManager ? cmpTechnologyManager.GetQueuedResearch() : null,
"researchStarted": cmpTechnologyManager ? cmpTechnologyManager.GetStartedTechs() : null,
"researchedTechs": cmpTechnologyManager ? cmpTechnologyManager.GetResearchedTechs() : null,
"classCounts": cmpTechnologyManager ? cmpTechnologyManager.GetClassCounts() : null,
"typeCountsByClass": cmpTechnologyManager ? cmpTechnologyManager.GetTypeCountsByClass() : null,
"canBarter": cmpPlayer.CanBarter(),
"barterPrices": Engine.QueryInterface(SYSTEM_ENTITY, IID_Barter).GetPrices(cmpPlayer)
});
}
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
if (cmpRangeManager)
ret.circularMap = cmpRangeManager.GetLosCircular();
let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain);
if (cmpTerrain)
ret.mapSize = cmpTerrain.GetMapSize();
let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
ret.timeElapsed = cmpTimer.GetTime();
let cmpCeasefireManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager);
if (cmpCeasefireManager)
{
ret.ceasefireActive = cmpCeasefireManager.IsCeasefireActive();
ret.ceasefireTimeRemaining = ret.ceasefireActive ? cmpCeasefireManager.GetCeasefireStartedTime() + cmpCeasefireManager.GetCeasefireTime() - ret.timeElapsed : 0;
}
let cmpCinemaManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_CinemaManager);
if (cmpCinemaManager)
ret.cinemaPlaying = cmpCinemaManager.IsPlaying();
let cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
ret.victoryConditions = cmpEndGameManager.GetVictoryConditions();
ret.alliedVictory = cmpEndGameManager.GetAlliedVictory();
ret.maxWorldPopulation = cmpPlayerManager.GetMaxWorldPopulation();
for (let i = 0; i < numPlayers; ++i)
{
let cmpPlayerStatisticsTracker = QueryPlayerIDInterface(i, IID_StatisticsTracker);
if (cmpPlayerStatisticsTracker)
ret.players[i].statistics = cmpPlayerStatisticsTracker.GetBasicStatistics();
}
return ret;
};
/**
* Returns global information about the current game state, plus statistics.
* This is used by the GUI at the end of a game, in the summary screen.
* Note: Amongst statistics, the team exploration map percentage is computed from
* scratch, so the extended simulation state should not be requested too often.
*/
GuiInterface.prototype.GetExtendedSimulationState = function()
{
let ret = this.GetSimulationState();
let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers();
for (let i = 0; i < numPlayers; ++i)
{
let cmpPlayerStatisticsTracker = QueryPlayerIDInterface(i, IID_StatisticsTracker);
if (cmpPlayerStatisticsTracker)
ret.players[i].sequences = cmpPlayerStatisticsTracker.GetSequences();
}
return ret;
};
/**
* Returns the gamesettings that were chosen at the time the match started.
*/
GuiInterface.prototype.GetInitAttributes = function()
{
return InitAttributes;
};
/**
* This data will be stored in the replay metadata file after a match has been finished recording.
*/
GuiInterface.prototype.GetReplayMetadata = function()
{
let extendedSimState = this.GetExtendedSimulationState();
return {
"timeElapsed": extendedSimState.timeElapsed,
"playerStates": extendedSimState.players,
"mapSettings": InitAttributes.settings
};
};
/**
* Called when the game ends if the current game is part of a campaign run.
*/
GuiInterface.prototype.GetCampaignGameEndData = function(player)
{
let cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger);
if (Trigger.prototype.OnCampaignGameEnd)
return Trigger.prototype.OnCampaignGameEnd();
return {};
};
GuiInterface.prototype.GetRenamedEntities = function(player)
{
if (this.miragedEntities[player])
return this.renamedEntities.concat(this.miragedEntities[player]);
return this.renamedEntities;
};
GuiInterface.prototype.ClearRenamedEntities = function()
{
this.renamedEntities = [];
this.miragedEntities = [];
};
GuiInterface.prototype.AddMiragedEntity = function(player, entity, mirage)
{
if (!this.miragedEntities[player])
this.miragedEntities[player] = [];
this.miragedEntities[player].push({ "entity": entity, "newentity": mirage });
};
/**
* Get common entity info, often used in the gui.
*/
GuiInterface.prototype.GetEntityState = function(player, ent)
{
let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager);
// All units must have a template; if not then it's a nonexistent entity id.
let template = cmpTemplateManager.GetCurrentTemplateName(ent);
if (!template)
return null;
let ret = {
"id": ent,
"player": INVALID_PLAYER,
"template": template
};
let cmpMirage = Engine.QueryInterface(ent, IID_Mirage);
if (cmpMirage)
ret.mirage = true;
let cmpIdentity = Engine.QueryInterface(ent, IID_Identity);
if (cmpIdentity)
ret.identity = {
"rank": cmpIdentity.GetRank(),
"classes": cmpIdentity.GetClassesList(),
"selectionGroupName": cmpIdentity.GetSelectionGroupName(),
"canDelete": !cmpIdentity.IsUndeletable(),
"hasSomeFormation": cmpIdentity.HasSomeFormation(),
"formations": cmpIdentity.GetFormationsList(),
"controllable": cmpIdentity.IsControllable()
};
let cmpPosition = Engine.QueryInterface(ent, IID_Position);
if (cmpPosition && cmpPosition.IsInWorld())
ret.position = cmpPosition.GetPosition();
let 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();
}
let cmpCapturable = QueryMiragedInterface(ent, IID_Capturable);
if (cmpCapturable)
{
ret.capturePoints = cmpCapturable.GetCapturePoints();
ret.maxCapturePoints = cmpCapturable.GetMaxCapturePoints();
}
let cmpBuilder = Engine.QueryInterface(ent, IID_Builder);
if (cmpBuilder)
ret.builder = true;
let cmpMarket = QueryMiragedInterface(ent, IID_Market);
if (cmpMarket)
ret.market = {
"land": cmpMarket.HasType("land"),
"naval": cmpMarket.HasType("naval")
};
let cmpPack = Engine.QueryInterface(ent, IID_Pack);
if (cmpPack)
ret.pack = {
"packed": cmpPack.IsPacked(),
"progress": cmpPack.GetProgress()
};
let cmpPopulation = Engine.QueryInterface(ent, IID_Population);
if (cmpPopulation)
ret.population = {
"bonus": cmpPopulation.GetPopBonus()
};
let cmpUpgrade = Engine.QueryInterface(ent, IID_Upgrade);
if (cmpUpgrade)
ret.upgrade = {
"upgrades": cmpUpgrade.GetUpgrades(),
"progress": cmpUpgrade.GetProgress(),
"template": cmpUpgrade.GetUpgradingTo(),
"isUpgrading": cmpUpgrade.IsUpgrading()
};
let cmpStatusEffects = Engine.QueryInterface(ent, IID_StatusEffectsReceiver);
if (cmpStatusEffects)
ret.statusEffects = cmpStatusEffects.GetActiveStatuses();
let cmpProductionQueue = Engine.QueryInterface(ent, IID_ProductionQueue);
if (cmpProductionQueue)
ret.production = {
"entities": cmpProductionQueue.GetEntitiesList(),
"technologies": cmpProductionQueue.GetTechnologiesList(),
"techCostMultiplier": cmpProductionQueue.GetTechCostMultiplier(),
"queue": cmpProductionQueue.GetQueue()
};
let cmpTrader = Engine.QueryInterface(ent, IID_Trader);
if (cmpTrader)
ret.trader = {
"goods": cmpTrader.GetGoods()
};
let cmpFoundation = QueryMiragedInterface(ent, IID_Foundation);
if (cmpFoundation)
ret.foundation = {
"numBuilders": cmpFoundation.GetNumBuilders(),
"buildTime": cmpFoundation.GetBuildTime()
};
let cmpRepairable = QueryMiragedInterface(ent, IID_Repairable);
if (cmpRepairable)
ret.repairable = {
"numBuilders": cmpRepairable.GetNumBuilders(),
"buildTime": cmpRepairable.GetBuildTime()
};
let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership);
if (cmpOwnership)
ret.player = cmpOwnership.GetOwner();
let cmpRallyPoint = Engine.QueryInterface(ent, IID_RallyPoint);
if (cmpRallyPoint)
ret.rallyPoint = { "position": cmpRallyPoint.GetPositions()[0] }; // undefined or {x,z} object
let 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()
};
let cmpTurretHolder = Engine.QueryInterface(ent, IID_TurretHolder);
if (cmpTurretHolder)
ret.turretHolder = {
"turretPoints": cmpTurretHolder.GetTurretPoints()
};
let cmpTurretable = Engine.QueryInterface(ent, IID_Turretable);
if (cmpTurretable)
ret.turretable = {
"holder": cmpTurretable.HolderID()
};
let cmpGarrisonable = Engine.QueryInterface(ent, IID_Garrisonable);
if (cmpGarrisonable)
ret.garrisonable = {
"holder": cmpGarrisonable.HolderID(),
"size": cmpGarrisonable.UnitSize()
};
let 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()
};
let cmpGuard = Engine.QueryInterface(ent, IID_Guard);
if (cmpGuard)
ret.guard = {
"entities": cmpGuard.GetEntities()
};
let cmpResourceGatherer = Engine.QueryInterface(ent, IID_ResourceGatherer);
if (cmpResourceGatherer)
{
ret.resourceCarrying = cmpResourceGatherer.GetCarryingStatus();
ret.resourceGatherRates = cmpResourceGatherer.GetGatherRates();
}
let cmpGate = Engine.QueryInterface(ent, IID_Gate);
if (cmpGate)
ret.gate = {
"locked": cmpGate.IsLocked()
};
let cmpAlertRaiser = Engine.QueryInterface(ent, IID_AlertRaiser);
if (cmpAlertRaiser)
ret.alertRaiser = true;
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
ret.visibility = cmpRangeManager.GetLosVisibility(ent, player);
let cmpAttack = Engine.QueryInterface(ent, IID_Attack);
if (cmpAttack)
{
let types = cmpAttack.GetAttackTypes();
if (types.length)
ret.attack = {};
for (let 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));
let range = cmpAttack.GetRange(type);
ret.attack[type].minRange = range.min;
ret.attack[type].maxRange = range.max;
let timers = cmpAttack.GetTimers(type);
ret.attack[type].prepareTime = timers.prepare;
ret.attack[type].repeatTime = timers.repeat;
if (type != "Ranged")
{
// Not a ranged attack, set some defaults.
ret.attack[type].elevationBonus = 0;
ret.attack[type].elevationAdaptedRange = ret.attack.maxRange;
continue;
}
ret.attack[type].elevationBonus = range.elevationBonus;
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, range.elevationBonus, cmpUnitAI ? 0 : 2 * Math.PI);
else
// Not in world, set a default?
ret.attack[type].elevationAdaptedRange = ret.attack.maxRange;
}
}
let cmpResistance = Engine.QueryInterface(ent, IID_Resistance);
if (cmpResistance)
ret.resistance = cmpResistance.GetResistanceOfForm(cmpFoundation ? "Foundation" : "Entity");
let 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();
let 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()
};
let cmpResourceDropsite = Engine.QueryInterface(ent, IID_ResourceDropsite);
if (cmpResourceDropsite)
ret.resourceDropsite = {
"types": cmpResourceDropsite.GetTypes(),
"sharable": cmpResourceDropsite.IsSharable(),
"shared": cmpResourceDropsite.IsShared()
};
let 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;
let 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()
};
let cmpLoot = Engine.QueryInterface(ent, IID_Loot);
if (cmpLoot)
{
ret.loot = cmpLoot.GetResources();
ret.loot.xp = cmpLoot.GetXp();
}
let cmpResourceTrickle = Engine.QueryInterface(ent, IID_ResourceTrickle);
if (cmpResourceTrickle)
ret.resourceTrickle = {
"interval": cmpResourceTrickle.GetInterval(),
"rates": cmpResourceTrickle.GetRates()
};
let cmpTreasure = Engine.QueryInterface(ent, IID_Treasure);
if (cmpTreasure)
ret.treasure = {
"collectTime": cmpTreasure.CollectionTime(),
"resources": cmpTreasure.Resources()
};
let cmpTreasureCollecter = Engine.QueryInterface(ent, IID_TreasureCollecter);
if (cmpTreasureCollecter)
ret.treasureCollecter = true;
let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion);
if (cmpUnitMotion)
ret.speed = {
"walk": cmpUnitMotion.GetWalkSpeed(),
"run": cmpUnitMotion.GetWalkSpeed() * cmpUnitMotion.GetRunMultiplier()
};
+ let cmpUpkeep = Engine.QueryInterface(ent, IID_Upkeep);
+ if (cmpUpkeep)
+ ret.upkeep = {
+ "interval": cmpUpkeep.GetInterval(),
+ "rates": cmpUpkeep.GetRates()
+ };
+
return ret;
};
GuiInterface.prototype.GetMultipleEntityStates = function(player, ents)
{
return ents.map(ent => ({ "entId": ent, "state": this.GetEntityState(player, ent) }));
};
GuiInterface.prototype.GetAverageRangeForBuildings = function(player, cmd)
{
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain);
let rot = { "x": 0, "y": 0, "z": 0 };
let pos = {
"x": cmd.x,
"y": cmpTerrain.GetGroundLevel(cmd.x, cmd.z),
"z": cmd.z
};
let elevationBonus = cmd.elevationBonus || 0;
let range = cmd.range;
return cmpRangeManager.GetElevationAdaptedRange(pos, rot, range, elevationBonus, 2 * Math.PI);
};
GuiInterface.prototype.GetTemplateData = function(player, data)
{
let templateName = data.templateName;
let owner = data.player !== undefined ? data.player : player;
let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager);
let template = cmpTemplateManager.GetTemplate(templateName);
if (!template)
return null;
let aurasTemplate = {};
if (!template.Auras)
return GetTemplateDataHelper(template, owner, aurasTemplate);
let auraNames = template.Auras._string.split(/\s+/);
for (let name of auraNames)
{
let auraTemplate = AuraTemplates.Get(name);
if (!auraTemplate)
error("Template " + templateName + " has undefined aura " + name);
else
aurasTemplate[name] = auraTemplate;
}
return GetTemplateDataHelper(template, owner, aurasTemplate);
};
GuiInterface.prototype.IsTechnologyResearched = function(player, data)
{
if (!data.tech)
return true;
let cmpTechnologyManager = QueryPlayerIDInterface(data.player !== undefined ? data.player : player, IID_TechnologyManager);
if (!cmpTechnologyManager)
return false;
return cmpTechnologyManager.IsTechnologyResearched(data.tech);
};
/**
* Checks whether the requirements for this technology have been met.
*/
GuiInterface.prototype.CheckTechnologyRequirements = function(player, data)
{
let cmpTechnologyManager = QueryPlayerIDInterface(data.player !== undefined ? data.player : player, IID_TechnologyManager);
if (!cmpTechnologyManager)
return false;
return cmpTechnologyManager.CanResearch(data.tech);
};
/**
* Returns technologies that are being actively researched, along with
* which entity is researching them and how far along the research is.
*/
GuiInterface.prototype.GetStartedResearch = function(player)
{
let cmpTechnologyManager = QueryPlayerIDInterface(player, IID_TechnologyManager);
if (!cmpTechnologyManager)
return {};
let ret = {};
for (let tech of cmpTechnologyManager.GetStartedTechs())
{
ret[tech] = { "researcher": cmpTechnologyManager.GetResearcher(tech) };
let cmpProductionQueue = Engine.QueryInterface(ret[tech].researcher, IID_ProductionQueue);
if (cmpProductionQueue)
{
ret[tech].progress = cmpProductionQueue.GetQueue()[0].progress;
ret[tech].timeRemaining = cmpProductionQueue.GetQueue()[0].timeRemaining;
}
else
{
ret[tech].progress = 0;
ret[tech].timeRemaining = 0;
}
}
return ret;
};
/**
* Returns the battle state of the player.
*/
GuiInterface.prototype.GetBattleState = function(player)
{
let cmpBattleDetection = QueryPlayerIDInterface(player, IID_BattleDetection);
if (!cmpBattleDetection)
return false;
return cmpBattleDetection.GetState();
};
/**
* Returns a list of ongoing attacks against the player.
*/
GuiInterface.prototype.GetIncomingAttacks = function(player)
{
let cmpAttackDetection = QueryPlayerIDInterface(player, IID_AttackDetection);
if (!cmpAttackDetection)
return [];
return cmpAttackDetection.GetIncomingAttacks();
};
/**
* Used to show a red square over GUI elements you can't yet afford.
*/
GuiInterface.prototype.GetNeededResources = function(player, data)
{
let cmpPlayer = QueryPlayerIDInterface(data.player !== undefined ? data.player : player);
return cmpPlayer ? cmpPlayer.GetNeededResources(data.cost) : {};
};
/**
* State of the templateData (player dependent): true when some template values have been modified
* and need to be reloaded by the gui.
*/
GuiInterface.prototype.OnTemplateModification = function(msg)
{
this.templateModified[msg.player] = true;
this.selectionDirty[msg.player] = true;
};
GuiInterface.prototype.IsTemplateModified = function(player)
{
return this.templateModified[player] || false;
};
GuiInterface.prototype.ResetTemplateModified = function()
{
this.templateModified = {};
};
/**
* Some changes may require an update to the selection panel,
* which is cached for efficiency. Inform the GUI it needs reloading.
*/
GuiInterface.prototype.OnDisabledTemplatesChanged = function(msg)
{
this.selectionDirty[msg.player] = true;
};
GuiInterface.prototype.OnDisabledTechnologiesChanged = function(msg)
{
this.selectionDirty[msg.player] = true;
};
GuiInterface.prototype.SetSelectionDirty = function(player)
{
this.selectionDirty[player] = true;
};
GuiInterface.prototype.IsSelectionDirty = function(player)
{
return this.selectionDirty[player] || false;
};
GuiInterface.prototype.ResetSelectionDirty = function()
{
this.selectionDirty = {};
};
/**
* Add a timed notification.
* Warning: timed notifacations are serialised
* (to also display them on saved games or after a rejoin)
* so they should allways be added and deleted in a deterministic way.
*/
GuiInterface.prototype.AddTimeNotification = function(notification, duration = 10000)
{
let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
notification.endTime = duration + cmpTimer.GetTime();
notification.id = ++this.timeNotificationID;
// Let all players and observers receive the notification by default.
if (!notification.players)
{
notification.players = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers();
notification.players[0] = -1;
}
this.timeNotifications.push(notification);
this.timeNotifications.sort((n1, n2) => n2.endTime - n1.endTime);
cmpTimer.SetTimeout(this.entity, IID_GuiInterface, "DeleteTimeNotification", duration, this.timeNotificationID);
return this.timeNotificationID;
};
GuiInterface.prototype.DeleteTimeNotification = function(notificationID)
{
this.timeNotifications = this.timeNotifications.filter(n => n.id != notificationID);
};
GuiInterface.prototype.GetTimeNotifications = function(player)
{
let time = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer).GetTime();
// Filter on players and time, since the delete timer might be executed with a delay.
return this.timeNotifications.filter(n => n.players.indexOf(player) != -1 && n.endTime > time);
};
GuiInterface.prototype.PushNotification = function(notification)
{
if (!notification.type || notification.type == "text")
this.AddTimeNotification(notification);
else
this.notifications.push(notification);
};
GuiInterface.prototype.GetNotifications = function()
{
let n = this.notifications;
this.notifications = [];
return n;
};
GuiInterface.prototype.GetAvailableFormations = function(player, wantedPlayer)
{
let cmpPlayer = QueryPlayerIDInterface(wantedPlayer);
if (!cmpPlayer)
return [];
return cmpPlayer.GetFormations();
};
GuiInterface.prototype.GetFormationRequirements = function(player, data)
{
return GetFormationRequirements(data.formationTemplate);
};
GuiInterface.prototype.CanMoveEntsIntoFormation = function(player, data)
{
return CanMoveEntsIntoFormation(data.ents, data.formationTemplate);
};
GuiInterface.prototype.GetFormationInfoFromTemplate = function(player, data)
{
let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager);
let template = cmpTemplateManager.GetTemplate(data.templateName);
if (!template || !template.Formation)
return {};
return {
"name": template.Formation.FormationName,
"tooltip": template.Formation.DisabledTooltip || "",
"icon": template.Formation.Icon
};
};
GuiInterface.prototype.IsFormationSelected = function(player, data)
{
return data.ents.some(ent => {
let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
return cmpUnitAI && cmpUnitAI.GetFormationTemplate() == data.formationTemplate;
});
};
GuiInterface.prototype.IsStanceSelected = function(player, data)
{
for (let ent of data.ents)
{
let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
if (cmpUnitAI && cmpUnitAI.GetStanceName() == data.stance)
return true;
}
return false;
};
GuiInterface.prototype.GetAllBuildableEntities = function(player, cmd)
{
let buildableEnts = [];
for (let ent of cmd.entities)
{
let cmpBuilder = Engine.QueryInterface(ent, IID_Builder);
if (!cmpBuilder)
continue;
for (let building of cmpBuilder.GetEntitiesList())
if (buildableEnts.indexOf(building) == -1)
buildableEnts.push(building);
}
return buildableEnts;
};
GuiInterface.prototype.UpdateDisplayedPlayerColors = function(player, data)
{
let updateEntityColor = (iids, entities) => {
for (let ent of entities)
for (let iid of iids)
{
let cmp = Engine.QueryInterface(ent, iid);
if (cmp)
cmp.UpdateColor();
}
};
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers();
for (let i = 1; i < numPlayers; ++i)
{
let cmpPlayer = QueryPlayerIDInterface(i, IID_Player);
if (!cmpPlayer)
continue;
cmpPlayer.SetDisplayDiplomacyColor(data.displayDiplomacyColors);
if (data.displayDiplomacyColors)
cmpPlayer.SetDiplomacyColor(data.displayedPlayerColors[i]);
updateEntityColor(data.showAllStatusBars && (i == player || player == -1) ?
[IID_Minimap, IID_RangeOverlayRenderer, IID_RallyPointRenderer, IID_StatusBars] :
[IID_Minimap, IID_RangeOverlayRenderer, IID_RallyPointRenderer],
cmpRangeManager.GetEntitiesByPlayer(i));
}
updateEntityColor([IID_Selectable, IID_StatusBars], data.selected);
Engine.QueryInterface(SYSTEM_ENTITY, IID_TerritoryManager).UpdateColors();
};
GuiInterface.prototype.SetSelectionHighlight = function(player, cmd)
{
// Cache of owner -> color map
let playerColors = {};
for (let ent of cmd.entities)
{
let cmpSelectable = Engine.QueryInterface(ent, IID_Selectable);
if (!cmpSelectable)
continue;
// Find the entity's owner's color.
let owner = INVALID_PLAYER;
let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership);
if (cmpOwnership)
owner = cmpOwnership.GetOwner();
let color = playerColors[owner];
if (!color)
{
color = { "r": 1, "g": 1, "b": 1 };
let cmpPlayer = QueryPlayerIDInterface(owner);
if (cmpPlayer)
color = cmpPlayer.GetDisplayedColor();
playerColors[owner] = color;
}
cmpSelectable.SetSelectionHighlight({ "r": color.r, "g": color.g, "b": color.b, "a": cmd.alpha }, cmd.selected);
let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager);
if (!cmpRangeOverlayManager || player != owner && player != INVALID_PLAYER)
continue;
cmpRangeOverlayManager.SetEnabled(cmd.selected, this.enabledVisualRangeOverlayTypes, false);
}
};
GuiInterface.prototype.EnableVisualRangeOverlayType = function(player, data)
{
this.enabledVisualRangeOverlayTypes[data.type] = data.enabled;
};
GuiInterface.prototype.GetEntitiesWithStatusBars = function()
{
return Array.from(this.entsWithAuraAndStatusBars);
};
GuiInterface.prototype.SetStatusBars = function(player, cmd)
{
let affectedEnts = new Set();
for (let ent of cmd.entities)
{
let cmpStatusBars = Engine.QueryInterface(ent, IID_StatusBars);
if (!cmpStatusBars)
continue;
cmpStatusBars.SetEnabled(cmd.enabled, cmd.showRank, cmd.showExperience);
let cmpAuras = Engine.QueryInterface(ent, IID_Auras);
if (!cmpAuras)
continue;
for (let name of cmpAuras.GetAuraNames())
{
if (!cmpAuras.GetOverlayIcon(name))
continue;
for (let e of cmpAuras.GetAffectedEntities(name))
affectedEnts.add(e);
if (cmd.enabled)
this.entsWithAuraAndStatusBars.add(ent);
else
this.entsWithAuraAndStatusBars.delete(ent);
}
}
for (let ent of affectedEnts)
{
let cmpStatusBars = Engine.QueryInterface(ent, IID_StatusBars);
if (cmpStatusBars)
cmpStatusBars.RegenerateSprites();
}
};
GuiInterface.prototype.SetRangeOverlays = function(player, cmd)
{
for (let ent of cmd.entities)
{
let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager);
if (cmpRangeOverlayManager)
cmpRangeOverlayManager.SetEnabled(cmd.enabled, this.enabledVisualRangeOverlayTypes, true);
}
};
GuiInterface.prototype.GetPlayerEntities = function(player)
{
return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetEntitiesByPlayer(player);
};
GuiInterface.prototype.GetNonGaiaEntities = function()
{
return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetNonGaiaEntities();
};
/**
* Displays the rally points of a given list of entities (carried in cmd.entities).
*
* The 'cmd' object may carry its own x/z coordinate pair indicating the location where the rally point should
* be rendered, in order to support instantaneously rendering a rally point marker at a specified location
* instead of incurring a delay while PostNetworkCommand processes the set-rallypoint command (see input.js).
* If cmd doesn't carry a custom location, then the position to render the marker at will be read from the
* RallyPoint component.
*/
GuiInterface.prototype.DisplayRallyPoint = function(player, cmd)
{
let cmpPlayer = QueryPlayerIDInterface(player);
// If there are some rally points already displayed, first hide them.
for (let ent of this.entsRallyPointsDisplayed)
{
let cmpRallyPointRenderer = Engine.QueryInterface(ent, IID_RallyPointRenderer);
if (cmpRallyPointRenderer)
cmpRallyPointRenderer.SetDisplayed(false);
}
this.entsRallyPointsDisplayed = [];
// Show the rally points for the passed entities.
for (let ent of cmd.entities)
{
let cmpRallyPointRenderer = Engine.QueryInterface(ent, IID_RallyPointRenderer);
if (!cmpRallyPointRenderer)
continue;
// Entity must have a rally point component to display a rally point marker
// (regardless of whether cmd specifies a custom location).
let cmpRallyPoint = Engine.QueryInterface(ent, IID_RallyPoint);
if (!cmpRallyPoint)
continue;
// Verify the owner.
let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership);
if (!(cmpPlayer && cmpPlayer.CanControlAllUnits()))
if (!cmpOwnership || cmpOwnership.GetOwner() != player)
continue;
// If the command was passed an explicit position, use that and
// override the real rally point position; otherwise use the real position.
let pos;
if (cmd.x && cmd.z)
pos = cmd;
else
// May return undefined if no rally point is set.
pos = cmpRallyPoint.GetPositions()[0];
if (pos)
{
// Only update the position if we changed it (cmd.queued is set).
// Note that Add-/SetPosition take a CFixedVector2D which has X/Y components, not X/Z.
if ("queued" in cmd)
{
if (cmd.queued == true)
cmpRallyPointRenderer.AddPosition(new Vector2D(pos.x, pos.z));
else
cmpRallyPointRenderer.SetPosition(new Vector2D(pos.x, pos.z));
}
else if (!cmpRallyPointRenderer.IsSet())
// Rebuild the renderer when not set (when reading saved game or in case of building update).
for (let posi of cmpRallyPoint.GetPositions())
cmpRallyPointRenderer.AddPosition(new Vector2D(posi.x, posi.z));
cmpRallyPointRenderer.SetDisplayed(true);
// Remember which entities have their rally points displayed so we can hide them again.
this.entsRallyPointsDisplayed.push(ent);
}
}
};
GuiInterface.prototype.AddTargetMarker = function(player, cmd)
{
let ent = Engine.AddLocalEntity(cmd.template);
if (!ent)
return;
let cmpPosition = Engine.QueryInterface(ent, IID_Position);
cmpPosition.JumpTo(cmd.x, cmd.z);
};
/**
* Display the building placement preview.
* cmd.template is the name of the entity template, or "" to disable the preview.
* cmd.x, cmd.z, cmd.angle give the location.
*
* Returns result object from CheckPlacement:
* {
* "success": true iff the placement is valid, else false
* "message": message to display in UI for invalid placement, else ""
* "parameters": parameters to use in the message
* "translateMessage": localisation info
* "translateParameters": localisation info
* "pluralMessage": we might return a plural translation instead (optional)
* "pluralCount": localisation info (optional)
* }
*/
GuiInterface.prototype.SetBuildingPlacementPreview = function(player, cmd)
{
let result = {
"success": false,
"message": "",
"parameters": {},
"translateMessage": false,
"translateParameters": []
};
if (!this.placementEntity || this.placementEntity[0] != cmd.template)
{
if (this.placementEntity)
Engine.DestroyEntity(this.placementEntity[1]);
if (cmd.template == "")
this.placementEntity = undefined;
else
this.placementEntity = [cmd.template, Engine.AddLocalEntity("preview|" + cmd.template)];
}
if (this.placementEntity)
{
let ent = this.placementEntity[1];
let pos = Engine.QueryInterface(ent, IID_Position);
if (pos)
{
pos.JumpTo(cmd.x, cmd.z);
pos.SetYRotation(cmd.angle);
}
let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership);
cmpOwnership.SetOwner(player);
let cmpBuildRestrictions = Engine.QueryInterface(ent, IID_BuildRestrictions);
if (!cmpBuildRestrictions)
error("cmpBuildRestrictions not defined");
else
result = cmpBuildRestrictions.CheckPlacement();
let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager);
if (cmpRangeOverlayManager)
cmpRangeOverlayManager.SetEnabled(true, this.enabledVisualRangeOverlayTypes);
// Set it to a red shade if this is an invalid location.
let cmpVisual = Engine.QueryInterface(ent, IID_Visual);
if (cmpVisual)
{
if (cmd.actorSeed !== undefined)
cmpVisual.SetActorSeed(cmd.actorSeed);
if (!result.success)
cmpVisual.SetShadingColor(1.4, 0.4, 0.4, 1);
else
cmpVisual.SetShadingColor(1, 1, 1, 1);
}
}
return result;
};
/**
* Previews the placement of a wall between cmd.start and cmd.end, or just the starting piece of a wall if cmd.end is not
* specified. Returns an object with information about the list of entities that need to be newly constructed to complete
* at least a part of the wall, or false if there are entities required to build at least part of the wall but none of
* them can be validly constructed.
*
* It's important to distinguish between three lists of entities that are at play here, because they may be subsets of one
* another depending on things like snapping and whether some of the entities inside them can be validly positioned.
* We have:
* - The list of entities that previews the wall. This list is usually equal to the entities required to construct the
* entire wall. However, if there is snapping to an incomplete tower (i.e. a foundation), it includes extra entities
* to preview the completed tower on top of its foundation.
*
* - The list of entities that need to be newly constructed to build the entire wall. This list is regardless of whether
* any of them can be validly positioned. The emphasishere here is on 'newly'; this list does not include any existing
* towers at either side of the wall that we snapped to. Or, more generally; it does not include any _entities_ that we
* snapped to; we might still snap to e.g. terrain, in which case the towers on either end will still need to be newly
* constructed.
*
* - The list of entities that need to be newly constructed to build at least a part of the wall. This list is the same
* as the one above, except that it is truncated at the first entity that cannot be validly positioned. This happens
* e.g. if the player tries to build a wall straight through an obstruction. Note that any entities that can be validly
* constructed but come after said first invalid entity are also truncated away.
*
* With this in mind, this method will return false if the second list is not empty, but the third one is. That is, if there
* were entities that are needed to build the wall, but none of them can be validly constructed. False is also returned in
* case of unexpected errors (typically missing components), and when clearing the preview by passing an empty wallset
* argument (see below). Otherwise, it will return an object with the following information:
*
* result: {
* 'startSnappedEnt': ID of the entity that we snapped to at the starting side of the wall. Currently only supports towers.
* 'endSnappedEnt': ID of the entity that we snapped to at the (possibly truncated) ending side of the wall. Note that this
* can only be set if no truncation of the second list occurs; if we snapped to an entity at the ending side
* but the wall construction was truncated before we could reach it, it won't be set here. Currently only
* supports towers.
* 'pieces': Array with the following data for each of the entities in the third list:
* [{
* 'template': Template name of the entity.
* 'x': X coordinate of the entity's position.
* 'z': Z coordinate of the entity's position.
* 'angle': Rotation around the Y axis of the entity (in radians).
* },
* ...]
* 'cost': { The total cost required for constructing all the pieces as listed above.
* 'food': ...,
* 'wood': ...,
* 'stone': ...,
* 'metal': ...,
* 'population': ...,
* }
* }
*
* @param cmd.wallSet Object holding the set of wall piece template names. Set to an empty value to clear the preview.
* @param cmd.start Starting point of the wall segment being created.
* @param cmd.end (Optional) Ending point of the wall segment being created. If not defined, it is understood that only
* the starting point of the wall is available at this time (e.g. while the player is still in the process
* of picking a starting point), and that therefore only the first entity in the wall (a tower) should be
* previewed.
* @param cmd.snapEntities List of candidate entities to snap the start and ending positions to.
*/
GuiInterface.prototype.SetWallPlacementPreview = function(player, cmd)
{
let wallSet = cmd.wallSet;
// Did the start position snap to anything?
// If we snapped, was it to an entity? If yes, hold that entity's ID.
let start = {
"pos": cmd.start,
"angle": 0,
"snapped": false,
"snappedEnt": INVALID_ENTITY
};
// Did the end position snap to anything?
// If we snapped, was it to an entity? If yes, hold that entity's ID.
let end = {
"pos": cmd.end,
"angle": 0,
"snapped": false,
"snappedEnt": INVALID_ENTITY
};
// --------------------------------------------------------------------------------
// Do some entity cache management and check for snapping.
if (!this.placementWallEntities)
this.placementWallEntities = {};
if (!wallSet)
{
// We're clearing the preview, clear the entity cache and bail.
for (let tpl in this.placementWallEntities)
{
for (let ent of this.placementWallEntities[tpl].entities)
Engine.DestroyEntity(ent);
this.placementWallEntities[tpl].numUsed = 0;
this.placementWallEntities[tpl].entities = [];
// Keep template data around.
}
return false;
}
for (let tpl in this.placementWallEntities)
{
for (let ent of this.placementWallEntities[tpl].entities)
{
let pos = Engine.QueryInterface(ent, IID_Position);
if (pos)
pos.MoveOutOfWorld();
}
this.placementWallEntities[tpl].numUsed = 0;
}
// Create cache entries for templates we haven't seen before.
for (let type in wallSet.templates)
{
if (type == "curves")
continue;
let tpl = wallSet.templates[type];
if (!(tpl in this.placementWallEntities))
{
this.placementWallEntities[tpl] = {
"numUsed": 0,
"entities": [],
"templateData": this.GetTemplateData(player, { "templateName": tpl }),
};
if (!this.placementWallEntities[tpl].templateData.wallPiece)
{
error("[SetWallPlacementPreview] No WallPiece component found for wall set template '" + tpl + "'");
return false;
}
}
}
// Prevent division by zero errors further on if the start and end positions are the same.
if (end.pos && (start.pos.x === end.pos.x && start.pos.z === end.pos.z))
end.pos = undefined;
// See if we need to snap the start and/or end coordinates to any of our list of snap entities. Note that, despite the list
// of snapping candidate entities, it might still snap to e.g. terrain features. Use the "ent" key in the returned snapping
// data to determine whether it snapped to an entity (if any), and to which one (see GetFoundationSnapData).
if (cmd.snapEntities)
{
// Value of 0.5 was determined through trial and error.
let snapRadius = this.placementWallEntities[wallSet.templates.tower].templateData.wallPiece.length * 0.5;
let startSnapData = this.GetFoundationSnapData(player, {
"x": start.pos.x,
"z": start.pos.z,
"template": wallSet.templates.tower,
"snapEntities": cmd.snapEntities,
"snapRadius": snapRadius,
});
if (startSnapData)
{
start.pos.x = startSnapData.x;
start.pos.z = startSnapData.z;
start.angle = startSnapData.angle;
start.snapped = true;
if (startSnapData.ent)
start.snappedEnt = startSnapData.ent;
}
if (end.pos)
{
let endSnapData = this.GetFoundationSnapData(player, {
"x": end.pos.x,
"z": end.pos.z,
"template": wallSet.templates.tower,
"snapEntities": cmd.snapEntities,
"snapRadius": snapRadius,
});
if (endSnapData)
{
end.pos.x = endSnapData.x;
end.pos.z = endSnapData.z;
end.angle = endSnapData.angle;
end.snapped = true;
if (endSnapData.ent)
end.snappedEnt = endSnapData.ent;
}
}
}
// Clear the single-building preview entity (we'll be rolling our own).
this.SetBuildingPlacementPreview(player, { "template": "" });
// --------------------------------------------------------------------------------
// Calculate wall placement and position preview entities.
let result = {
"pieces": [],
"cost": { "population": 0, "time": 0 }
};
for (let res of Resources.GetCodes())
result.cost[res] = 0;
let previewEntities = [];
if (end.pos)
// See helpers/Walls.js.
previewEntities = GetWallPlacement(this.placementWallEntities, wallSet, start, end);
// For wall placement, we may (and usually do) need to have wall pieces overlap each other more than would
// otherwise be allowed by their obstruction shapes. However, during this preview phase, this is not so much of
// an issue, because all preview entities have their obstruction components deactivated, meaning that their
// obstruction shapes do not register in the simulation and hence cannot affect it. This implies that the preview
// entities cannot be found to obstruct each other, which largely solves the issue of overlap between wall pieces.
// Note that they will still be obstructed by existing shapes in the simulation (that have the BLOCK_FOUNDATION
// flag set), which is what we want. The only exception to this is when snapping to existing towers (or
// foundations thereof); the wall segments that connect up to these will be found to be obstructed by the
// existing tower/foundation, and be shaded red to indicate that they cannot be placed there. To prevent this,
// we manually set the control group of the outermost wall pieces equal to those of the snapped-to towers, so
// that they are free from mutual obstruction (per definition of obstruction control groups). This is done by
// assigning them an extra "controlGroup" field, which we'll then set during the placement loop below.
// Additionally, in the situation that we're snapping to merely a foundation of a tower instead of a fully
// constructed one, we'll need an extra preview entity for the starting tower, which also must not be obstructed
// by the foundation it snaps to.
if (start.snappedEnt && start.snappedEnt != INVALID_ENTITY)
{
let startEntObstruction = Engine.QueryInterface(start.snappedEnt, IID_Obstruction);
if (previewEntities.length && startEntObstruction)
previewEntities[0].controlGroups = [startEntObstruction.GetControlGroup()];
// If we're snapping to merely a foundation, add an extra preview tower and also set it to the same control group.
let startEntState = this.GetEntityState(player, start.snappedEnt);
if (startEntState.foundation)
{
let cmpPosition = Engine.QueryInterface(start.snappedEnt, IID_Position);
if (cmpPosition)
previewEntities.unshift({
"template": wallSet.templates.tower,
"pos": start.pos,
"angle": cmpPosition.GetRotation().y,
"controlGroups": [startEntObstruction ? startEntObstruction.GetControlGroup() : undefined],
"excludeFromResult": true // Preview only, must not appear in the result.
});
}
}
else
{
// Didn't snap to an existing entity, add the starting tower manually. To prevent odd-looking rotation jumps
// when shift-clicking to build a wall, reuse the placement angle that was last seen on a validly positioned
// wall piece.
// To illustrate the last point, consider what happens if we used some constant instead, say, 0. Issuing the
// build command for a wall is asynchronous, so when the preview updates after shift-clicking, the wall piece
// foundations are not registered yet in the simulation. This means they cannot possibly be picked in the list
// of candidate entities for snapping. In the next preview update, we therefore hit this case, and would rotate
// the preview to 0 radians. Then, after one or two simulation updates or so, the foundations register and
// onSimulationUpdate in session.js updates the preview again. It first grabs a new list of snapping candidates,
// which this time does include the new foundations; so we snap to the entity, and rotate the preview back to
// the foundation's angle.
// The result is a noticeable rotation to 0 and back, which is undesirable. So, for a split second there until
// the simulation updates, we fake it by reusing the last angle and hope the player doesn't notice.
previewEntities.unshift({
"template": wallSet.templates.tower,
"pos": start.pos,
"angle": previewEntities.length ? previewEntities[0].angle : this.placementWallLastAngle
});
}
if (end.pos)
{
// Analogous to the starting side case above.
if (end.snappedEnt && end.snappedEnt != INVALID_ENTITY)
{
let endEntObstruction = Engine.QueryInterface(end.snappedEnt, IID_Obstruction);
// Note that it's possible for the last entity in previewEntities to be the same as the first, i.e. the
// same wall piece snapping to both a starting and an ending tower. And it might be more common than you would
// expect; the allowed overlap between wall segments and towers facilitates this to some degree. To deal with
// the possibility of dual initial control groups, we use a '.controlGroups' array rather than a single
// '.controlGroup' property. Note that this array can only ever have 0, 1 or 2 elements (checked at a later time).
if (previewEntities.length > 0 && endEntObstruction)
{
previewEntities[previewEntities.length - 1].controlGroups = previewEntities[previewEntities.length - 1].controlGroups || [];
previewEntities[previewEntities.length - 1].controlGroups.push(endEntObstruction.GetControlGroup());
}
// If we're snapping to a foundation, add an extra preview tower and also set it to the same control group.
let endEntState = this.GetEntityState(player, end.snappedEnt);
if (endEntState.foundation)
{
let cmpPosition = Engine.QueryInterface(end.snappedEnt, IID_Position);
if (cmpPosition)
previewEntities.push({
"template": wallSet.templates.tower,
"pos": end.pos,
"angle": cmpPosition.GetRotation().y,
"controlGroups": [endEntObstruction ? endEntObstruction.GetControlGroup() : undefined],
"excludeFromResult": true
});
}
}
else
previewEntities.push({
"template": wallSet.templates.tower,
"pos": end.pos,
"angle": previewEntities.length ? previewEntities[previewEntities.length - 1].angle : this.placementWallLastAngle
});
}
let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain);
if (!cmpTerrain)
{
error("[SetWallPlacementPreview] System Terrain component not found");
return false;
}
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
if (!cmpRangeManager)
{
error("[SetWallPlacementPreview] System RangeManager component not found");
return false;
}
// Loop through the preview entities, and construct the subset of them that need to be, and can be, validly constructed
// to build at least a part of the wall (meaning that the subset is truncated after the first entity that needs to be,
// but cannot validly be, constructed). See method-level documentation for more details.
let allPiecesValid = true;
// Number of entities that are required to build the entire wall, regardless of validity.
let numRequiredPieces = 0;
for (let i = 0; i < previewEntities.length; ++i)
{
let entInfo = previewEntities[i];
let ent = null;
let tpl = entInfo.template;
let tplData = this.placementWallEntities[tpl].templateData;
let entPool = this.placementWallEntities[tpl];
if (entPool.numUsed >= entPool.entities.length)
{
ent = Engine.AddLocalEntity("preview|" + tpl);
entPool.entities.push(ent);
}
else
ent = entPool.entities[entPool.numUsed];
if (!ent)
{
error("[SetWallPlacementPreview] Failed to allocate or reuse preview entity of template '" + tpl + "'");
continue;
}
// Move piece to right location.
// TODO: Consider reusing SetBuildingPlacementReview for this, enhanced to be able to deal with multiple entities.
let cmpPosition = Engine.QueryInterface(ent, IID_Position);
if (cmpPosition)
{
cmpPosition.JumpTo(entInfo.pos.x, entInfo.pos.z);
cmpPosition.SetYRotation(entInfo.angle);
// If this piece is a tower, then it should have a Y position that is at least as high as its surrounding pieces.
if (tpl === wallSet.templates.tower)
{
let terrainGroundPrev = null;
let terrainGroundNext = null;
if (i > 0)
terrainGroundPrev = cmpTerrain.GetGroundLevel(previewEntities[i - 1].pos.x, previewEntities[i - 1].pos.z);
if (i < previewEntities.length - 1)
terrainGroundNext = cmpTerrain.GetGroundLevel(previewEntities[i + 1].pos.x, previewEntities[i + 1].pos.z);
if (terrainGroundPrev != null || terrainGroundNext != null)
{
let targetY = Math.max(terrainGroundPrev, terrainGroundNext);
cmpPosition.SetHeightFixed(targetY);
}
}
}
let cmpObstruction = Engine.QueryInterface(ent, IID_Obstruction);
if (!cmpObstruction)
{
error("[SetWallPlacementPreview] Preview entity of template '" + tpl + "' does not have an Obstruction component");
continue;
}
// Assign any predefined control groups. Note that there can only be 0, 1 or 2 predefined control groups; if there are
// more, we've made a programming error. The control groups are assigned from the entInfo.controlGroups array on a
// first-come first-served basis; the first value in the array is always assigned as the primary control group, and
// any second value as the secondary control group.
// By default, we reset the control groups to their standard values. Remember that we're reusing entities; if we don't
// reset them, then an ending wall segment that was e.g. at one point snapped to an existing tower, and is subsequently
// reused as a non-snapped ending wall segment, would no longer be capable of being obstructed by the same tower it was
// once snapped to.
let primaryControlGroup = ent;
let secondaryControlGroup = INVALID_ENTITY;
if (entInfo.controlGroups && entInfo.controlGroups.length > 0)
{
if (entInfo.controlGroups.length > 2)
{
error("[SetWallPlacementPreview] Encountered preview entity of template '" + tpl + "' with more than 2 initial control groups");
break;
}
primaryControlGroup = entInfo.controlGroups[0];
if (entInfo.controlGroups.length > 1)
secondaryControlGroup = entInfo.controlGroups[1];
}
cmpObstruction.SetControlGroup(primaryControlGroup);
cmpObstruction.SetControlGroup2(secondaryControlGroup);
let validPlacement = false;
let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership);
cmpOwnership.SetOwner(player);
// Check whether it's in a visible or fogged region.
// TODO: Should definitely reuse SetBuildingPlacementPreview, this is just straight up copy/pasta.
let visible = cmpRangeManager.GetLosVisibility(ent, player) != "hidden";
if (visible)
{
let cmpBuildRestrictions = Engine.QueryInterface(ent, IID_BuildRestrictions);
if (!cmpBuildRestrictions)
{
error("[SetWallPlacementPreview] cmpBuildRestrictions not defined for preview entity of template '" + tpl + "'");
continue;
}
// TODO: Handle results of CheckPlacement.
validPlacement = cmpBuildRestrictions && cmpBuildRestrictions.CheckPlacement().success;
// If a wall piece has two control groups, it's likely a segment that spans
// between two existing towers. To avoid placing a duplicate wall segment,
// check for collisions with entities that share both control groups.
if (validPlacement && entInfo.controlGroups && entInfo.controlGroups.length > 1)
validPlacement = cmpObstruction.CheckDuplicateFoundation();
}
allPiecesValid = allPiecesValid && validPlacement;
// The requirement below that all pieces so far have to have valid positions, rather than only this single one,
// ensures that no more foundations will be placed after a first invalidly-positioned piece. (It is possible
// for pieces past some invalidly-positioned ones to still have valid positions, e.g. if you drag a wall
// through and past an existing building).
// Additionally, the excludeFromResult flag is set for preview entities that were manually added to be placed
// on top of foundations of incompleted towers that we snapped to; they must not be part of the result.
if (!entInfo.excludeFromResult)
++numRequiredPieces;
if (allPiecesValid && !entInfo.excludeFromResult)
{
result.pieces.push({
"template": tpl,
"x": entInfo.pos.x,
"z": entInfo.pos.z,
"angle": entInfo.angle,
});
this.placementWallLastAngle = entInfo.angle;
// Grab the cost of this wall piece and add it up (note; preview entities don't have their Cost components
// copied over, so we need to fetch it from the template instead).
// TODO: We should really use a Cost object or at least some utility functions for this, this is mindless
// boilerplate that's probably duplicated in tons of places.
for (let res of Resources.GetCodes().concat(["population", "time"]))
result.cost[res] += tplData.cost[res];
}
let canAfford = true;
let cmpPlayer = QueryPlayerIDInterface(player, IID_Player);
if (cmpPlayer && cmpPlayer.GetNeededResources(result.cost))
canAfford = false;
let cmpVisual = Engine.QueryInterface(ent, IID_Visual);
if (cmpVisual)
{
if (!allPiecesValid || !canAfford)
cmpVisual.SetShadingColor(1.4, 0.4, 0.4, 1);
else
cmpVisual.SetShadingColor(1, 1, 1, 1);
}
++entPool.numUsed;
}
// If any were entities required to build the wall, but none of them could be validly positioned, return failure
// (see method-level documentation).
if (numRequiredPieces > 0 && result.pieces.length == 0)
return false;
if (start.snappedEnt && start.snappedEnt != INVALID_ENTITY)
result.startSnappedEnt = start.snappedEnt;
// We should only return that we snapped to an entity if all pieces up until that entity can be validly constructed,
// i.e. are included in result.pieces (see docs for the result object).
if (end.pos && end.snappedEnt && end.snappedEnt != INVALID_ENTITY && allPiecesValid)
result.endSnappedEnt = end.snappedEnt;
return result;
};
/**
* Given the current position {data.x, data.z} of an foundation of template data.template, returns the position and angle to snap
* it to (if necessary/useful).
*
* @param data.x The X position of the foundation to snap.
* @param data.z The Z position of the foundation to snap.
* @param data.template The template to get the foundation snapping data for.
* @param data.snapEntities Optional; list of entity IDs to snap to if {data.x, data.z} is within a circle of radius data.snapRadius
* around the entity. Only takes effect when used in conjunction with data.snapRadius.
* When this option is used and the foundation is found to snap to one of the entities passed in this list
* (as opposed to e.g. snapping to terrain features), then the result will contain an additional key "ent",
* holding the ID of the entity that was snapped to.
* @param data.snapRadius Optional; when used in conjunction with data.snapEntities, indicates the circle radius around an entity that
* {data.x, data.z} must be located within to have it snap to that entity.
*/
GuiInterface.prototype.GetFoundationSnapData = function(player, data)
{
let template = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager).GetTemplate(data.template);
if (!template)
{
warn("[GetFoundationSnapData] Failed to load template '" + data.template + "'");
return false;
}
if (data.snapEntities && data.snapRadius && data.snapRadius > 0)
{
// See if {data.x, data.z} is inside the snap radius of any of the snap entities; and if so, to which it is closest.
// (TODO: Break unlikely ties by choosing the lowest entity ID.)
let minDist2 = -1;
let minDistEntitySnapData = null;
let radius2 = data.snapRadius * data.snapRadius;
for (let ent of data.snapEntities)
{
let cmpPosition = Engine.QueryInterface(ent, IID_Position);
if (!cmpPosition || !cmpPosition.IsInWorld())
continue;
let pos = cmpPosition.GetPosition();
let dist2 = (data.x - pos.x) * (data.x - pos.x) + (data.z - pos.z) * (data.z - pos.z);
if (dist2 > radius2)
continue;
if (minDist2 < 0 || dist2 < minDist2)
{
minDist2 = dist2;
minDistEntitySnapData = {
"x": pos.x,
"z": pos.z,
"angle": cmpPosition.GetRotation().y,
"ent": ent
};
}
}
if (minDistEntitySnapData != null)
return minDistEntitySnapData;
}
if (data.snapToEdges)
{
let position = this.obstructionSnap.getPosition(data, template);
if (position)
return position;
}
if (template.BuildRestrictions.PlacementType == "shore")
{
let angle = GetDockAngle(template, data.x, data.z);
if (angle !== undefined)
return {
"x": data.x,
"z": data.z,
"angle": angle
};
}
return false;
};
GuiInterface.prototype.PlaySound = function(player, data)
{
if (!data.entity)
return;
PlaySound(data.name, data.entity);
};
/**
* Find any idle units.
*
* @param data.idleClasses Array of class names to include.
* @param data.prevUnit The previous idle unit, if calling a second time to iterate through units. May be left undefined.
* @param data.limit The number of idle units to return. May be left undefined (will return all idle units).
* @param data.excludeUnits Array of units to exclude.
*
* Returns an array of idle units.
* If multiple classes were supplied, and multiple items will be returned, the items will be sorted by class.
*/
GuiInterface.prototype.FindIdleUnits = function(player, data)
{
let idleUnits = [];
// The general case is that only the 'first' idle unit is required; filtering would examine every unit.
// This loop imitates a grouping/aggregation on the first matching idle class.
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
for (let entity of cmpRangeManager.GetEntitiesByPlayer(player))
{
let filtered = this.IdleUnitFilter(entity, data.idleClasses, data.excludeUnits);
if (!filtered.idle)
continue;
// If the entity is in the 'current' (first, 0) bucket on a resumed search, it must be after the "previous" unit, if any.
// By adding to the 'end', there is no pause if the series of units loops.
let bucket = filtered.bucket;
if (bucket == 0 && data.prevUnit && entity <= data.prevUnit)
bucket = data.idleClasses.length;
if (!idleUnits[bucket])
idleUnits[bucket] = [];
idleUnits[bucket].push(entity);
// If enough units have been collected in the first bucket, go ahead and return them.
if (data.limit && bucket == 0 && idleUnits[0].length == data.limit)
return idleUnits[0];
}
let reduced = idleUnits.reduce((prev, curr) => prev.concat(curr), []);
if (data.limit && reduced.length > data.limit)
return reduced.slice(0, data.limit);
return reduced;
};
/**
* Discover if the player has idle units.
*
* @param data.idleClasses Array of class names to include.
* @param data.excludeUnits Array of units to exclude.
*
* Returns a boolean of whether the player has any idle units
*/
GuiInterface.prototype.HasIdleUnits = function(player, data)
{
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
return cmpRangeManager.GetEntitiesByPlayer(player).some(unit => this.IdleUnitFilter(unit, data.idleClasses, data.excludeUnits).idle);
};
/**
* Whether to filter an idle unit
*
* @param unit The unit to filter.
* @param idleclasses Array of class names to include.
* @param excludeUnits Array of units to exclude.
*
* Returns an object with the following fields:
* - idle - true if the unit is considered idle by the filter, false otherwise.
* - bucket - if idle, set to the index of the first matching idle class, undefined otherwise.
*/
GuiInterface.prototype.IdleUnitFilter = function(unit, idleClasses, excludeUnits)
{
let cmpUnitAI = Engine.QueryInterface(unit, IID_UnitAI);
if (!cmpUnitAI || !cmpUnitAI.IsIdle())
return { "idle": false };
let cmpGarrisonable = Engine.QueryInterface(unit, IID_Garrisonable);
if (cmpGarrisonable && cmpGarrisonable.IsGarrisoned())
return { "idle": false };
let cmpIdentity = Engine.QueryInterface(unit, IID_Identity);
if (!cmpIdentity)
return { "idle": false };
let bucket = idleClasses.findIndex(elem => MatchesClassList(cmpIdentity.GetClassesList(), elem));
if (bucket == -1 || excludeUnits.indexOf(unit) > -1)
return { "idle": false };
return { "idle": true, "bucket": bucket };
};
GuiInterface.prototype.GetTradingRouteGain = function(player, data)
{
if (!data.firstMarket || !data.secondMarket)
return null;
return CalculateTraderGain(data.firstMarket, data.secondMarket, data.template);
};
GuiInterface.prototype.GetTradingDetails = function(player, data)
{
let cmpEntityTrader = Engine.QueryInterface(data.trader, IID_Trader);
if (!cmpEntityTrader || !cmpEntityTrader.CanTrade(data.target))
return null;
let firstMarket = cmpEntityTrader.GetFirstMarket();
let secondMarket = cmpEntityTrader.GetSecondMarket();
let result = null;
if (data.target === firstMarket)
{
result = {
"type": "is first",
"hasBothMarkets": cmpEntityTrader.HasBothMarkets()
};
if (cmpEntityTrader.HasBothMarkets())
result.gain = cmpEntityTrader.GetGoods().amount;
}
else if (data.target === secondMarket)
result = {
"type": "is second",
"gain": cmpEntityTrader.GetGoods().amount,
};
else if (!firstMarket)
result = { "type": "set first" };
else if (!secondMarket)
result = {
"type": "set second",
"gain": cmpEntityTrader.CalculateGain(firstMarket, data.target),
};
else
result = { "type": "set first" };
return result;
};
GuiInterface.prototype.CanAttack = function(player, data)
{
let cmpAttack = Engine.QueryInterface(data.entity, IID_Attack);
return cmpAttack && cmpAttack.CanAttack(data.target, data.types || undefined);
};
/*
* Returns batch build time.
*/
GuiInterface.prototype.GetBatchTime = function(player, data)
{
let cmpProductionQueue = Engine.QueryInterface(data.entity, IID_ProductionQueue);
if (!cmpProductionQueue)
return 0;
return cmpProductionQueue.GetBatchTime(data.batchSize);
};
GuiInterface.prototype.IsMapRevealed = function(player)
{
return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetLosRevealAll(player);
};
GuiInterface.prototype.SetPathfinderDebugOverlay = function(player, enabled)
{
Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).SetDebugOverlay(enabled);
};
GuiInterface.prototype.SetPathfinderHierDebugOverlay = function(player, enabled)
{
Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).SetHierDebugOverlay(enabled);
};
GuiInterface.prototype.SetObstructionDebugOverlay = function(player, enabled)
{
Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager).SetDebugOverlay(enabled);
};
GuiInterface.prototype.SetMotionDebugOverlay = function(player, data)
{
for (let ent of data.entities)
{
let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion);
if (cmpUnitMotion)
cmpUnitMotion.SetDebugOverlay(data.enabled);
}
};
GuiInterface.prototype.SetRangeDebugOverlay = function(player, enabled)
{
Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).SetDebugOverlay(enabled);
};
GuiInterface.prototype.GetTraderNumber = function(player)
{
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
let traders = cmpRangeManager.GetEntitiesByPlayer(player).filter(e => Engine.QueryInterface(e, IID_Trader));
let landTrader = { "total": 0, "trading": 0, "garrisoned": 0 };
let shipTrader = { "total": 0, "trading": 0 };
for (let ent of traders)
{
let cmpIdentity = Engine.QueryInterface(ent, IID_Identity);
let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
if (!cmpIdentity || !cmpUnitAI)
continue;
if (cmpIdentity.HasClass("Ship"))
{
++shipTrader.total;
if (cmpUnitAI.order && cmpUnitAI.order.type == "Trade")
++shipTrader.trading;
}
else
{
++landTrader.total;
if (cmpUnitAI.order && cmpUnitAI.order.type == "Trade")
++landTrader.trading;
if (cmpUnitAI.order && cmpUnitAI.order.type == "Garrison")
{
let holder = cmpUnitAI.order.data.target;
let cmpHolderUnitAI = Engine.QueryInterface(holder, IID_UnitAI);
if (cmpHolderUnitAI && cmpHolderUnitAI.order && cmpHolderUnitAI.order.type == "Trade")
++landTrader.garrisoned;
}
}
}
return { "landTrader": landTrader, "shipTrader": shipTrader };
};
GuiInterface.prototype.GetTradingGoods = function(player)
{
let cmpPlayer = QueryPlayerIDInterface(player);
if (!cmpPlayer)
return [];
return cmpPlayer.GetTradingGoods();
};
GuiInterface.prototype.OnGlobalEntityRenamed = function(msg)
{
this.renamedEntities.push(msg);
};
/**
* List the GuiInterface functions that can be safely called by GUI scripts.
* (GUI scripts are non-deterministic and untrusted, so these functions must be
* appropriately careful. They are called with a first argument "player", which is
* trusted and indicates the player associated with the current client; no data should
* be returned unless this player is meant to be able to see it.)
*/
let exposedFunctions = {
"GetSimulationState": 1,
"GetExtendedSimulationState": 1,
"GetInitAttributes": 1,
"GetReplayMetadata": 1,
"GetCampaignGameEndData": 1,
"GetRenamedEntities": 1,
"ClearRenamedEntities": 1,
"GetEntityState": 1,
"GetMultipleEntityStates": 1,
"GetAverageRangeForBuildings": 1,
"GetTemplateData": 1,
"IsTechnologyResearched": 1,
"CheckTechnologyRequirements": 1,
"GetStartedResearch": 1,
"GetBattleState": 1,
"GetIncomingAttacks": 1,
"GetNeededResources": 1,
"GetNotifications": 1,
"GetTimeNotifications": 1,
"GetAvailableFormations": 1,
"GetFormationRequirements": 1,
"CanMoveEntsIntoFormation": 1,
"IsFormationSelected": 1,
"GetFormationInfoFromTemplate": 1,
"IsStanceSelected": 1,
"UpdateDisplayedPlayerColors": 1,
"SetSelectionHighlight": 1,
"GetAllBuildableEntities": 1,
"SetStatusBars": 1,
"GetPlayerEntities": 1,
"GetNonGaiaEntities": 1,
"DisplayRallyPoint": 1,
"AddTargetMarker": 1,
"SetBuildingPlacementPreview": 1,
"SetWallPlacementPreview": 1,
"GetFoundationSnapData": 1,
"PlaySound": 1,
"FindIdleUnits": 1,
"HasIdleUnits": 1,
"GetTradingRouteGain": 1,
"GetTradingDetails": 1,
"CanAttack": 1,
"GetBatchTime": 1,
"IsMapRevealed": 1,
"SetPathfinderDebugOverlay": 1,
"SetPathfinderHierDebugOverlay": 1,
"SetObstructionDebugOverlay": 1,
"SetMotionDebugOverlay": 1,
"SetRangeDebugOverlay": 1,
"EnableVisualRangeOverlayType": 1,
"SetRangeOverlays": 1,
"GetTraderNumber": 1,
"GetTradingGoods": 1,
"IsTemplateModified": 1,
"ResetTemplateModified": 1,
"IsSelectionDirty": 1,
"ResetSelectionDirty": 1
};
GuiInterface.prototype.ScriptCall = function(player, name, args)
{
if (exposedFunctions[name])
return this[name](player, args);
throw new Error("Invalid GuiInterface Call name \"" + name + "\"");
};
Engine.RegisterSystemComponentType(IID_GuiInterface, "GuiInterface", GuiInterface);
Index: ps/trunk/binaries/data/mods/public/simulation/components/Upkeep.js
===================================================================
--- ps/trunk/binaries/data/mods/public/simulation/components/Upkeep.js (nonexistent)
+++ ps/trunk/binaries/data/mods/public/simulation/components/Upkeep.js (revision 25191)
@@ -0,0 +1,150 @@
+function Upkeep() {}
+
+Upkeep.prototype.Schema =
+ "Controls the resource upkeep of an entity." +
+ "" +
+ Resources.BuildSchema("nonNegativeDecimal") +
+ "" +
+ "" +
+ "" +
+ "";
+
+Upkeep.prototype.Init = function()
+{
+ this.upkeepInterval = +this.template.Interval;
+ this.CheckTimer();
+};
+
+/**
+ * @return {number} - The interval between resource subtractions, in ms.
+ */
+Upkeep.prototype.GetInterval = function()
+{
+ return this.upkeepInterval;
+};
+
+/**
+ * @return {Object} - The upkeep rates in the form of { "resourceName": {number} }.
+ */
+Upkeep.prototype.GetRates = function()
+{
+ return this.rates;
+};
+
+/**
+ * @return {boolean} - Whether this entity has at least one non-zero amount of resources to pay.
+ */
+Upkeep.prototype.ComputeRates = function()
+{
+ this.rates = {};
+ let hasUpkeep = false;
+ for (let resource in this.template.Rates)
+ {
+ let rate = ApplyValueModificationsToEntity("Upkeep/Rates/" + resource, +this.template.Rates[resource], this.entity);
+ if (rate)
+ {
+ this.rates[resource] = rate;
+ hasUpkeep = true;
+ }
+ }
+
+ return hasUpkeep;
+};
+
+/**
+ * Try to subtract the needed resources.
+ * Data and lateness are unused.
+ */
+Upkeep.prototype.Pay = function(data, lateness)
+{
+ let cmpPlayer = QueryOwnerInterface(this.entity);
+ if (!cmpPlayer)
+ return;
+
+ if (!cmpPlayer.TrySubtractResources(this.rates))
+ this.HandleInsufficientUpkeep();
+ else
+ this.HandleSufficientUpkeep();
+};
+
+/**
+ * E.g. take a hitpoint, reduce CP.
+ */
+Upkeep.prototype.HandleInsufficientUpkeep = function()
+{
+ if (this.unpayed)
+ return;
+
+ let cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity);
+ if (cmpIdentity)
+ cmpIdentity.SetControllable(false);
+ this.unpayed = true;
+};
+
+/**
+ * Reset to the previous stage.
+ */
+Upkeep.prototype.HandleSufficientUpkeep = function()
+{
+ if (!this.unpayed)
+ return;
+
+ let cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity);
+ if (cmpIdentity)
+ cmpIdentity.SetControllable(true);
+ delete this.unpayed;
+};
+
+Upkeep.prototype.OnValueModification = function(msg)
+{
+ if (msg.component != "Upkeep")
+ return;
+
+ this.CheckTimer();
+};
+
+/**
+ * Recalculate the interval and update the timer accordingly.
+ */
+Upkeep.prototype.CheckTimer = function()
+{
+ if (!this.ComputeRates())
+ {
+ if (!this.timer)
+ return;
+
+ let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
+ cmpTimer.CancelTimer(this.timer);
+ delete this.timer;
+ return;
+ }
+
+ let oldUpkeepInterval = this.upkeepInterval;
+ this.upkeepInterval = ApplyValueModificationsToEntity("Upkeep/Interval", +this.template.Interval, this.entity);
+ if (this.upkeepInterval < 0)
+ {
+ let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
+ cmpTimer.CancelTimer(this.timer);
+ delete this.timer;
+ return;
+ }
+
+ if (this.timer)
+ {
+ if (this.upkeepInterval == oldUpkeepInterval)
+ return;
+
+ // If the timer wasn't invalidated before (interval <= 0), just update it.
+ if (oldUpkeepInterval > 0)
+ {
+ let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
+ cmpTimer.UpdateRepeatTime(this.timer, this.upkeepInterval);
+ return;
+ }
+ }
+
+ let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
+ this.timer = cmpTimer.SetInterval(this.entity, IID_Upkeep, "Pay", this.upkeepInterval, this.upkeepInterval, undefined);
+};
+
+Engine.RegisterComponentType(IID_Upkeep, "Upkeep", Upkeep);
Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/Upkeep.js
___________________________________________________________________
Added: svn:eol-style
## -0,0 +1 ##
+native
\ No newline at end of property
Added: svn:mime-type
## -0,0 +1 ##
+text/plain
\ No newline at end of property
Index: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Upkeep.js
===================================================================
--- ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Upkeep.js (nonexistent)
+++ ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Upkeep.js (revision 25191)
@@ -0,0 +1 @@
+Engine.RegisterInterface("Upkeep");
Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Upkeep.js
___________________________________________________________________
Added: svn:eol-style
## -0,0 +1 ##
+native
\ No newline at end of property
Added: svn:mime-type
## -0,0 +1 ##
+text/plain
\ No newline at end of property
Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js
===================================================================
--- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js (revision 25190)
+++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js (revision 25191)
@@ -1,615 +1,616 @@
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/Resistance.js");
Engine.LoadComponentScript("interfaces/DeathDamage.js");
Engine.LoadComponentScript("interfaces/EndGameManager.js");
Engine.LoadComponentScript("interfaces/EntityLimits.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/ProductionQueue.js");
Engine.LoadComponentScript("interfaces/Promotion.js");
Engine.LoadComponentScript("interfaces/Repairable.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/TurretHolder.js");
Engine.LoadComponentScript("interfaces/Timer.js");
Engine.LoadComponentScript("interfaces/Treasure.js");
Engine.LoadComponentScript("interfaces/TreasureCollecter.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 = {
"GetCodes": () => ["food", "metal", "stone", "wood"],
"GetNames": () => ({
"food": "Food",
"metal": "Metal",
"stone": "Stone",
"wood": "Wood"
}),
"GetResource": resource => ({
"aiAnalysisInfluenceGroup":
resource == "food" ? "ignore" :
resource == "wood" ? "abundant" : "sparse"
})
};
var cmp = ConstructComponent(SYSTEM_ENTITY, "GuiInterface");
AddMock(SYSTEM_ENTITY, IID_Barter, {
"GetPrices": function() {
return {
"buy": { "food": 150 },
"sell": { "food": 25 }
};
}
});
AddMock(SYSTEM_ENTITY, IID_EndGameManager, {
"GetVictoryConditions": () => ["conquest", "wonder"],
"GetAlliedVictory": function() { return false; }
});
AddMock(SYSTEM_ENTITY, IID_PlayerManager, {
"GetNumPlayers": function() { return 2; },
"GetPlayerByID": function(id) { TS_ASSERT(id === 0 || id === 1); return 100 + id; },
"GetMaxWorldPopulation": function() {}
});
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 ""; }
});
AddMock(SYSTEM_ENTITY, IID_Timer, {
"GetTime": function() { return 0; },
"SetTimeout": function(ent, iid, funcname, time, data) { return 0; }
});
AddMock(100, IID_Player, {
"GetName": function() { return "Player 1"; },
"GetCiv": function() { return "gaia"; },
"GetColor": function() { return { "r": 1, "g": 1, "b": 1, "a": 1 }; },
"CanControlAllUnits": function() { return false; },
"GetPopulationCount": function() { return 10; },
"GetPopulationLimit": function() { return 20; },
"GetMaxPopulation": function() { return 200; },
"GetResourceCounts": function() { return { "food": 100 }; },
"GetResourceGatherers": function() { return { "food": 1 }; },
"GetPanelEntities": function() { return []; },
"IsTrainingBlocked": function() { return false; },
"GetState": function() { return "active"; },
"GetTeam": function() { return -1; },
"GetLockTeams": function() { return false; },
"GetCheatsEnabled": function() { return false; },
"GetDiplomacy": function() { return [-1, 1]; },
"IsAlly": function() { return false; },
"IsMutualAlly": function() { return false; },
"IsNeutral": function() { return false; },
"IsEnemy": function() { return true; },
"GetDisabledTemplates": function() { return {}; },
"GetDisabledTechnologies": function() { return {}; },
"CanBarter": function() { return false; },
"GetSpyCostMultiplier": function() { return 1; },
"HasSharedDropsites": function() { return false; },
"HasSharedLos": function() { return false; }
});
AddMock(100, IID_EntityLimits, {
"GetLimits": function() { return { "Foo": 10 }; },
"GetCounts": function() { return { "Foo": 5 }; },
"GetLimitChangers": function() { return { "Foo": {} }; },
"GetMatchCounts": function() { return { "Bar": 0 }; }
});
AddMock(100, IID_TechnologyManager, {
"IsTechnologyResearched": tech => tech == "phase_village",
"GetQueuedResearch": () => new Map(),
"GetStartedTechs": () => new Set(),
"GetResearchedTechs": () => new Set(),
"GetClassCounts": () => ({}),
"GetTypeCountsByClass": () => ({})
});
AddMock(100, IID_StatisticsTracker, {
"GetBasicStatistics": function() {
return {
"resourcesGathered": {
"food": 100,
"wood": 0,
"metal": 0,
"stone": 0,
"vegetarianFood": 0
},
"percentMapExplored": 10
};
},
"GetSequences": function() {
return {
"unitsTrained": [0, 10],
"unitsLost": [0, 42],
"buildingsConstructed": [1, 3],
"buildingsCaptured": [3, 7],
"buildingsLost": [3, 10],
"civCentresBuilt": [4, 10],
"resourcesGathered": {
"food": [5, 100],
"wood": [0, 0],
"metal": [0, 0],
"stone": [0, 0],
"vegetarianFood": [0, 0]
},
"treasuresCollected": [1, 20],
"lootCollected": [0, 2],
"percentMapExplored": [0, 10],
"teamPercentMapExplored": [0, 10],
"percentMapControlled": [0, 10],
"teamPercentMapControlled": [0, 10],
"peakPercentOfMapControlled": [0, 10],
"teamPeakPercentOfMapControlled": [0, 10]
};
},
"IncreaseTrainedUnitsCounter": function() { return 1; },
"IncreaseConstructedBuildingsCounter": function() { return 1; },
"IncreaseBuiltCivCentresCounter": function() { return 1; }
});
AddMock(101, IID_Player, {
"GetName": function() { return "Player 2"; },
"GetCiv": function() { return "mace"; },
"GetColor": function() { return { "r": 1, "g": 0, "b": 0, "a": 1 }; },
"CanControlAllUnits": function() { return true; },
"GetPopulationCount": function() { return 40; },
"GetPopulationLimit": function() { return 30; },
"GetMaxPopulation": function() { return 300; },
"GetResourceCounts": function() { return { "food": 200 }; },
"GetResourceGatherers": function() { return { "food": 3 }; },
"GetPanelEntities": function() { return []; },
"IsTrainingBlocked": function() { return false; },
"GetState": function() { return "active"; },
"GetTeam": function() { return -1; },
"GetLockTeams": function() {return false; },
"GetCheatsEnabled": function() { return false; },
"GetDiplomacy": function() { return [-1, 1]; },
"IsAlly": function() { return true; },
"IsMutualAlly": function() {return false; },
"IsNeutral": function() { return false; },
"IsEnemy": function() { return false; },
"GetDisabledTemplates": function() { return {}; },
"GetDisabledTechnologies": function() { return {}; },
"CanBarter": function() { return false; },
"GetSpyCostMultiplier": function() { return 1; },
"HasSharedDropsites": function() { return false; },
"HasSharedLos": function() { return false; }
});
AddMock(101, IID_EntityLimits, {
"GetLimits": function() { return { "Bar": 20 }; },
"GetCounts": function() { return { "Bar": 0 }; },
"GetLimitChangers": function() { return { "Bar": {} }; },
"GetMatchCounts": function() { return { "Foo": 0 }; }
});
AddMock(101, IID_TechnologyManager, {
"IsTechnologyResearched": tech => tech == "phase_village",
"GetQueuedResearch": () => new Map(),
"GetStartedTechs": () => new Set(),
"GetResearchedTechs": () => new Set(),
"GetClassCounts": () => ({}),
"GetTypeCountsByClass": () => ({})
});
AddMock(101, IID_StatisticsTracker, {
"GetBasicStatistics": function() {
return {
"resourcesGathered": {
"food": 100,
"wood": 0,
"metal": 0,
"stone": 0,
"vegetarianFood": 0
},
"percentMapExplored": 10
};
},
"GetSequences": function() {
return {
"unitsTrained": [0, 10],
"unitsLost": [0, 9],
"buildingsConstructed": [0, 5],
"buildingsCaptured": [0, 7],
"buildingsLost": [0, 4],
"civCentresBuilt": [0, 1],
"resourcesGathered": {
"food": [0, 100],
"wood": [0, 0],
"metal": [0, 0],
"stone": [0, 0],
"vegetarianFood": [0, 0]
},
"treasuresCollected": [0, 0],
"lootCollected": [0, 0],
"percentMapExplored": [0, 10],
"teamPercentMapExplored": [0, 10],
"percentMapControlled": [0, 10],
"teamPercentMapControlled": [0, 10],
"peakPercentOfMapControlled": [0, 10],
"teamPeakPercentOfMapControlled": [0, 10]
};
},
"IncreaseTrainedUnitsCounter": function() { return 1; },
"IncreaseConstructedBuildingsCounter": function() { return 1; },
"IncreaseBuiltCivCentresCounter": function() { return 1; }
});
// 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(), {
"players": [
{
"name": "Player 1",
"civ": "gaia",
"color": { "r": 1, "g": 1, "b": 1, "a": 1 },
"controlsAll": false,
"popCount": 10,
"popLimit": 20,
"popMax": 200,
"panelEntities": [],
"resourceCounts": { "food": 100 },
"resourceGatherers": { "food": 1 },
"trainingBlocked": false,
"state": "active",
"team": -1,
"teamsLocked": false,
"cheatsEnabled": false,
"disabledTemplates": {},
"disabledTechnologies": {},
"hasSharedDropsites": false,
"hasSharedLos": false,
"spyCostMultiplier": 1,
"phase": "village",
"isAlly": [false, false],
"isMutualAlly": [false, false],
"isNeutral": [false, false],
"isEnemy": [true, true],
"entityLimits": { "Foo": 10 },
"entityCounts": { "Foo": 5 },
"matchEntityCounts": { "Bar": 0 },
"entityLimitChangers": { "Foo": {} },
"researchQueued": new Map(),
"researchStarted": new Set(),
"researchedTechs": new Set(),
"classCounts": {},
"typeCountsByClass": {},
"canBarter": false,
"barterPrices": {
"buy": { "food": 150 },
"sell": { "food": 25 }
},
"statistics": {
"resourcesGathered": {
"food": 100,
"wood": 0,
"metal": 0,
"stone": 0,
"vegetarianFood": 0
},
"percentMapExplored": 10
}
},
{
"name": "Player 2",
"civ": "mace",
"color": { "r": 1, "g": 0, "b": 0, "a": 1 },
"controlsAll": true,
"popCount": 40,
"popLimit": 30,
"popMax": 300,
"panelEntities": [],
"resourceCounts": { "food": 200 },
"resourceGatherers": { "food": 3 },
"trainingBlocked": false,
"state": "active",
"team": -1,
"teamsLocked": false,
"cheatsEnabled": false,
"disabledTemplates": {},
"disabledTechnologies": {},
"hasSharedDropsites": false,
"hasSharedLos": false,
"spyCostMultiplier": 1,
"phase": "village",
"isAlly": [true, true],
"isMutualAlly": [false, false],
"isNeutral": [false, false],
"isEnemy": [false, false],
"entityLimits": { "Bar": 20 },
"entityCounts": { "Bar": 0 },
"matchEntityCounts": { "Foo": 0 },
"entityLimitChangers": { "Bar": {} },
"researchQueued": new Map(),
"researchStarted": new Set(),
"researchedTechs": new Set(),
"classCounts": {},
"typeCountsByClass": {},
"canBarter": false,
"barterPrices": {
"buy": { "food": 150 },
"sell": { "food": 25 }
},
"statistics": {
"resourcesGathered": {
"food": 100,
"wood": 0,
"metal": 0,
"stone": 0,
"vegetarianFood": 0
},
"percentMapExplored": 10
}
}
],
"circularMap": false,
"timeElapsed": 0,
"victoryConditions": ["conquest", "wonder"],
"alliedVictory": false,
"maxWorldPopulation": undefined
});
TS_ASSERT_UNEVAL_EQUALS(cmp.GetExtendedSimulationState(), {
"players": [
{
"name": "Player 1",
"civ": "gaia",
"color": { "r": 1, "g": 1, "b": 1, "a": 1 },
"controlsAll": false,
"popCount": 10,
"popLimit": 20,
"popMax": 200,
"panelEntities": [],
"resourceCounts": { "food": 100 },
"resourceGatherers": { "food": 1 },
"trainingBlocked": false,
"state": "active",
"team": -1,
"teamsLocked": false,
"cheatsEnabled": false,
"disabledTemplates": {},
"disabledTechnologies": {},
"hasSharedDropsites": false,
"hasSharedLos": false,
"spyCostMultiplier": 1,
"phase": "village",
"isAlly": [false, false],
"isMutualAlly": [false, false],
"isNeutral": [false, false],
"isEnemy": [true, true],
"entityLimits": { "Foo": 10 },
"entityCounts": { "Foo": 5 },
"matchEntityCounts": { "Bar": 0 },
"entityLimitChangers": { "Foo": {} },
"researchQueued": new Map(),
"researchStarted": new Set(),
"researchedTechs": new Set(),
"classCounts": {},
"typeCountsByClass": {},
"canBarter": false,
"barterPrices": {
"buy": { "food": 150 },
"sell": { "food": 25 }
},
"statistics": {
"resourcesGathered": {
"food": 100,
"wood": 0,
"metal": 0,
"stone": 0,
"vegetarianFood": 0
},
"percentMapExplored": 10
},
"sequences": {
"unitsTrained": [0, 10],
"unitsLost": [0, 42],
"buildingsConstructed": [1, 3],
"buildingsCaptured": [3, 7],
"buildingsLost": [3, 10],
"civCentresBuilt": [4, 10],
"resourcesGathered": {
"food": [5, 100],
"wood": [0, 0],
"metal": [0, 0],
"stone": [0, 0],
"vegetarianFood": [0, 0]
},
"treasuresCollected": [1, 20],
"lootCollected": [0, 2],
"percentMapExplored": [0, 10],
"teamPercentMapExplored": [0, 10],
"percentMapControlled": [0, 10],
"teamPercentMapControlled": [0, 10],
"peakPercentOfMapControlled": [0, 10],
"teamPeakPercentOfMapControlled": [0, 10]
}
},
{
"name": "Player 2",
"civ": "mace",
"color": { "r": 1, "g": 0, "b": 0, "a": 1 },
"controlsAll": true,
"popCount": 40,
"popLimit": 30,
"popMax": 300,
"panelEntities": [],
"resourceCounts": { "food": 200 },
"resourceGatherers": { "food": 3 },
"trainingBlocked": false,
"state": "active",
"team": -1,
"teamsLocked": false,
"cheatsEnabled": false,
"disabledTemplates": {},
"disabledTechnologies": {},
"hasSharedDropsites": false,
"hasSharedLos": false,
"spyCostMultiplier": 1,
"phase": "village",
"isAlly": [true, true],
"isMutualAlly": [false, false],
"isNeutral": [false, false],
"isEnemy": [false, false],
"entityLimits": { "Bar": 20 },
"entityCounts": { "Bar": 0 },
"matchEntityCounts": { "Foo": 0 },
"entityLimitChangers": { "Bar": {} },
"researchQueued": new Map(),
"researchStarted": new Set(),
"researchedTechs": new Set(),
"classCounts": {},
"typeCountsByClass": {},
"canBarter": false,
"barterPrices": {
"buy": { "food": 150 },
"sell": { "food": 25 }
},
"statistics": {
"resourcesGathered": {
"food": 100,
"wood": 0,
"metal": 0,
"stone": 0,
"vegetarianFood": 0
},
"percentMapExplored": 10
},
"sequences": {
"unitsTrained": [0, 10],
"unitsLost": [0, 9],
"buildingsConstructed": [0, 5],
"buildingsCaptured": [0, 7],
"buildingsLost": [0, 4],
"civCentresBuilt": [0, 1],
"resourcesGathered": {
"food": [0, 100],
"wood": [0, 0],
"metal": [0, 0],
"stone": [0, 0],
"vegetarianFood": [0, 0]
},
"treasuresCollected": [0, 0],
"lootCollected": [0, 0],
"percentMapExplored": [0, 10],
"teamPercentMapExplored": [0, 10],
"percentMapControlled": [0, 10],
"teamPercentMapControlled": [0, 10],
"peakPercentOfMapControlled": [0, 10],
"teamPeakPercentOfMapControlled": [0, 10]
}
}
],
"circularMap": false,
"timeElapsed": 0,
"victoryConditions": ["conquest", "wonder"],
"alliedVictory": false,
"maxWorldPopulation": undefined
});
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; },
"HasSomeFormation": function() { return false; },
"GetFormationsList": function() { return []; },
});
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,
"hasSomeFormation": false,
"formations": [],
"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 }
}
});
Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Upkeep.js
===================================================================
--- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Upkeep.js (nonexistent)
+++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Upkeep.js (revision 25191)
@@ -0,0 +1,221 @@
+Resources = {
+ "GetCodes": () => ["food", "metal"],
+ "GetTradableCodes": () => ["food", "metal"],
+ "GetBarterableCodes": () => ["food", "metal"],
+ "GetResource": () => ({}),
+ "BuildSchema": (type) => {
+ let schema = "";
+ for (let res of Resources.GetCodes())
+ schema +=
+ "" +
+ "" +
+ "" +
+ "" +
+ "";
+ return "" + schema + "";
+ }
+};
+
+Engine.LoadComponentScript("interfaces/Player.js");
+Engine.LoadComponentScript("interfaces/StatisticsTracker.js");
+Engine.LoadComponentScript("interfaces/Timer.js");
+Engine.LoadComponentScript("interfaces/Upkeep.js");
+Engine.LoadComponentScript("Player.js");
+Engine.LoadComponentScript("Timer.js");
+Engine.LoadComponentScript("Upkeep.js");
+
+// Upkeep requires this function to be defined before the component is built.
+let ApplyValueModificationsToEntity = (valueName, currentValue, entity) => currentValue;
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+let testedEnt = 10;
+let turnLength = 0.2;
+let playerEnt = 1;
+let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer", {});
+
+let cmpUpkeep = ConstructComponent(testedEnt, "Upkeep", {
+ "Interval": "200",
+ "Rates": {
+ "food": "0",
+ "metal": "0"
+ }
+});
+
+let cmpPlayer = ConstructComponent(playerEnt, "Player", {
+ "SpyCostMultiplier": "1",
+ "BarterMultiplier": {
+ "Buy": {
+ "food": "1",
+ "metal": "1"
+ },
+ "Sell": {
+ "food": "1",
+ "metal": "1"
+ }
+ },
+});
+
+let QueryOwnerInterface = () => cmpPlayer;
+Engine.RegisterGlobal("QueryOwnerInterface", QueryOwnerInterface);
+Engine.RegisterGlobal("QueryPlayerIDInterface", () => null);
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 300, "metal": 300 });
+TS_ASSERT_EQUALS(cmpUpkeep.GetInterval(), 200);
+
+// Since there is no rate > 0, nothing should change.
+TS_ASSERT_UNEVAL_EQUALS(cmpUpkeep.GetRates(), {});
+TS_ASSERT_EQUALS(cmpUpkeep.ComputeRates(), false);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 300, "metal": 300 });
+
+// Test that only requiring food works.
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => {
+ if (valueName == "Upkeep/Rates/food")
+ return currentValue + 1;
+
+ return currentValue;
+};
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+// Calling OnValueModification will reset the timer, which can then be called, thus decreasing the resources of the player.
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_UNEVAL_EQUALS(cmpUpkeep.GetRates(), { "food": 1 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 299, "metal": 300 });
+TS_ASSERT_EQUALS(cmpUpkeep.ComputeRates(), true);
+
+// Reset the pay modification.
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => currentValue;
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_UNEVAL_EQUALS(cmpUpkeep.GetRates(), {});
+TS_ASSERT_EQUALS(cmpUpkeep.ComputeRates(), false);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 299, "metal": 300 });
+
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => {
+ if (valueName == "Upkeep/Interval")
+ return currentValue + 200;
+ if (valueName == "Upkeep/Rates/food")
+ return currentValue + 1;
+
+ return currentValue;
+};
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_EQUALS(cmpUpkeep.GetInterval(), 400);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 299, "metal": 300 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 298, "metal": 300 });
+
+// Interval becomes a normal timer, thus cancelled after the first execution.
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => {
+ if (valueName == "Upkeep/Interval")
+ return currentValue - 200;
+ if (valueName == "Upkeep/Rates/food")
+ return currentValue + 1;
+
+ return currentValue;
+};
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_EQUALS(cmpUpkeep.GetInterval(), 0);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 298, "metal": 300 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 297, "metal": 300 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 297, "metal": 300 });
+
+// Timer became invalidated, check whether it's recreated properly after that.
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => {
+ if (valueName == "Upkeep/Interval")
+ return currentValue - 100;
+ if (valueName == "Upkeep/Rates/food")
+ return currentValue + 1;
+
+ return currentValue;
+};
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_EQUALS(cmpUpkeep.GetInterval(), 100);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 295, "metal": 300 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 293, "metal": 300 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 291, "metal": 300 });
+
+// Value is now invalid, timer should be cancelled.
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => {
+ if (valueName == "Upkeep/Interval")
+ return currentValue - 201;
+ if (valueName == "Upkeep/Rates/food")
+ return currentValue + 1;
+
+ return currentValue;
+};
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_EQUALS(cmpUpkeep.GetInterval(), -1);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 291, "metal": 300 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 291, "metal": 300 });
+
+// Timer became invalidated, check whether it's recreated properly after that.
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => {
+ if (valueName == "Upkeep/Rates/food")
+ return currentValue + 1;
+
+ return currentValue;
+};
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_EQUALS(cmpUpkeep.GetInterval(), 200);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 290, "metal": 300 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 289, "metal": 300 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 288, "metal": 300 });
+
+// Test multiple upkeep resources.
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => {
+ if (valueName == "Upkeep/Rates/food")
+ return currentValue + 1;
+ if (valueName == "Upkeep/Rates/metal")
+ return currentValue + 2;
+
+ return currentValue;
+};
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_EQUALS(cmpUpkeep.GetInterval(), 200);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 287, "metal": 298 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 286, "metal": 296 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 285, "metal": 294 });
+
+// Test we don't go into negative resources.
+let cmpGUI = AddMock(SYSTEM_ENTITY, IID_GuiInterface, {
+ "PushNotification": () => {}
+});
+let notificationSpy = new Spy(cmpGUI, "PushNotification");
+ApplyValueModificationsToEntity = (valueName, currentValue, entity) => {
+ if (valueName == "Upkeep/Rates/food")
+ return currentValue + 1;
+
+ return currentValue;
+};
+Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity);
+cmpUpkeep.OnValueModification({ "component": "Upkeep" });
+TS_ASSERT_EQUALS(cmpUpkeep.GetInterval(), 200);
+cmpTimer.OnUpdate({ "turnLength": turnLength * 285 });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 0, "metal": 294 });
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 0, "metal": 294 });
+TS_ASSERT_EQUALS(notificationSpy._called, 1);
+cmpTimer.OnUpdate({ "turnLength": turnLength });
+TS_ASSERT_UNEVAL_EQUALS(cmpPlayer.GetResourceCounts(), { "food": 0, "metal": 294 });
+TS_ASSERT_EQUALS(notificationSpy._called, 2);
Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Upkeep.js
___________________________________________________________________
Added: svn:eol-style
## -0,0 +1 ##
+native
\ No newline at end of property
Added: svn:mime-type
## -0,0 +1 ##
+text/plain
\ No newline at end of property