0ad/binaries/data/mods/public/simulation/components/tests/test_Trainer.js
Atrik 0ad6d36049 Fix Trainer-ProductionQueue sync OnOwnershipChange
Use ProductionQueue.RemoveItem() instead of direct StopBatch() calls
to ensure both components stay in sync when entity training lists
change due to ownership changes.

Fixes #8691
2026-02-17 16:07:24 +01:00

364 lines
11 KiB
JavaScript

Engine.RegisterGlobal("Resources", {
"BuildSchema": (a, b) => {},
"GetCodes": () => ["food"]
});
Engine.LoadHelperScript("Player.js");
Engine.LoadHelperScript("Sound.js");
Engine.LoadComponentScript("interfaces/BuildRestrictions.js");
Engine.LoadComponentScript("interfaces/EntityLimits.js");
Engine.LoadComponentScript("interfaces/Foundation.js");
Engine.LoadComponentScript("interfaces/StatisticsTracker.js");
Engine.LoadComponentScript("interfaces/Trainer.js");
Engine.LoadComponentScript("interfaces/TrainingRestrictions.js");
Engine.LoadComponentScript("interfaces/ProductionQueue.js");
Engine.LoadComponentScript("interfaces/Trigger.js");
Engine.LoadComponentScript("ProductionQueue.js");
Engine.LoadComponentScript("EntityLimits.js");
Engine.LoadComponentScript("Trainer.js");
Engine.LoadComponentScript("TrainingRestrictions.js");
Engine.RegisterGlobal("ApplyValueModificationsToEntity", (_, value) => value);
Engine.RegisterGlobal("ApplyValueModificationsToTemplate", (_, value) => value);
const playerID = 1;
const playerEntityID = 11;
const entityID = 21;
AddMock(SYSTEM_ENTITY, IID_TemplateManager, {
"TemplateExists": () => true,
"GetTemplate": name => ({})
});
let cmpTrainer = ConstructComponent(entityID, "Trainer", {
"Entities": { "_string": "units/{civ}/cavalry_javelineer_b " +
"units/{civ}/infantry_swordsman_b " +
"units/{native}/support_civilian" }
});
cmpTrainer.GetUpgradedTemplate = (template) => template;
// ProductionQueue mock that just delegates to Trainer's queue
AddMock(entityID, IID_ProductionQueue, {
"GetQueue": () =>
{
// Convert Trainer's internal queue to ProductionQueue format
const queue = [];
for (const [id, item] of cmpTrainer.queue)
queue.push({
"id": id,
"unitTemplate": item.templateName,
"batchID": id // In this mock, batchID = ProductionQueue ID
});
return queue;
},
"RemoveItem": (id) =>
{
// Simply call StopBatch on the Trainer
cmpTrainer.StopBatch(id);
}
});
AddMock(SYSTEM_ENTITY, IID_PlayerManager, {
"GetPlayerByID": id => playerEntityID
});
AddMock(playerEntityID, IID_Player, {
"GetDisabledTemplates": () => ({}),
"GetPlayerID": () => playerID
});
AddMock(playerEntityID, IID_Identity, {
"GetCiv": () => "iber",
});
AddMock(entityID, IID_Ownership, {
"GetOwner": () => playerID
});
AddMock(entityID, IID_Identity, {
"GetCiv": () => "iber"
});
let GetUpgradedTemplate = (_, template) => template === "units/iber/cavalry_javelineer_b" ? "units/iber/cavalry_javelineer_a" : template;
Engine.RegisterGlobal("GetUpgradedTemplate", GetUpgradedTemplate);
cmpTrainer.CalculateEntitiesMap();
TS_ASSERT_UNEVAL_EQUALS(
cmpTrainer.GetEntitiesList(),
["units/iber/cavalry_javelineer_a", "units/iber/infantry_swordsman_b", "units/iber/support_civilian"]
);
GetUpgradedTemplate = (_, template) => template;
Engine.RegisterGlobal("GetUpgradedTemplate", GetUpgradedTemplate);
cmpTrainer.CalculateEntitiesMap();
TS_ASSERT_UNEVAL_EQUALS(
cmpTrainer.GetEntitiesList(),
["units/iber/cavalry_javelineer_b", "units/iber/infantry_swordsman_b", "units/iber/support_civilian"]
);
AddMock(SYSTEM_ENTITY, IID_TemplateManager, {
"TemplateExists": name => name == "units/iber/support_civilian",
"GetTemplate": name => ({})
});
cmpTrainer.CalculateEntitiesMap();
TS_ASSERT_UNEVAL_EQUALS(cmpTrainer.GetEntitiesList(), ["units/iber/support_civilian"]);
AddMock(SYSTEM_ENTITY, IID_TemplateManager, {
"TemplateExists": () => true,
"GetTemplate": name => ({})
});
AddMock(playerEntityID, IID_Player, {
"GetDisabledTemplates": () => ({ "units/athen/infantry_swordsman_b": true }),
"GetPlayerID": () => playerID
});
cmpTrainer.CalculateEntitiesMap();
TS_ASSERT_UNEVAL_EQUALS(
cmpTrainer.GetEntitiesList(),
["units/iber/cavalry_javelineer_b", "units/iber/infantry_swordsman_b", "units/iber/support_civilian"]
);
AddMock(playerEntityID, IID_Player, {
"GetDisabledTemplates": () => ({ "units/iber/infantry_swordsman_b": true }),
"GetPlayerID": () => playerID
});
cmpTrainer.CalculateEntitiesMap();
TS_ASSERT_UNEVAL_EQUALS(
cmpTrainer.GetEntitiesList(),
["units/iber/cavalry_javelineer_b", "units/iber/support_civilian"]
);
AddMock(playerEntityID, IID_Player, {
"GetDisabledTemplates": () => ({ "units/athen/infantry_swordsman_b": true }),
"GetPlayerID": () => playerID
});
AddMock(playerEntityID, IID_Identity, {
"GetCiv": () => "athen",
});
cmpTrainer.CalculateEntitiesMap();
TS_ASSERT_UNEVAL_EQUALS(
cmpTrainer.GetEntitiesList(),
["units/athen/cavalry_javelineer_b", "units/iber/support_civilian"]
);
AddMock(playerEntityID, IID_Player, {
"GetDisabledTemplates": () => ({ "units/iber/infantry_swordsman_b": false }),
"GetPlayerID": () => playerID
});
AddMock(playerEntityID, IID_Identity, {
"GetCiv": () => "iber",
});
cmpTrainer.CalculateEntitiesMap();
TS_ASSERT_UNEVAL_EQUALS(
cmpTrainer.GetEntitiesList(),
["units/iber/cavalry_javelineer_b", "units/iber/infantry_swordsman_b", "units/iber/support_civilian"]
);
// Test Queuing a unit.
const queuedUnit = "units/iber/infantry_swordsman_b";
const cost = {
"food": 10
};
AddMock(SYSTEM_ENTITY, IID_TemplateManager, {
"TemplateExists": () => true,
"GetTemplate": name => ({
"Cost": {
"BuildTime": 1,
"Population": 1,
"Resources": cost
},
"TrainingRestrictions": {
"Category": "some_limit",
"MatchLimit": "7"
}
})
});
AddMock(SYSTEM_ENTITY, IID_Trigger, {
"CallEvent": () => {}
});
AddMock(SYSTEM_ENTITY, IID_GuiInterface, {
"PushNotification": () => {},
"SetSelectionDirty": () => {}
});
const cmpPlayer = AddMock(playerEntityID, IID_Player, {
"BlockTraining": () => {},
"GetPlayerID": () => playerID,
"RefundResources": (resources) =>
{
TS_ASSERT_UNEVAL_EQUALS(resources, cost);
},
"TrySubtractResources": (resources) =>
{
TS_ASSERT_UNEVAL_EQUALS(resources, cost);
// Just have enough resources.
return true;
},
"TryReservePopulationSlots": () => false, // Always have pop space.
"UnReservePopulationSlots": () => {}, // Always have pop space.
"UnBlockTraining": () => {},
"GetDisabledTemplates": () => ({})
});
const spyCmpPlayerSubtract = new Spy(cmpPlayer, "TrySubtractResources");
const spyCmpPlayerRefund = new Spy(cmpPlayer, "RefundResources");
const spyCmpPlayerPop = new Spy(cmpPlayer, "TryReservePopulationSlots");
ConstructComponent(playerEntityID, "EntityLimits", {
"Limits": {
"some_limit": 0
},
"LimitChangers": {},
"LimitRemovers": {}
});
// Test that we can't exceed the entity limit.
TS_ASSERT_EQUALS(cmpTrainer.QueueBatch(queuedUnit, 1), -1);
// And that in that case, the resources are not lost.
// ToDo: This is a bad test, it relies on the order of subtraction in the cmp.
// Better would it be to check the states before and after the queue.
TS_ASSERT_EQUALS(spyCmpPlayerSubtract._called, spyCmpPlayerRefund._called);
ConstructComponent(playerEntityID, "EntityLimits", {
"Limits": {
"some_limit": 5
},
"LimitChangers": {},
"LimitRemovers": {}
});
let id = cmpTrainer.QueueBatch(queuedUnit, 1);
TS_ASSERT_EQUALS(spyCmpPlayerSubtract._called, 2);
TS_ASSERT_EQUALS(cmpTrainer.queue.size, 1);
// Test removing a queued batch.
cmpTrainer.StopBatch(id);
TS_ASSERT_EQUALS(spyCmpPlayerRefund._called, 2);
TS_ASSERT_EQUALS(cmpTrainer.queue.size, 0);
const cmpEntLimits = QueryOwnerInterface(entityID, IID_EntityLimits);
TS_ASSERT(cmpEntLimits.AllowedToTrain("some_limit", 5));
// Test finishing a queued batch.
id = cmpTrainer.QueueBatch(queuedUnit, 1);
TS_ASSERT(cmpEntLimits.AllowedToTrain("some_limit", 4));
TS_ASSERT_EQUALS(cmpTrainer.GetBatch(id).progress, 0);
TS_ASSERT_EQUALS(cmpTrainer.Progress(id, 500), 500);
TS_ASSERT_EQUALS(spyCmpPlayerPop._called, 1);
TS_ASSERT_EQUALS(cmpTrainer.GetBatch(id).progress, 0.5);
const spawedEntityIDs = [4, 5, 6, 7, 8];
let spawned = 0;
Engine.AddEntity = () =>
{
const ent = spawedEntityIDs[spawned++];
ConstructComponent(ent, "TrainingRestrictions", {
"Category": "some_limit"
});
AddMock(ent, IID_Identity, {
"GetClassesList": () => []
});
AddMock(ent, IID_Position, {
"JumpTo": () => {}
});
AddMock(ent, IID_Ownership, {
"SetOwner": (pid) =>
{
QueryOwnerInterface(ent, IID_EntityLimits).OnGlobalOwnershipChanged({
"entity": ent,
"from": -1,
"to": pid
});
},
"GetOwner": () => playerID
});
return ent;
};
AddMock(entityID, IID_Footprint, {
"PickSpawnPoint": () => ({ "x": 0, "y": 1, "z": 0 })
});
cmpTrainer = SerializationCycle(cmpTrainer);
TS_ASSERT_EQUALS(cmpTrainer.Progress(id, 1000), 500);
TS_ASSERT(!cmpTrainer.HasBatch(id));
TS_ASSERT(!cmpEntLimits.AllowedToTrain("some_limit", 5));
TS_ASSERT(cmpEntLimits.AllowedToTrain("some_limit", 4));
TS_ASSERT_EQUALS(cmpEntLimits.GetCounts().some_limit, 1);
TS_ASSERT_EQUALS(cmpEntLimits.GetMatchCounts()["units/iber/infantry_swordsman_b"], 1);
// Now check that it doesn't get updated when the spawn doesn't succeed. (regression_test_d1879)
cmpPlayer.TrySubtractResources = () => true;
cmpPlayer.RefundResources = () => {};
AddMock(entityID, IID_Footprint, {
"PickSpawnPoint": () => ({ "x": -1, "y": -1, "z": -1 })
});
id = cmpTrainer.QueueBatch(queuedUnit, 2);
TS_ASSERT_EQUALS(cmpTrainer.Progress(id, 2000), 2000);
TS_ASSERT(cmpTrainer.HasBatch(id));
TS_ASSERT_EQUALS(cmpEntLimits.GetCounts().some_limit, 3);
TS_ASSERT_EQUALS(cmpEntLimits.GetMatchCounts()["units/iber/infantry_swordsman_b"], 3);
cmpTrainer = SerializationCycle(cmpTrainer);
// Check that when the batch is removed the counts are subtracted again.
cmpTrainer.StopBatch(id);
TS_ASSERT_EQUALS(cmpEntLimits.GetCounts().some_limit, 1);
TS_ASSERT_EQUALS(cmpEntLimits.GetMatchCounts()["units/iber/infantry_swordsman_b"], 1);
const queuedSecondUnit = "units/iber/cavalry_javelineer_b";
// Check changing the allowed entities has effect.
const id1 = cmpTrainer.QueueBatch(queuedUnit, 1);
const id2 = cmpTrainer.QueueBatch(queuedSecondUnit, 1);
TS_ASSERT_EQUALS(cmpTrainer.queue.size, 2);
TS_ASSERT_EQUALS(cmpTrainer.GetBatch(id1).unitTemplate, queuedUnit);
TS_ASSERT_EQUALS(cmpTrainer.GetBatch(id2).unitTemplate, queuedSecondUnit);
// Add a modifier that replaces unit A with unit C,
// adds a unit D and removes unit B from the roster.
Engine.RegisterGlobal("ApplyValueModificationsToEntity", (_, val) =>
{
return typeof val === "string" ? HandleTokens(val, "units/{civ}/cavalry_javelineer_b>units/{civ}/c units/{civ}/d -units/{civ}/infantry_swordsman_b") : val;
});
cmpTrainer.OnValueModification({
"component": "Trainer",
"valueNames": ["Trainer/Entities/_string"],
"entities": [entityID]
});
TS_ASSERT_UNEVAL_EQUALS(
cmpTrainer.GetEntitiesList(), ["units/iber/c", "units/iber/support_civilian", "units/iber/d"]
);
TS_ASSERT_EQUALS(cmpTrainer.queue.size, 1);
TS_ASSERT_EQUALS(cmpTrainer.GetBatch(id1), undefined);
TS_ASSERT_EQUALS(cmpTrainer.GetBatch(id2).unitTemplate, "units/iber/c");
// Test that we can affect an empty trainer.
const emptyTrainer = ConstructComponent(entityID, "Trainer", null);
// Need to add ProductionQueue mock for empty trainer too
AddMock(entityID, IID_ProductionQueue, {
"GetQueue": () => [],
"RemoveItem": () => {}
});
emptyTrainer.OnValueModification({ "component": "Trainer", "entities": [entityID], "valueNames": ["Trainer/Entities/"] });
TS_ASSERT_UNEVAL_EQUALS(
emptyTrainer.GetEntitiesList(),
["units/iber/d"]
);