Index: binaries/data/mods/public/gui/common/tooltips.js =================================================================== --- binaries/data/mods/public/gui/common/tooltips.js +++ binaries/data/mods/public/gui/common/tooltips.js @@ -377,21 +377,21 @@ let tooltipString = ""; if (template.Tooltip) { - tooltipAttributes.push("%(tooltip)s"); + tooltipAttributes.push(translate("%(tooltip)s")); tooltipString = translate(template.Tooltip); } let attackEffectsString = ""; if (template.Damage || template.Capture) { - tooltipAttributes.push("%(effects)s"); + tooltipAttributes.push(translate("%(effects)s")); attackEffectsString = attackEffectsDetails(template); } let intervalString = ""; if (template.Interval) { - tooltipAttributes.push("%(rate)s"); + tooltipAttributes.push(translate("%(rate)s")); intervalString = sprintf(translate("%(interval)s"), { "interval": attackRateDetails(+template.Interval) }); @@ -400,19 +400,34 @@ let durationString = ""; if (template.Duration) { - tooltipAttributes.push("%(duration)s"); + tooltipAttributes.push(translate("%(duration)s")); durationString = sprintf(translate("%(durName)s: %(duration)s"), { "durName": headerFont(translate("Duration")), "duration": getSecondsString((template._timeElapsed ? +template.Duration - template._timeElapsed : +template.Duration) / 1000), }); } - return sprintf(translate("%(statusName)s: " + tooltipAttributes.join(translate(commaFont(", ")))), { + let stackabilityString = ""; + if (template.Stackability && template.Stackability != "Ignore") + { + tooltipAttributes.push(translate("%(stackability)s")); + 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("%(statusName)s: %(statusInfo)s"), { "statusName": headerFont(translateWithContext("status effect", template.Name)), - "tooltip": tooltipString, - "effects": attackEffectsString, - "rate": intervalString, - "duration": durationString + "statusInfo": sprintf(translate(tooltipAttributes.join(commaFont(translate(", ")))), { + "tooltip": tooltipString, + "effects": attackEffectsString, + "rate": intervalString, + "duration": durationString, + "stackability": stackabilityString + }) }); } Index: binaries/data/mods/public/gui/session/selection_details.js =================================================================== --- binaries/data/mods/public/gui/session/selection_details.js +++ binaries/data/mods/public/gui/session/selection_details.js @@ -108,7 +108,9 @@ size.top = i * 18; size.bottom = i * 18 + 16; statusIcons[i].size = size; - i++; + + if (++i >= statusIcons.length) + break; } for (; i < statusIcons.length; ++i) statusIcons[i].hidden = true; Index: binaries/data/mods/public/gui/session/selection_panels_middle/single_details_area.xml =================================================================== --- binaries/data/mods/public/gui/session/selection_panels_middle/single_details_area.xml +++ binaries/data/mods/public/gui/session/selection_panels_middle/single_details_area.xml @@ -83,7 +83,7 @@ Rank - + Index: binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js =================================================================== --- binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js +++ binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js @@ -46,14 +46,30 @@ * * @param {string} statusName - The name of the status effect. * @param {object} data - The various effects and timings. + * @param {object} attackerData - The attacker and attackerOwner. */ StatusEffectsReceiver.prototype.AddStatus = function(statusName, data, attackerData) { if (this.activeStatusEffects[statusName]) { - // TODO: implement different behaviour when receiving the same status multiple times. - // For now, these are ignored. - return; + if (data.Stackability == "Ignore") + return; + if (data.Stackability == "Extend") + { + this.activeStatusEffects[statusName].Duration += data.Duration; + return; + } + if (data.Stackability == "Replace") + this.RemoveStatus(statusName); + else if (data.Stackability == "Stack") + { + let i = 0; + let temp; + do + temp = statusName + "_" + i++; + while (!!this.activeStatusEffects[temp]); + statusName = temp; + } } this.activeStatusEffects[statusName] = {}; Index: binaries/data/mods/public/simulation/components/tests/test_Attack.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Attack.js +++ binaries/data/mods/public/simulation/components/tests/test_Attack.js @@ -107,7 +107,30 @@ "Capture": 8, "MaxRange": 10, }, - "Slaughter": {} + "Slaughter": {}, + "StatusEffect": { + "ApplyStatus": { + "StatusInternalName": { + "Name": "StatusShownName", + "Tooltip": "StatusTooltip", + "Duration": 5000, + "Stackability": "Stacks", + "Modifiers": { + "SE": { + "Paths": { + "_string": "Health/Max" + }, + "Affects": { + "_string": "Unit" + }, + "Add": 10 + } + } + } + }, + "MinRange": 10, + "MaxRange": 80 + } }); let defender = ++entityID; @@ -174,6 +197,29 @@ } }); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("StatusEffect"), { + "ApplyStatus": { + "StatusInternalName": { + "Name": "StatusShownName", + "Tooltip": "StatusTooltip", + "Duration": 5000, + "Interval": 0, + "Stackability": "Stacks", + "Modifiers": { + "SE": { + "Paths": { + "_string": "Health/Max" + }, + "Affects": { + "_string": "Unit" + }, + "Add": 10 + } + } + } + } + }); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("Ranged"), { "prepare": 300, "repeat": 500 Index: binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js +++ binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js @@ -1,32 +1,43 @@ +Engine.LoadHelperScript("MultiKeyMap.js"); +Engine.LoadHelperScript("Player.js"); +Engine.LoadHelperScript("ValueModification.js"); +Engine.LoadComponentScript("interfaces/Health.js"); +Engine.LoadComponentScript("interfaces/ModifiersManager.js"); Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); Engine.LoadComponentScript("interfaces/Timer.js"); +Engine.LoadComponentScript("Health.js"); +Engine.LoadComponentScript("ModifiersManager.js"); Engine.LoadComponentScript("StatusEffectsReceiver.js"); Engine.LoadComponentScript("Timer.js"); -var target = 42; -var cmpStatusReceiver; -var cmpTimer; -var dealtDamage; -var enemyEntity = 4; -var enemy = 2; +let target = 42; +let cmpStatusReceiver = ConstructComponent(target, "StatusEffectsReceiver"); +let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); +let dealtDamage; +let enemyEntity = 4; +let enemy = 2; + +let Attacking = { + "HandleAttackEffects": (_, attackData) => { + for (let type in attackData.Damage) + dealtDamage += attackData.Damage[type]; + } +}; +Engine.RegisterGlobal("Attacking", Attacking); -function setup() +function reset() { - cmpStatusReceiver = ConstructComponent(target, "StatusEffectsReceiver"); - cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); + for (let status of Object.keys(cmpStatusReceiver.GetActiveStatuses())) + cmpStatusReceiver.RemoveStatus(status); dealtDamage = 0; } function testInflictEffects() { - setup(); + reset(); let statusName = "Burn"; - let Attacking = { - "HandleAttackEffects": (_, attackData) => { dealtDamage += attackData.Damage[statusName]; } - }; - Engine.RegisterGlobal("Attacking", Attacking); - // damage scheduled: 0, 10, 20 sec + // Damage scheduled: 0, 10, 20 seconds. cmpStatusReceiver.AddStatus(statusName, { "Duration": 20000, "Interval": 10000, @@ -59,16 +70,9 @@ function testMultipleEffects() { - setup(); - let Attacking = { - "HandleAttackEffects": (_, attackData) => { - if (attackData.Damage.Burn) dealtDamage += attackData.Damage.Burn; - if (attackData.Damage.Poison) dealtDamage += attackData.Damage.Poison; - }, - }; - Engine.RegisterGlobal("Attacking", Attacking); + reset(); - // damage scheduled: 0, 1, 2, 10 sec + // Damage scheduled: 0, 1, 2, 10 seconds. cmpStatusReceiver.ApplyStatus({ "Burn": { "Duration": 20000, @@ -103,14 +107,10 @@ function testRemoveStatus() { - setup(); + reset(); let statusName = "Poison"; - let Attacking = { - "HandleAttackEffects": (_, attackData) => { dealtDamage += attackData.Damage[statusName]; } - }; - Engine.RegisterGlobal("Attacking", Attacking); - // damage scheduled: 0, 10, 20 sec + // Damage scheduled: 0, 10, 20 seconds. cmpStatusReceiver.AddStatus(statusName, { "Duration": 20000, "Interval": 10000, @@ -133,3 +133,242 @@ } testRemoveStatus(); + +function testModificationStatus() +{ + reset(); + + AddMock(target, IID_Identity, { + "GetClassesList": () => ["AffectedClass"] + }); + let cmpModifiersManager = ConstructComponent(SYSTEM_ENTITY, "ModifiersManager"); + + let maxHealth = 100; + AddMock(target, IID_Health, { + "GetMaxHitpoints": () => ApplyValueModificationsToEntity("Health/Max", maxHealth, target) + }); + + let statusName = "Haste"; + let factor = 0.5; + cmpStatusReceiver.AddStatus(statusName, { + "Duration": 5000, + "Modifiers": { + [statusName]: { + "Paths": { + "_string": "Health/Max" + }, + "Affects": { + "_string": "AffectedClass" + }, + "Multiply": factor + } + } + }, + { + "entity": enemyEntity, + "owner": enemy, + }); + + let cmpHealth = Engine.QueryInterface(target, IID_Health); + // Test that the modification is applied. + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth * factor); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth * factor); + + // Test that the modification is removed after the appropriate time. + cmpTimer.OnUpdate({ "turnLength": 4 }); + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth); + + + // Test addition. + let addition = 50; + cmpStatusReceiver.AddStatus(statusName, { + "Duration": 5000, + "Modifiers": { + [statusName]: { + "Paths": { + "_string": "Health/Max" + }, + "Affects": { + "_string": "AffectedClass" + }, + "Add": addition + } + } + }, + { + "entity": enemyEntity, + "owner": enemy, + }); + + // Test that the addition modification is applied. + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth + addition); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth + addition); + + // Test that the modification is removed after the appropriate time. + cmpTimer.OnUpdate({ "turnLength": 4 }); + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth); + + + // Test replacement. + let newValue = 50; + cmpStatusReceiver.AddStatus(statusName, { + "Duration": 5000, + "Modifiers": { + [statusName]: { + "Paths": { + "_string": "Health/Max" + }, + "Affects": { + "_string": "AffectedClass" + }, + "Replace": newValue + } + } + }, + { + "entity": enemyEntity, + "owner": enemy, + }); + + // Test that the replacement modification is applied. + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), newValue); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), newValue); + + // Test that the modification is removed after the appropriate time. + cmpTimer.OnUpdate({ "turnLength": 4 }); + TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), maxHealth); +} + +testModificationStatus(); + +function applyStatus(stackability) +{ + cmpStatusReceiver.ApplyStatus({ + "randomName": { + "Duration": 3000, + "Interval": 1000, + "Damage": { + "randomName": 1 + }, + "Stackability": stackability + } + }); +} + +function testStackabilityIgnore() +{ + reset(); + + applyStatus("Ignore"); + + // 1 Second: 1 update and lateness. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 2); + + applyStatus("Ignore"); + + // 2 Seconds. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 3); + + // 3 Seconds: finished in previous turn. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 3); +} + +function testStackabilityExtend() +{ + reset(); + + applyStatus("Extend"); + + // 1 Second: 1 update and lateness. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 2); + + // Add 3 seconds. + applyStatus("Extend"); + + // 2 Seconds. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 3); + + // 3 Seconds: extended in previous turn. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 4); + + // 4 Seconds. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 5); + + // 5 Seconds. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 6); + + // 6 Seconds: finished in previous turn (3 + 3). + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 6); +} + +function testStackabilityReplace() +{ + reset(); + + applyStatus("Replace"); + + // 1 Second: 1 update and lateness. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 2); + + applyStatus("Replace"); + + // 2 Seconds: 1 update and lateness of the new status. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 4); + + // 3 Seconds. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 5); + + // 4 Seconds: finished in previous turn. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 5); +} + +function testStackabilityStack() +{ + reset(); + + applyStatus("Stack"); + + // 1 Second: 1 update and lateness. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 2); + + applyStatus("Stack"); + + // 2 Seconds: 1 damage from the previous status + 2 from the new (1 turn + lateness). + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 5); + + // 3 Seconds: first one finished in the previous turn, +1 from the new. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 6); + + // 4 Seconds: new status finished in previous turn. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 6); +} + +function testStackability() +{ + testStackabilityIgnore(); + testStackabilityExtend(); + testStackabilityReplace(); + testStackabilityStack(); +} + +testStackability(); Index: binaries/data/mods/public/simulation/data/technologies/advanced_unit_bonus.json =================================================================== --- binaries/data/mods/public/simulation/data/technologies/advanced_unit_bonus.json +++ binaries/data/mods/public/simulation/data/technologies/advanced_unit_bonus.json @@ -23,7 +23,9 @@ { "value": "Loot/wood", "multiply": 1.2 }, { "value": "Loot/stone", "multiply": 1.2 }, { "value": "Loot/metal", "multiply": 1.2 }, - { "value": "Loot/xp", "multiply": 1.2 } + { "value": "Loot/xp", "multiply": 1.2 }, + { "value": "Attack/Ranged/ApplyStatus/Bogus/Duration", "multiply": 2 }, + { "value": "Attack/Ranged/ApplyStatus/AnotherBogus/Modifiers/Resiliance/Add", "multiply": 2 } ], "affects": ["Advanced Unit", "Elite Unit"] } Index: binaries/data/mods/public/simulation/helpers/Attacking.js =================================================================== --- binaries/data/mods/public/simulation/helpers/Attacking.js +++ binaries/data/mods/public/simulation/helpers/Attacking.js @@ -26,6 +26,14 @@ "" + "" + "" + + "" + + "" + + "Ignore" + + "Extend" + + "Replace" + + "Stack" + + "" + + "" + "" + "" + "" + @@ -106,7 +114,40 @@ ret.Capture = ApplyValueModificationsToEntity(valueModifRoot + "/Capture", +(template.Capture || 0), entity); if (template.ApplyStatus) - ret.ApplyStatus = template.ApplyStatus; + { + ret.ApplyStatus = {}; + for (let effect in template.ApplyStatus) + { + let statusTemplate = template.ApplyStatus[effect]; + ret.ApplyStatus[effect] = { + "Name": statusTemplate.Name, + "Tooltip": statusTemplate.Tooltip, + "Duration": ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Duration", +(statusTemplate.Duration || 0), entity), + "Interval": ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Interval", +(statusTemplate.Interval || 0), entity), + "Stackability": statusTemplate.Stackability + }; + Object.assign(ret.ApplyStatus[effect], this.GetAttackEffectsData(valueModifRoot + "/ApplyStatus" + effect, statusTemplate, entity)); + if (statusTemplate.Modifiers) + { + let modifiers = {}; + for (let modifier in statusTemplate.Modifiers) + { + let modifierTemplate = statusTemplate.Modifiers[modifier]; + modifiers[modifier] = { + "Paths": modifierTemplate.Paths, + "Affects": modifierTemplate.Affects + }; + if (modifierTemplate.Add !== undefined) + modifiers[modifier].Add = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Add", +modifierTemplate.Add, entity); + if (modifierTemplate.Multiply !== undefined) + modifiers[modifier].Multiply = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Multiply", +modifierTemplate.Multiply, entity); + if (modifierTemplate.Replace !== undefined) + modifiers[modifier].Replace = modifierTemplate.Replace; + } + ret.ApplyStatus[effect].Modifiers = modifiers; + } + } + } if (template.Bonuses) ret.Bonuses = template.Bonuses; Index: binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_archer.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_archer.xml +++ binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_archer.xml @@ -11,6 +11,51 @@ 6.0 0 + + + StatusEffect's name 1 + Extend + Some bogus bonus tooltip describing the effect of the modifiers. This speeds up the entity but halves the health and resource gathering speed. + 15000 + + + UnitMotion/WalkSpeed + Unit + 20 + + + Health/Max ResourceGatherer/BaseSpeed + Unit Structure + 0.5 + + + + + StatusEffect's name 2 + Replace + 10000 + 1000 + + 1 + + + + StatusEffect's name 3 + Stack + Some bogus bonus tooltip describing the effect of the modifiers. This increases the armour permanently. + 1000 + + 1 + + + + Armour/Hack Armour/Pierce Armour/Crush + Unit + 20 + + + + 72.0 0.0 600