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 @@ -407,12 +407,25 @@ }); } + let stackabilityString = ""; + if (template.Stackability && template.Stackability != "Ignore") + { + tooltipAttributes.push("%(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: " + tooltipAttributes.join(translate(commaFont(", ")))), { "statusName": headerFont(translateWithContext("status effect", template.Name)), "tooltip": tooltipString, "effects": attackEffectsString, "rate": intervalString, - "duration": durationString + "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_StatusEffectsReceiver.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js +++ binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js @@ -1,5 +1,12 @@ +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"); @@ -68,7 +75,7 @@ }; Engine.RegisterGlobal("Attacking", Attacking); - // damage scheduled: 0, 1, 2, 10 sec + // Damage scheduled: 0, 1, 2, 10 seconds. cmpStatusReceiver.ApplyStatus({ "Burn": { "Duration": 20000, @@ -110,7 +117,7 @@ }; 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 +140,203 @@ } testRemoveStatus(); + +function testModificationStatus() +{ + setup(); + + 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); +} + +testModificationStatus(); + +function applyStatus(stackability) +{ + cmpStatusReceiver.ApplyStatus({ + "Burn": { + "Duration": 3000, + "Interval": 1000, + "Damage": { + "Burn": 1 + }, + "Stackability": stackability + } + }); +} + +function testStackabilityIgnore() +{ + setup(); + let Attacking = { + "HandleAttackEffects": (_, attackData) => { + if (attackData.Damage.Burn) dealtDamage += attackData.Damage.Burn; + }, + }; + Engine.RegisterGlobal("Attacking", Attacking); + + 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() +{ + setup(); + let Attacking = { + "HandleAttackEffects": (_, attackData) => { + if (attackData.Damage.Burn) dealtDamage += attackData.Damage.Burn; + }, + }; + Engine.RegisterGlobal("Attacking", Attacking); + + applyStatus("Extend"); + + // 1 Second: 1 update and lateness. + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_EQUALS(dealtDamage, 2); + + applyStatus("Extend"); // +3 Seconds. + + // 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() +{ + setup(); + let Attacking = { + "HandleAttackEffects": (_, attackData) => { + if (attackData.Damage.Burn) dealtDamage += attackData.Damage.Burn; + }, + }; + Engine.RegisterGlobal("Attacking", Attacking); + + 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() +{ + setup(); + let Attacking = { + "HandleAttackEffects": (_, attackData) => { + if (attackData.Damage.Burn) dealtDamage += attackData.Damage.Burn; + }, + }; + Engine.RegisterGlobal("Attacking", Attacking); + + 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 || 1000), 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) + modifiers[modifier].Add = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Add", +(modifierTemplate.Add || 0), entity); + if (modifierTemplate.Multiply) + modifiers[modifier].Multiply = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Multiply", +(modifierTemplate.Multiply || 0), entity); + if (modifierTemplate.Replace) + 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