Index: binaries/data/mods/public/simulation/components/ResourceGatherer.js =================================================================== --- binaries/data/mods/public/simulation/components/ResourceGatherer.js +++ binaries/data/mods/public/simulation/components/ResourceGatherer.js @@ -184,25 +184,109 @@ }; /** + * Starts gathering on the specified target. + * @param {number} target - The target to gather from. + * @return {number} - The gathering rate. + */ +ResourceGatherer.prototype.StartGathering = function(target) +{ + // We were already gathering! + if (this.target) + this.StopGathering(); + + let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); + if (!cmpOwnership) + return 0; + + // Check if the resource is full. + // We'll only be added if we're not already in. + let cmpResourceSupply = Engine.QueryInterface(target, IID_ResourceSupply); + if (!cmpResourceSupply || !cmpResourceSupply.AddGatherer(cmpOwnership.GetOwner(), this.entity)) + return 0; + + let rate = this.GetTargetGatherRate(target); + if (!rate) + return 0; + + // Try to gather a treasure. + if (this.TryInstantGather(target)) + return 0; + + // Calculate timing based on gather rates. + // This allows the gather rate to control how often we gather, instead of how much. + let timing = 1000 / rate; + this.target = target; + + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + this.gatherTimer = cmpTimer.SetInterval(this.entity, IID_ResourceGatherer, "PerformGather", timing, timing, null); + + return rate; +}; + +/** + * Stop gathering. + */ +ResourceGatherer.prototype.StopGathering = function() +{ + if (this.gatherTimer) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + cmpTimer.CancelTimer(this.gatherTimer); + delete this.gatherTimer; + } + + if (!this.target) + return; + + let cmpResourceSupply = Engine.QueryInterface(this.target, IID_ResourceSupply); + if (cmpResourceSupply) + cmpResourceSupply.RemoveGatherer(this.entity); + + delete this.target; +}; + +/** * Gather from the target entity. This should only be called after a successful range check, * and if the target has a compatible ResourceSupply. * Call interval will be determined by gather rate, so always gather 1 amount when called. */ -ResourceGatherer.prototype.PerformGather = function(target) +ResourceGatherer.prototype.PerformGather = function() { - if (!this.GetTargetGatherRate(target)) - return { "exhausted": true }; + if (!this.target) + { + this.StopGathering(); + return; + } + if (!this.GetTargetGatherRate(this.target)) + { + this.StopGathering(); + Engine.PostMessage(this.entity, MT_GatheringStateChanged, { + "exhausted": true, + "filled": undefined + }); + return; + } - let gatherAmount = 1; + let cmpResourceSupply = Engine.QueryInterface(this.target, IID_ResourceSupply); + if (!cmpResourceSupply) + { + this.StopGathering(); + return; + } - let cmpResourceSupply = Engine.QueryInterface(target, IID_ResourceSupply); + let gatherAmount = 1; let type = cmpResourceSupply.GetType(); - // Initialise the carried count if necessary + // If we've already got some resources but they're the wrong type, + // drop them first to ensure we're only ever carrying one type. + if (this.IsCarryingAnythingExcept(type.generic)) + this.DropResources(); + + // Initialise the carried count if necessary. if (!this.carrying[type.generic]) this.carrying[type.generic] = 0; - // Find the maximum so we won't exceed our capacity + // Find the maximum so we won't exceed our capacity. let maxGathered = this.GetCapacity(type.generic) - this.carrying[type.generic]; let status = cmpResourceSupply.TakeResources(Math.min(gatherAmount, maxGathered)); @@ -213,19 +297,22 @@ // Update stats of how much the player collected. // (We have to do it here rather than at the dropsite, because we - // need to know what subtype it was) + // need to know what subtype it was.) let cmpStatisticsTracker = QueryOwnerInterface(this.entity, IID_StatisticsTracker); if (cmpStatisticsTracker) cmpStatisticsTracker.IncreaseResourceGatheredCounter(type.generic, status.amount, type.specific); Engine.PostMessage(this.entity, MT_ResourceCarryingChanged, { "to": this.GetCarryingStatus() }); - - return { - "amount": status.amount, - "exhausted": status.exhausted, - "filled": this.carrying[type.generic] >= this.GetCapacity(type.generic) - }; + let filled = !this.CanCarryMore(type.generic); + if (status.exhausted || filled) + { + this.StopGathering(); + Engine.PostMessage(this.entity, MT_GatheringStateChanged, { + "exhausted": status.exhausted, + "filled": filled + }); + } }; /** @@ -236,14 +323,14 @@ ResourceGatherer.prototype.GetTargetGatherRate = function(target) { let cmpResourceSupply = QueryMiragedInterface(target, IID_ResourceSupply); - if (!cmpResourceSupply) + if (!cmpResourceSupply || cmpResourceSupply.GetCurrentAmount() <= 0) return 0; let type = cmpResourceSupply.GetType(); let rate = 0; if (type.specific) - rate = this.GetGatherRate(type.generic+"."+type.specific); + rate = this.GetGatherRate(type.generic + "." + type.specific); if (rate == 0 && type.generic) rate = this.GetGatherRate(type.generic); @@ -252,7 +339,7 @@ // Apply diminishing returns with more gatherers, for e.g. infinite farms. For most resources this has no effect // (GetDiminishingReturns will return null). We can assume that for resources that are miraged this is the case - // (else just add the diminishing returns data to the mirage data and remove the early return above) + // (else just add the diminishing returns data to the mirage data and remove the early return above). let diminishingReturns = cmpResourceSupply.GetDiminishingReturns(); if (diminishingReturns) rate *= diminishingReturns; Index: binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- binaries/data/mods/public/simulation/components/UnitAI.js +++ binaries/data/mods/public/simulation/components/UnitAI.js @@ -2230,31 +2230,25 @@ "enter": function() { this.gatheringTarget = this.order.data.target || INVALID_ENTITY; // deleted in "leave". - // Check if the resource is full. - // Will only be added if we're not already in. - let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); - let cmpSupply; - if (cmpOwnership) - cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); - if (!cmpSupply || !cmpSupply.AddGatherer(cmpOwnership.GetOwner(), this.entity)) - { - this.SetNextState("FINDINGNEWTARGET"); - return true; - } - // If this order was forced, the player probably gave it, but now we've reached the target // switch to an unforced order (can be interrupted by attacks) this.order.data.force = false; this.order.data.autoharvest = true; + let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); + if (!cmpResourceGatherer) + { + this.FinishOrder(); + return true; + } + // Calculate timing based on gather rates // This allows the gather rate to control how often we gather, instead of how much. - let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); - let rate = cmpResourceGatherer.GetTargetGatherRate(this.gatheringTarget); + let rate = cmpResourceGatherer.StartGathering(this.gatheringTarget); if (!rate) { - // Try to find another target if the current one stopped existing + // Try to find another target if the current one stopped existing. if (!Engine.QueryInterface(this.gatheringTarget, IID_Identity)) { this.SetNextState("FINDINGNEWTARGET"); @@ -2289,33 +2283,62 @@ "leave": function() { this.StopTimer(); - // Don't use ownership because this is called after a conversion/resignation - // and the ownership would be invalid then. - let cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); - if (cmpSupply) - cmpSupply.RemoveGatherer(this.entity); + let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); + if (cmpResourceGatherer) + cmpResourceGatherer.StopGathering(); delete this.gatheringTarget; this.ResetAnimation(); }, + "GatheringStateChanged": function(msg) { + let status = msg.data; + + // If we've collected as many resources as possible, + // return to the nearest dropsite. + if (status.filled) + { + let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); + if (!cmpResourceGatherer) + { + this.FinishOrder(); + return; + } + + let nearestDropsite = this.FindNearestDropsite(cmpResourceGatherer.GetLastCarriedType().generic); + if (nearestDropsite) + { + // (Keep this Gather order on the stack so we'll + // continue gathering after returning) + // However mark our target as invalid if it's exhausted, + // so we don't waste time trying to gather from it. + if (status.exhausted) + this.order.data.target = INVALID_ENTITY; + this.PushOrderFront("ReturnResource", { "target": nearestDropsite, "force": false }); + return; + } + + // Oh no, couldn't find any dropsites. Give up on gathering. + this.FinishOrder(); + return; + } + + // Find a new target if the current one is exhausted. + if (status.exhausted) + this.SetNextState("FINDINGNEWTARGET"); + }, + "Timer": function(msg) { let resourceTemplate = this.order.data.template; let resourceType = this.order.data.type; - let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); - if (!cmpOwnership) - return; - // TODO: we are leaking information here - if the target died in FOW, we'll know it's dead // straight away. // Seems one would have to listen to ownership changed messages to make it work correctly // but that's likely prohibitively expansive performance wise. - let cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); // If we can't gather from the target, find a new one. - if (!cmpSupply || !cmpSupply.IsAvailable(cmpOwnership.GetOwner(), this.entity) || - !this.CanGather(this.gatheringTarget)) + if (!this.CanGather(this.gatheringTarget)) { this.SetNextState("FINDINGNEWTARGET"); return; @@ -2339,49 +2362,7 @@ return; } - // Gather the resources: - - let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); - - // Try to gather treasure - if (cmpResourceGatherer.TryInstantGather(this.gatheringTarget)) - return; - - // If we've already got some resources but they're the wrong type, - // drop them first to ensure we're only ever carrying one type - if (cmpResourceGatherer.IsCarryingAnythingExcept(resourceType.generic)) - cmpResourceGatherer.DropResources(); - this.FaceTowardsTarget(this.order.data.target); - - // Collect from the target - let status = cmpResourceGatherer.PerformGather(this.gatheringTarget); - - // If we've collected as many resources as possible, - // return to the nearest dropsite - if (status.filled) - { - let nearestDropsite = this.FindNearestDropsite(resourceType.generic); - if (nearestDropsite) - { - // (Keep this Gather order on the stack so we'll - // continue gathering after returning) - // However mark our target as invalid if it's exhausted, so we don't waste time - // trying to gather from it. - if (status.exhausted) - this.order.data.target = INVALID_ENTITY; - this.PushOrderFront("ReturnResource", { "target": nearestDropsite, "force": false }); - return; - } - - // Oh no, couldn't find any drop sites. Give up on gathering. - this.FinishOrder(); - return; - } - - // Find a new target if the current one is exhausted - if (status.exhausted) - this.SetNextState("FINDINGNEWTARGET"); }, }, @@ -4136,6 +4117,11 @@ this.UnitFsm.ProcessMessage(this, {"type": "PackFinished", "packed": msg.packed}); }; +UnitAI.prototype.OnGatheringStateChanged = function(msg) +{ + this.UnitFsm.ProcessMessage(this, { "type": "GatheringStateChanged", "data": msg }); +}; + //// Helper functions to be called by the FSM //// UnitAI.prototype.GetWalkSpeed = function() Index: binaries/data/mods/public/simulation/components/interfaces/ResourceGatherer.js =================================================================== --- binaries/data/mods/public/simulation/components/interfaces/ResourceGatherer.js +++ binaries/data/mods/public/simulation/components/interfaces/ResourceGatherer.js @@ -1,7 +1,13 @@ Engine.RegisterInterface("ResourceGatherer"); /** - * Message of the form { "to": [{ "type": string, "amount": number, "max":number }] } + * Message of the form { "to": [{ "type": string, "amount": number, "max": number }] } * sent from ResourceGatherer component whenever the amount of carried resources changes. */ Engine.RegisterMessageType("ResourceCarryingChanged"); + +/** + * Message of the form { "exhausted": {boolean}, "filled": {boolean} } + * sent from ResourceGatherer component whenever it has stopped gathering. + */ +Engine.RegisterMessageType("GatheringStateChanged"); Index: binaries/data/mods/public/simulation/components/tests/test_ResourceGatherer.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/tests/test_ResourceGatherer.js @@ -0,0 +1,285 @@ +Resources = { + "BuildSchema": () => { + let schema = ""; + for (let res of ["food", "wood"]) + { + for (let subtype in ["meat", "grain"]) + schema += "" + res + "." + subtype + ""; + schema += " treasure." + res + ""; + } + return "" + schema + ""; + } +}; + +Engine.LoadComponentScript("interfaces/ResourceGatherer.js"); +Engine.LoadComponentScript("interfaces/ResourceSupply.js"); +Engine.LoadComponentScript("interfaces/StatisticsTracker.js"); +Engine.LoadComponentScript("interfaces/Timer.js"); +Engine.LoadComponentScript("interfaces/Trigger.js"); +Engine.LoadComponentScript("ResourceGatherer.js"); +Engine.LoadComponentScript("Timer.js"); +Engine.LoadHelperScript("Player.js"); + +let ApplyValueModificationsToEntity = (valueName, value, entity) => value; +Engine.RegisterGlobal("ApplyValueModificationsToEntity", ApplyValueModificationsToEntity); +let QueryOwnerInterface = () => {}; +Engine.RegisterGlobal("QueryOwnerInterface", QueryOwnerInterface); +let cmpTimer; + +let gathererID = 101; +let supplyID = 102; + +let template = { + "MaxDistance": "10", + "BaseSpeed": "1", + "Rates": { + "food": "1", + "wood": "2" + }, + "Capacities": { + "food": "10", + "wood": "20" + } +}; +let cmpResourceGatherer = ConstructComponent(gathererID, "ResourceGatherer", template); +function reset() +{ + cmpResourceGatherer = ConstructComponent(gathererID, "ResourceGatherer", template); + cmpResourceGatherer.RecalculateGatherRatesAndCapacities(); // Force updating values. + cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer", {}); +}; + +// General getters. +TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), []); +TS_ASSERT_EQUALS(cmpResourceGatherer.GetMainCarryingType(), undefined); +TS_ASSERT_EQUALS(cmpResourceGatherer.GetLastCarriedType(), undefined); +reset(); +TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetGatherRates(), { + "food": 1, + "wood": 2 +}); +TS_ASSERT_EQUALS(cmpResourceGatherer.GetGatherRate("food"), 1); +TS_ASSERT_EQUALS(cmpResourceGatherer.GetGatherRate("bogus"), 0); +TS_ASSERT_EQUALS(cmpResourceGatherer.GetCapacity("wood"), 20); +TS_ASSERT_EQUALS(cmpResourceGatherer.GetCapacity("bogus"), 0); +TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetRange(), { + "max": 10, + "min": 0 +}); +TS_ASSERT(cmpResourceGatherer.CanCarryMore("food")); +TS_ASSERT(!cmpResourceGatherer.CanCarryMore("bogus")); +TS_ASSERT(!cmpResourceGatherer.IsCarrying("food")); +TS_ASSERT(!cmpResourceGatherer.IsCarrying("bogus")); + +function testTreasure() +{ + reset(); + AddMock(supplyID, IID_ResourceSupply, { + "GetType": () => { return { "generic": "bogus" }; } + }); + + TS_ASSERT(!cmpResourceGatherer.TryInstantGather(supplyID)); + + AddMock(supplyID, IID_ResourceSupply, { + "GetType": () => { return { "generic": "treasure" }; }, + "GetCurrentAmount": () => 10, + "TakeResources": (value) => { return { + "amount": value, + "exhausted": true + }; } + }); + + TS_ASSERT(cmpResourceGatherer.TryInstantGather(supplyID)); +}; +testTreasure(); + +function testNormalGathering() +{ + reset(); + // No owner. + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 0); + + AddMock(gathererID, IID_Ownership, { + "GetOwner": () => 1 + }); + + // Resource supply is full (or something else). + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => false + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 0); + + // Supply is empty. + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => true, + "GetCurrentAmount": () => 0 + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 0); + + // Supply is wrong type. + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => true, + "GetCurrentAmount": () => 1, + "GetType": () => { return { "generic": "bogus" }; }, + "GetDiminishingReturns": () => 1 + + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 0); + + // Supply is non-empty and right type. + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => true, + "GetCurrentAmount": () => 2, + "GetType": () => { return { "generic": "food" }; }, + "GetDiminishingReturns": () => 1, + "TakeResources": (amount) => { return { + "amount": amount, + "exhausted": false + }; }, + "RemoveGatherer": () => {} + + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 1); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 1, "max": 10 }]); + TS_ASSERT_EQUALS(cmpResourceGatherer.GetMainCarryingType(), "food"); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetLastCarriedType(), { "generic": "food" }); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 2, "max": 10 }]); + cmpResourceGatherer.StopGathering(); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 2, "max": 10 }]); + TS_ASSERT(cmpResourceGatherer.IsCarrying("food")); + TS_ASSERT(cmpResourceGatherer.CanCarryMore("food")); +}; +testNormalGathering(); + +function testSecondTypeDitchesFirst() +{ + reset(); + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => true, + "GetCurrentAmount": () => 2, + "GetType": () => { return { "generic": "food" }; }, + "GetDiminishingReturns": () => 1, + "TakeResources": (amount) => { return { + "amount": amount, + "exhausted": false + }; }, + "RemoveGatherer": () => {} + + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 1); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 1, "max": 10 }]); + cmpResourceGatherer.StopGathering(); + + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => true, + "GetCurrentAmount": () => 3, + "GetType": () => { return { "generic": "wood" }; }, + "GetDiminishingReturns": () => 1, + "TakeResources": (amount) => { return { + "amount": amount, + "exhausted": false + }; }, + "RemoveGatherer": () => {} + + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 2); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "wood", "amount": 2, "max": 20 }]); + + TS_ASSERT(!cmpResourceGatherer.IsCarrying("food")); + TS_ASSERT(cmpResourceGatherer.CanCarryMore("food")); + TS_ASSERT(cmpResourceGatherer.IsCarrying("wood")); + TS_ASSERT(cmpResourceGatherer.CanCarryMore("wood")); +}; +testSecondTypeDitchesFirst(); + +function testStopIfExhausted() +{ + reset(); + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => true, + "GetCurrentAmount": () => 1, + "GetType": () => { return { "generic": "food" }; }, + "GetDiminishingReturns": () => 1, + "TakeResources": (amount) => { return { + "amount": amount, + "exhausted": true + }; }, + "RemoveGatherer": () => {} + + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 1); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 1, "max": 10 }]); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 1, "max": 10 }]); +}; +testStopIfExhausted(); + +function testStopIfFilled() +{ + reset(); + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => true, + "GetCurrentAmount": () => 11, + "GetType": () => { return { "generic": "food" }; }, + "GetDiminishingReturns": () => 1, + "TakeResources": (amount) => { return { + "amount": amount, + "exhausted": false + }; }, + "RemoveGatherer": () => {} + + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 1); + cmpTimer.OnUpdate({ "turnLength": 10 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 10, "max": 10 }]); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 10, "max": 10 }]); +}; +testStopIfFilled(); + +function testAddingTwiceDoesntMatter() +{ + reset(); + AddMock(supplyID, IID_ResourceSupply, { + "AddGatherer": () => true, + "GetCurrentAmount": () => 3, + "GetType": () => { return { "generic": "food" }; }, + "GetDiminishingReturns": () => 1, + "TakeResources": (amount) => { return { + "amount": amount, + "exhausted": false + }; }, + "RemoveGatherer": () => {} + + }); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 1); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 1, "max": 10 }]); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 2, "max": 10 }]); + TS_ASSERT_EQUALS(cmpResourceGatherer.StartGathering(supplyID), 1); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 3, "max": 10 }]); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT_UNEVAL_EQUALS(cmpResourceGatherer.GetCarryingStatus(), + [{ "type": "food", "amount": 4, "max": 10 }]); +}; +testAddingTwiceDoesntMatter();