From e2f4ce0649fc8029c76534cfb599c78378811c24 Mon Sep 17 00:00:00 2001 From: Freagarach Date: Sun, 4 Apr 2021 06:52:20 +0000 Subject: [PATCH] Add Upkeep component. Adds a new component effectively handling negative resource trickles. Prevents resources from going negative and provides an effect for not being able to pay. The effect chosen for 0 A.D. is having the entity not being controllable. Not used in the public mod itself, but it is in quite some mods. Based on an idea of @Nescio Differential revision: D1323 Comments by: @Angen, (@smiley,) @Stan This was SVN commit r25191. --- .../mods/public/globalscripts/Templates.js | 10 + .../data/mods/public/gui/common/tooltips.js | 24 ++ .../gui/reference/common/ReferencePage.js | 1 + .../public/gui/session/selection_details.js | 1 + .../simulation/components/GuiInterface.js | 7 + .../public/simulation/components/Upkeep.js | 150 ++++++++++++ .../components/interfaces/Upkeep.js | 1 + .../components/tests/test_GuiInterface.js | 1 + .../components/tests/test_Upkeep.js | 221 ++++++++++++++++++ 9 files changed, 416 insertions(+) create mode 100644 binaries/data/mods/public/simulation/components/Upkeep.js create mode 100644 binaries/data/mods/public/simulation/components/interfaces/Upkeep.js create mode 100644 binaries/data/mods/public/simulation/components/tests/test_Upkeep.js diff --git a/binaries/data/mods/public/globalscripts/Templates.js b/binaries/data/mods/public/globalscripts/Templates.js index 295b7e963e..411bfdf847 100644 --- a/binaries/data/mods/public/globalscripts/Templates.js +++ b/binaries/data/mods/public/globalscripts/Templates.js @@ -496,6 +496,16 @@ function GetTemplateDataHelper(template, player, auraTemplates, modifiers = {}) "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 = { diff --git a/binaries/data/mods/public/gui/common/tooltips.js b/binaries/data/mods/public/gui/common/tooltips.js index b017436156..dbce8ffbb4 100644 --- a/binaries/data/mods/public/gui/common/tooltips.js +++ b/binaries/data/mods/public/gui/common/tooltips.js @@ -846,6 +846,30 @@ function getResourceTrickleTooltip(template) }); } +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', diff --git a/binaries/data/mods/public/gui/reference/common/ReferencePage.js b/binaries/data/mods/public/gui/reference/common/ReferencePage.js index 02a9d6ee35..87843063a0 100644 --- a/binaries/data/mods/public/gui/reference/common/ReferencePage.js +++ b/binaries/data/mods/public/gui/reference/common/ReferencePage.js @@ -65,5 +65,6 @@ ReferencePage.prototype.StatsFunctions = [ getTreasureTooltip, getPopulationBonusTooltip, getResourceTrickleTooltip, + getUpkeepTooltip, getLootTooltip ]; diff --git a/binaries/data/mods/public/gui/session/selection_details.js b/binaries/data/mods/public/gui/session/selection_details.js index 83fcc96663..5ad741dd14 100644 --- a/binaries/data/mods/public/gui/session/selection_details.js +++ b/binaries/data/mods/public/gui/session/selection_details.js @@ -329,6 +329,7 @@ function displaySingle(entState) getPopulationBonusTooltip, getProjectilesTooltip, getResourceTrickleTooltip, + getUpkeepTooltip, getLootTooltip ].map(func => func(entState)).filter(tip => tip).join("\n"); if (detailedTooltip) diff --git a/binaries/data/mods/public/simulation/components/GuiInterface.js b/binaries/data/mods/public/simulation/components/GuiInterface.js index 479be904f1..d8ce4c0762 100644 --- a/binaries/data/mods/public/simulation/components/GuiInterface.js +++ b/binaries/data/mods/public/simulation/components/GuiInterface.js @@ -576,6 +576,13 @@ GuiInterface.prototype.GetEntityState = function(player, ent) "run": cmpUnitMotion.GetWalkSpeed() * cmpUnitMotion.GetRunMultiplier() }; + let cmpUpkeep = Engine.QueryInterface(ent, IID_Upkeep); + if (cmpUpkeep) + ret.upkeep = { + "interval": cmpUpkeep.GetInterval(), + "rates": cmpUpkeep.GetRates() + }; + return ret; }; diff --git a/binaries/data/mods/public/simulation/components/Upkeep.js b/binaries/data/mods/public/simulation/components/Upkeep.js new file mode 100644 index 0000000000..5085e7f2ac --- /dev/null +++ b/binaries/data/mods/public/simulation/components/Upkeep.js @@ -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); diff --git a/binaries/data/mods/public/simulation/components/interfaces/Upkeep.js b/binaries/data/mods/public/simulation/components/interfaces/Upkeep.js new file mode 100644 index 0000000000..5560327413 --- /dev/null +++ b/binaries/data/mods/public/simulation/components/interfaces/Upkeep.js @@ -0,0 +1 @@ +Engine.RegisterInterface("Upkeep"); diff --git a/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js b/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js index b8948869cb..c8339089f9 100644 --- a/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js +++ b/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js @@ -39,6 +39,7 @@ 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"); diff --git a/binaries/data/mods/public/simulation/components/tests/test_Upkeep.js b/binaries/data/mods/public/simulation/components/tests/test_Upkeep.js new file mode 100644 index 0000000000..11e93e3dce --- /dev/null +++ b/binaries/data/mods/public/simulation/components/tests/test_Upkeep.js @@ -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);