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,10 @@ size.top = i * 18; size.bottom = i * 18 + 16; statusIcons[i].size = size; - i++; + + // We can currently only show five icons. + if (++i > 4) + 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,35 @@ * * @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") + { + for (let i = 0; true; ++i) + { + let temp = statusName + "_" + i; + if (!this.activeStatusEffects[temp]) + { + statusName = temp; + break; + } + } + } } 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 @@ -68,7 +68,7 @@ }; Engine.RegisterGlobal("Attacking", Attacking); - // damage scheduled: 0, 1, 2, 10 sec + // Damage scheduled: 0, 1, 2, 10 seconds. cmpStatusReceiver.ApplyStatus({ "Burn": { "Duration": 20000, @@ -133,3 +133,155 @@ } testRemoveStatus(); + +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,37 @@ ret.Capture = ApplyValueModificationsToEntity(valueModifRoot + "/Capture", +(template.Capture || 0), entity); if (template.ApplyStatus) - ret.ApplyStatus = template.ApplyStatus; + { + ret.ApplyStatus = {}; + for (let effect in template.ApplyStatus) + { + ret.ApplyStatus[effect] = { + "Name": template.ApplyStatus[effect].Name, + "Tooltip": template.ApplyStatus[effect].Tooltip, + "Duration": ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Duration", +(template.ApplyStatus[effect].Duration || 0), entity), + "Interval": ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Interval", +(template.ApplyStatus[effect].Interval || 1000), entity), + "Stackability": template.ApplyStatus[effect].Stackability + }; + Object.assign(ret.ApplyStatus[effect], this.GetAttackEffectsData(valueModifRoot + "/ApplyStatus" + effect, template.ApplyStatus[effect], entity)); + if (template.ApplyStatus[effect].Modifiers) + { + ret.ApplyStatus[effect].Modifiers = {}; + for (let modifier in template.ApplyStatus[effect].Modifiers) + { + ret.ApplyStatus[effect].Modifiers[modifier] = { + "Paths": template.ApplyStatus[effect].Modifiers[modifier].Paths, + "Affects": template.ApplyStatus[effect].Modifiers[modifier].Affects + }; + if (template.ApplyStatus[effect].Modifiers[modifier].Add) + ret.ApplyStatus[effect].Modifiers[modifier].Add = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Add", +(template.ApplyStatus[effect].Modifiers[modifier].Add || 0), entity); + if (template.ApplyStatus[effect].Modifiers[modifier].Multiply) + ret.ApplyStatus[effect].Modifiers[modifier].Multiply = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Multiply", +(template.ApplyStatus[effect].Modifiers[modifier].Multiply || 0), entity); + if (template.ApplyStatus[effect].Modifiers[modifier].Replace) + ret.ApplyStatus[effect].Modifiers[modifier].Replace = template.ApplyStatus[effect].Modifiers[modifier].Replace; + } + } + } + } 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