1228 lines
42 KiB
C#
1228 lines
42 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank.Tests;
|
|
|
|
public sealed class CombatControllerTests
|
|
{
|
|
[Fact]
|
|
public void ApproachClosesFromConfiguredRangeBeforeAttacking()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", distance: 12, angle: 0)],
|
|
NavigationSnapshot = NavigationAt(heading: 0f),
|
|
};
|
|
surface.NavigationObjects[10u] = new PluginNavigationObject(
|
|
10u,
|
|
"Drudge",
|
|
new PluginNavigationPosition(0x7F7F0001, 0d, 0.1d, 0d, 0f, true));
|
|
var settings = new CombatSettings
|
|
{
|
|
MaximumRange = 5f,
|
|
ApproachDistance = 20f,
|
|
ScanIntervalSeconds = 0.05d,
|
|
};
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.05d, navigationEnabled: true);
|
|
|
|
PluginMovementIntent intent = Assert.Single(surface.MovementIntents);
|
|
Assert.True(intent.Forward);
|
|
Assert.Equal(0, surface.BeginCount);
|
|
Assert.Contains("Approaching", controller.Status, StringComparison.Ordinal);
|
|
|
|
surface.Targets = [Target(10, "Drudge", distance: 4, angle: 0)];
|
|
controller.OnTick(0.05d, navigationEnabled: true);
|
|
|
|
Assert.Equal(1, surface.ClearMovementCount);
|
|
Assert.Equal(10u, surface.LastBeginTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void IdlePeaceIsTheNoTargetFallback()
|
|
{
|
|
var surface = new FakeAutomation { CombatSnapshot = Physical() };
|
|
var controller = new CombatController(
|
|
new FakeHost(surface),
|
|
new CombatSettings { IdlePeaceMode = true });
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25d);
|
|
|
|
Assert.Equal(PluginCombatMode.Peace, surface.CombatSnapshot.Mode);
|
|
Assert.Contains("peace", controller.Status, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void HigherPriorityRuleWinsEvenWhenTargetIsFarther()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets =
|
|
[
|
|
Target(10, "Drudge", distance: 2, angle: 0),
|
|
Target(20, "Olthoi Soldier", distance: 12, angle: 30),
|
|
],
|
|
};
|
|
var settings = new CombatSettings
|
|
{
|
|
MaximumRange = 20f,
|
|
SelectionMethod = TargetSelectionMethod.Range,
|
|
};
|
|
settings.Rules.Insert(0, new MonsterRule("name#^Olthoi", 4));
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(20u, surface.LastBeginTarget);
|
|
Assert.Contains("Olthoi Soldier", controller.TargetText, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void TargetLockKeepsCurrentTargetAcrossRescan()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "First", 4, 20)],
|
|
};
|
|
var settings = new CombatSettings
|
|
{
|
|
TargetLock = true,
|
|
ScanIntervalSeconds = 0.1,
|
|
};
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
controller.Toggle();
|
|
controller.OnTick(0.1);
|
|
|
|
surface.CombatSnapshot = Physical();
|
|
surface.Targets =
|
|
[
|
|
Target(10, "First", 4, 20),
|
|
Target(11, "Closer", 1, 0),
|
|
];
|
|
controller.OnTick(0.1);
|
|
|
|
Assert.Equal(10u, surface.LastBeginTarget);
|
|
Assert.Contains("First", controller.TargetText, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void PreviousValidTargetWinsAngleTieBreakWithoutTargetLock()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets =
|
|
[
|
|
Target(10, "First", 4, 1),
|
|
Target(11, "Second", 4, 20),
|
|
],
|
|
};
|
|
var settings = new CombatSettings
|
|
{
|
|
SelectionMethod = TargetSelectionMethod.Angle,
|
|
TargetLock = false,
|
|
ScanIntervalSeconds = 0.1,
|
|
};
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
controller.Toggle();
|
|
controller.OnTick(0.1);
|
|
|
|
surface.CombatSnapshot = Physical();
|
|
surface.Targets =
|
|
[
|
|
Target(10, "First", 4, 20),
|
|
Target(11, "Second", 4, 1),
|
|
];
|
|
controller.OnTick(0.1);
|
|
|
|
Assert.Equal(10u, surface.LastBeginTarget);
|
|
Assert.Contains("First", controller.TargetText, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void PreviousTargetDoesNotBeatNewHigherPriorityRule()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
};
|
|
var settings = new CombatSettings
|
|
{
|
|
SelectionMethod = TargetSelectionMethod.Range,
|
|
ScanIntervalSeconds = 0.1,
|
|
};
|
|
settings.Rules.Insert(0, new MonsterRule("name#^Olthoi", 4));
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
controller.Toggle();
|
|
controller.OnTick(0.1);
|
|
|
|
surface.CombatSnapshot = Physical();
|
|
surface.Targets =
|
|
[
|
|
Target(10, "Drudge", 2, 0),
|
|
Target(20, "Olthoi Soldier", 4, 20),
|
|
];
|
|
controller.OnTick(0.1);
|
|
|
|
Assert.Equal(20u, surface.LastBeginTarget);
|
|
Assert.Contains("Olthoi Soldier", controller.TargetText, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void BothSelectionUsesAngleForNearTargetsAndRangeWhenNoneAreNear()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets =
|
|
[
|
|
Target(10, "Near side", 3, 80),
|
|
Target(11, "Near ahead", 8, 5),
|
|
Target(12, "Far", 20, 0),
|
|
],
|
|
};
|
|
var settings = new CombatSettings
|
|
{
|
|
MaximumRange = 20f,
|
|
SelectionMethod = TargetSelectionMethod.Both,
|
|
TargetSelectAngleRange = 10,
|
|
};
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(11u, surface.LastBeginTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void PhysicalAttackWaitsForConfiguredPowerBeforeRelease()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
};
|
|
var settings = new CombatSettings { AttackPower = 0.6f };
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
Assert.Equal(1, surface.BeginCount);
|
|
|
|
surface.CombatSnapshot = Physical(
|
|
request: true, build: true, bar: 0.59f) with
|
|
{
|
|
DesiredPower = 0.6f,
|
|
};
|
|
controller.OnTick(0.01);
|
|
Assert.Equal(0, surface.ReleaseCount);
|
|
|
|
surface.CombatSnapshot = Physical(
|
|
request: true, build: true, bar: 0.60f) with
|
|
{
|
|
DesiredPower = 0.6f,
|
|
};
|
|
controller.OnTick(0.01);
|
|
Assert.Equal(1, surface.ReleaseCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void CombatController_SummonsConfiguredPetBeforeStartingAttack()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
ItemEntries = [PetDevice(88, 49387)],
|
|
};
|
|
var settings = new CombatSettings { SummonPets = true };
|
|
settings.CombatItemObjectIds.Add(88u);
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(88u, surface.LastUsedItem);
|
|
Assert.Equal(0, surface.BeginCount);
|
|
Assert.Contains("Summoning", controller.Status, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void MagicModeCastsBestProjectedAttackOnExplicitTarget()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
KnownAttackSpells = [Spell(100, "Incantation of Flame Bolt")],
|
|
};
|
|
var controller = new CombatController(
|
|
new FakeHost(surface), new CombatSettings());
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal((100u, 10u), surface.LastTargetedCast);
|
|
Assert.Contains("Flame Bolt", controller.Status, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void BreakableTurnFacesTargetBeforeDispatchingTargetedSpell()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
KnownAttackSpells =
|
|
[
|
|
Spell(100, "Incantation of Flame Bolt") with
|
|
{
|
|
RequiresTurnTo = true,
|
|
},
|
|
],
|
|
NavigationSnapshot = NavigationAt(heading: 0f),
|
|
};
|
|
surface.NavigationObjects[10u] = new PluginNavigationObject(
|
|
10u,
|
|
"Drudge",
|
|
new PluginNavigationPosition(
|
|
0x7F7F0001u,
|
|
0.1d,
|
|
0d,
|
|
0d,
|
|
0f,
|
|
true));
|
|
var controller = new CombatController(
|
|
new FakeHost(surface),
|
|
new CombatSettings { UseBreakableTurnTo = true });
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
PluginMovementIntent turn = Assert.Single(surface.MovementIntents);
|
|
Assert.True(turn.TurnRight);
|
|
Assert.Empty(surface.CastSpellIds);
|
|
|
|
surface.NavigationSnapshot = NavigationAt(heading: 90f);
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal([100u], surface.CastSpellIds);
|
|
Assert.Equal(1, surface.ClearMovementCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProjectileAwarenessBlocksSpellBeforeCastDispatch()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
KnownAttackSpells =
|
|
[
|
|
Spell(100, "Incantation of Flame Bolt") with
|
|
{
|
|
IsProjectile = true,
|
|
},
|
|
],
|
|
ProjectilePath = new(
|
|
PluginProjectilePathStatus.Blocked,
|
|
CollisionChecks: 3,
|
|
BlockingObjectId: 0x50000001u),
|
|
};
|
|
var controller = new CombatController(
|
|
new FakeHost(surface),
|
|
new CombatSettings { UseProjectileAwareness = true });
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Empty(surface.CastSpellIds);
|
|
Assert.Contains("blocked", controller.Status, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Equal(10u, surface.LastProjectileTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProjectileAwarenessCanBeExplicitlyDisabled()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
KnownAttackSpells =
|
|
[
|
|
Spell(100, "Incantation of Flame Bolt") with
|
|
{
|
|
IsProjectile = true,
|
|
},
|
|
],
|
|
ProjectilePath = new(PluginProjectilePathStatus.Blocked),
|
|
};
|
|
var controller = new CombatController(
|
|
new FakeHost(surface),
|
|
new CombatSettings { UseProjectileAwareness = false });
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Contains(100u, surface.CastSpellIds);
|
|
Assert.Equal(0u, surface.LastProjectileTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void CollisionDebugPublishesDiagnosticSamplesToTheGraphicalHost()
|
|
{
|
|
PluginProjectileDebugSample[] samples =
|
|
[new(new System.Numerics.Vector3(1f, 2f, 3f), false, 0.4f)];
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
KnownAttackSpells =
|
|
[
|
|
Spell(100, "Incantation of Flame Bolt") with
|
|
{
|
|
IsProjectile = true,
|
|
},
|
|
],
|
|
ProjectilePath = new(
|
|
PluginProjectilePathStatus.Blocked,
|
|
CollisionChecks: 1)
|
|
{
|
|
DebugSamples = samples,
|
|
},
|
|
};
|
|
var controller = new CombatController(
|
|
new FakeHost(surface),
|
|
new CombatSettings
|
|
{
|
|
UseProjectileAwareness = true,
|
|
ShowCollisionDebug = true,
|
|
});
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(samples, surface.ShownProjectileDebugSamples);
|
|
}
|
|
|
|
[Fact]
|
|
public void DoJiggleUsesRetailSelectionCycleInsteadOfMovingTheCharacter()
|
|
{
|
|
PluginSpellInfo attack = Spell(100, "Incantation of Flame Bolt") with
|
|
{
|
|
IsProjectile = false,
|
|
School = 34,
|
|
Difficulty = 300,
|
|
};
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
KnownAttackSpells = [attack],
|
|
};
|
|
var controller = new CombatController(
|
|
new FakeHost(surface),
|
|
new CombatSettings
|
|
{
|
|
UseProjectileAwareness = false,
|
|
DoJiggle = true,
|
|
});
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
surface.LastCastCompletion = new PluginCastCompletion(1, 100, 10, 0);
|
|
|
|
controller.OnTick(0.01);
|
|
controller.OnTick(0.131);
|
|
|
|
Assert.Equal(
|
|
[
|
|
PluginSelectionAction.PreviousSelection,
|
|
PluginSelectionAction.NextPlayer,
|
|
PluginSelectionAction.PreviousPlayer,
|
|
],
|
|
surface.SelectionActions);
|
|
Assert.Empty(surface.MovementIntents);
|
|
}
|
|
|
|
[Fact]
|
|
public void MagicRuleDebuffsAndWaitsForServerReceiptBeforeAttack()
|
|
{
|
|
PluginSpellInfo imperil = Spell(90, "Imperil Other VII") with
|
|
{
|
|
IsDebuff = true,
|
|
IsOffensive = true,
|
|
DurationSeconds = 60,
|
|
};
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
KnownCombatSpells = [imperil],
|
|
KnownAttackSpells = [Spell(100, "Incantation of Flame Bolt")],
|
|
};
|
|
var settings = new CombatSettings();
|
|
settings.Rules.Clear();
|
|
settings.Rules.Add(new MonsterRule(
|
|
"DEFAULT",
|
|
new MonsterRuleActions
|
|
{
|
|
Flags = MonsterActionFlags.Imperil | MonsterActionFlags.Attack,
|
|
}));
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
Assert.Equal((90u, 10u), surface.LastTargetedCast);
|
|
Assert.DoesNotContain(100u, surface.CastSpellIds);
|
|
|
|
surface.LastCastCompletion = new PluginCastCompletion(1, 90, 10, 0);
|
|
controller.OnTick(0.25);
|
|
Assert.Contains(100u, surface.CastSpellIds);
|
|
}
|
|
|
|
[Fact]
|
|
public void RingOnlyRuleCastsUntargetedRingWithOneNearbyRingTarget()
|
|
{
|
|
PluginSpellInfo ring = Spell(110, "Flame Ring") with
|
|
{
|
|
IsOffensive = true,
|
|
TargetMask = 0,
|
|
Description = "Shoots waves of fire outward from the caster.",
|
|
};
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 3, 0)],
|
|
KnownCombatSpells = [ring],
|
|
};
|
|
var settings = new CombatSettings();
|
|
settings.Rules.Clear();
|
|
settings.Rules.Add(new MonsterRule(
|
|
"DEFAULT",
|
|
new MonsterRuleActions
|
|
{
|
|
Flags = MonsterActionFlags.Ring,
|
|
DamageType = MonsterDamageType.Fire,
|
|
}));
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(110u, surface.LastUntargetedCast);
|
|
Assert.Equal(default, surface.LastTargetedCast);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExplicitMonsterWeaponUsesCanonicalEquipmentCommandBeforeAttack()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
EquipmentItems =
|
|
[
|
|
Equipment(700, "Fire Sword", damageType: 0x10),
|
|
],
|
|
};
|
|
var settings = new CombatSettings();
|
|
settings.Rules.Clear();
|
|
settings.Rules.Add(new MonsterRule(
|
|
"DEFAULT",
|
|
new MonsterRuleActions
|
|
{
|
|
Flags = MonsterActionFlags.Attack,
|
|
WeaponObjectId = 700,
|
|
}));
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(700u, surface.LastEquipObjectId);
|
|
Assert.Equal(0, surface.BeginCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExplicitMonsterWeaponResolvesDurableNameAfterRelogChangesObjectId()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
EquipmentItems =
|
|
[
|
|
Equipment(900, "Fire Sword", damageType: 0x10),
|
|
],
|
|
};
|
|
var settings = new CombatSettings();
|
|
settings.Rules.Clear();
|
|
settings.Rules.Add(new MonsterRule(
|
|
"DEFAULT",
|
|
new MonsterRuleActions
|
|
{
|
|
Flags = MonsterActionFlags.Attack,
|
|
WeaponObjectId = 700,
|
|
WeaponName = "Fire Sword",
|
|
}));
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(900u, surface.LastEquipObjectId);
|
|
Assert.Equal(0, surface.BeginCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutomaticDamageSelectionChoosesStrongestMatchingWeapon()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
EquipmentItems =
|
|
[
|
|
Equipment(700, "Weak fire", damageType: 0x10, damage: 20),
|
|
Equipment(701, "Strong fire", damageType: 0x10, damage: 35),
|
|
Equipment(702, "Acid", damageType: 0x20, damage: 99),
|
|
],
|
|
};
|
|
var settings = new CombatSettings();
|
|
settings.CombatItemNames.Add("Strong fire");
|
|
settings.Rules.Clear();
|
|
settings.Rules.Add(new MonsterRule(
|
|
"DEFAULT",
|
|
new MonsterRuleActions
|
|
{
|
|
Flags = MonsterActionFlags.Attack,
|
|
DamageType = MonsterDamageType.Fire,
|
|
}));
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(701u, surface.LastEquipObjectId);
|
|
Assert.Equal(0, surface.BeginCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissileLauncherSelectsOfficialBestAvailableAmmunition()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
CharacterSkills =
|
|
[
|
|
new PluginSkillInfo(
|
|
47u,
|
|
"Missile Weapons",
|
|
PluginSkillTraining.Trained,
|
|
300u)
|
|
{
|
|
Base = 300u,
|
|
},
|
|
],
|
|
EquipmentItems =
|
|
[
|
|
Equipment(
|
|
700,
|
|
"Fire Bow",
|
|
damageType: 0x10,
|
|
equippedLocation: 0x00100000u,
|
|
ammoType: 0x001u),
|
|
Equipment(
|
|
801,
|
|
"Deadly Fire Arrow",
|
|
damageType: 0x10,
|
|
combatUse: 3,
|
|
ammoType: 0x001u,
|
|
stackSize: 20),
|
|
],
|
|
ItemEntries =
|
|
[
|
|
InventoryItem(
|
|
801,
|
|
"Deadly Fire Arrow",
|
|
itemType: 0x100,
|
|
spellId: 0,
|
|
equipped: false),
|
|
],
|
|
};
|
|
var settings = new CombatSettings();
|
|
settings.Rules.Clear();
|
|
settings.Rules.Add(new MonsterRule(
|
|
"DEFAULT",
|
|
new MonsterRuleActions
|
|
{
|
|
Flags = MonsterActionFlags.Attack,
|
|
DamageType = MonsterDamageType.Fire,
|
|
WeaponObjectId = 700,
|
|
}));
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(801u, surface.LastEquipObjectId);
|
|
Assert.Equal(0, surface.BeginCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void StuckCombatModeUsesProfiledCasterAfterRetailRetryCount()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
IgnoreModeChanges = true,
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
EquipmentItems =
|
|
[
|
|
Equipment(700, "Fire Sword", damageType: 0x10),
|
|
Equipment(
|
|
800,
|
|
"Recovery Wand",
|
|
damageType: 0,
|
|
itemType: 0x00008000u),
|
|
],
|
|
};
|
|
var settings = new CombatSettings();
|
|
settings.CombatItemNames.Add("Recovery Wand");
|
|
settings.Rules.Clear();
|
|
settings.Rules.Add(new MonsterRule(
|
|
"DEFAULT",
|
|
new MonsterRuleActions
|
|
{
|
|
Flags = MonsterActionFlags.Attack,
|
|
WeaponObjectId = 700,
|
|
}));
|
|
var controller = new CombatController(
|
|
new FakeHost(surface),
|
|
settings,
|
|
new VitalSettings { DropToPeaceModeRetryCount = 2 });
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(1, surface.ModeChangeRequests);
|
|
Assert.Equal(800u, surface.LastUsedItem);
|
|
Assert.Equal(0u, surface.LastEquipObjectId);
|
|
Assert.Contains(
|
|
"stuck combat state",
|
|
controller.Status,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionLossDisablesAndAborts()
|
|
{
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 2, 0)],
|
|
};
|
|
var controller = new CombatController(
|
|
new FakeHost(surface), new CombatSettings());
|
|
controller.Toggle();
|
|
surface.IsAvailable = false;
|
|
|
|
controller.OnTick(0.1);
|
|
|
|
Assert.False(controller.Enabled);
|
|
Assert.Equal(1, surface.AbortCount);
|
|
Assert.Equal("Session ended", controller.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public void CasterItemDebuffWaitsForUseDoneAndConfirmedCastChat()
|
|
{
|
|
PluginSpellInfo imperil = Spell(90, "Imperil Other VII") with
|
|
{
|
|
School = 31,
|
|
IsDebuff = true,
|
|
IsOffensive = true,
|
|
DurationSeconds = 60,
|
|
};
|
|
PluginInventoryItem lens = InventoryItem(
|
|
800, "Imperil Lens", 0x8000, 90, equipped: true) with
|
|
{
|
|
ItemSpellcraft = 400,
|
|
};
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical() with { Mode = PluginCombatMode.Magic },
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
SpellLookup = [imperil],
|
|
ItemEntries = [lens],
|
|
};
|
|
var settings = DebuffOnly(MonsterActionFlags.Imperil);
|
|
settings.CombatItemObjectIds.Add(lens.ObjectId);
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal((800u, 10u), surface.LastAppliedItem);
|
|
Assert.Equal(0, surface.BeginCount);
|
|
|
|
surface.LastItemCompletion = new PluginItemUseCompletion(
|
|
1, 800, 10, 0);
|
|
surface.ChatMessages =
|
|
[
|
|
new PluginChatMessage(
|
|
1, 0, 0, string.Empty,
|
|
"You cast Imperil Other VII on Drudge.",
|
|
string.Empty),
|
|
];
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(1, surface.ApplyCount);
|
|
Assert.Contains("Debuffs complete", controller.Status, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProcWeaponChargesAtZeroAndRequiresCastChatNotAttackDone()
|
|
{
|
|
PluginSpellInfo imperil = Spell(91, "Imperil Other VII") with
|
|
{
|
|
School = 31,
|
|
IsDebuff = true,
|
|
IsOffensive = true,
|
|
DurationSeconds = 60,
|
|
TargetMask = 0x10,
|
|
};
|
|
PluginInventoryItem sword = InventoryItem(
|
|
801, "Imperil Sword", 1, 0, equipped: true) with
|
|
{
|
|
ItemSpellcraft = 400,
|
|
AppraisedSpellIds = [91u],
|
|
};
|
|
var surface = new FakeAutomation
|
|
{
|
|
CombatSnapshot = Physical(),
|
|
Targets = [Target(10, "Drudge", 5, 0)],
|
|
SpellLookup = [imperil],
|
|
ItemEntries = [sword],
|
|
};
|
|
var settings = DebuffOnly(MonsterActionFlags.Imperil);
|
|
settings.CombatItemObjectIds.Add(sword.ObjectId);
|
|
var controller = new CombatController(new FakeHost(surface), settings);
|
|
|
|
controller.Toggle();
|
|
controller.OnTick(0.25);
|
|
|
|
Assert.Equal(10u, surface.LastBeginTarget);
|
|
Assert.Equal(0f, surface.LastBeginPower);
|
|
|
|
surface.CombatSnapshot = Physical(request: true, build: true, bar: 0);
|
|
controller.OnTick(0.1);
|
|
Assert.Equal(1, surface.ReleaseCount);
|
|
|
|
surface.CombatSnapshot = Physical() with
|
|
{
|
|
CompletionRevision = 1,
|
|
CompletionWeenieError = 0,
|
|
};
|
|
controller.OnTick(0.1);
|
|
Assert.DoesNotContain("Debuffs complete", controller.Status, StringComparison.Ordinal);
|
|
|
|
surface.ChatMessages =
|
|
[
|
|
new PluginChatMessage(
|
|
1, 0, 0, string.Empty,
|
|
"You cast Imperil Other VII on Drudge.",
|
|
string.Empty),
|
|
];
|
|
controller.OnTick(0.1);
|
|
Assert.Contains("Debuffs complete", controller.Status, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static CombatSettings DebuffOnly(MonsterActionFlags flag)
|
|
{
|
|
var settings = new CombatSettings();
|
|
settings.Rules.Clear();
|
|
settings.Rules.Add(new MonsterRule(
|
|
"DEFAULT",
|
|
new MonsterRuleActions { Flags = flag }));
|
|
return settings;
|
|
}
|
|
|
|
private static PluginCombatSnapshot Physical(
|
|
bool request = false,
|
|
bool build = false,
|
|
float bar = 0f) => new(
|
|
SelectedObjectId: 0,
|
|
PluginCombatMode.Melee,
|
|
PluginAttackHeight.Medium,
|
|
DesiredPower: 0.5f,
|
|
PowerBarLevel: bar,
|
|
BuildInProgress: build,
|
|
RequestInProgress: request,
|
|
ServerResponsePending: false,
|
|
RepeatAttackInProgress: false);
|
|
|
|
private static PluginCombatTarget Target(
|
|
uint id, string name, float distance, float angle) => new(
|
|
id, name, id + 1000, distance, angle, true, 1f);
|
|
|
|
private static PluginSpellInfo Spell(uint id, string name) => new(
|
|
id, name, Family: 1, Tier: 8, Difficulty: 350, ManaCost: 30,
|
|
DurationSeconds: 0, School: 34, Description: string.Empty,
|
|
IsSelfTargeted: false, IsBeneficial: false);
|
|
|
|
private static PluginEquipmentItem Equipment(
|
|
uint id,
|
|
string name,
|
|
int damageType,
|
|
int damage = 20,
|
|
uint equippedLocation = 0,
|
|
uint itemType = 1,
|
|
byte combatUse = 1,
|
|
uint ammoType = 0,
|
|
int stackSize = 1) => new(
|
|
id,
|
|
name,
|
|
ItemType: itemType,
|
|
ValidLocations: 0x00100000,
|
|
EquippedLocation: equippedLocation,
|
|
ContainerObjectId: 1,
|
|
WielderObjectId: 0,
|
|
CombatUse: combatUse,
|
|
DamageType: damageType,
|
|
WeaponSkill: 44,
|
|
Damage: damage,
|
|
DamageVariance: 0.25)
|
|
{
|
|
AmmoType = ammoType,
|
|
StackSize = stackSize,
|
|
};
|
|
|
|
private static PluginInventoryItem PetDevice(uint id, uint wcid) => new(
|
|
id, wcid, "Frost Pet", 0, 1, 0, 0, 0, 0, 0, 0, 1, 50, 50,
|
|
0, 49000, 3, 0, false, 0, 0, 0, 0, 0, 54, 100, 0);
|
|
|
|
private static PluginInventoryItem InventoryItem(
|
|
uint id,
|
|
string name,
|
|
uint itemType,
|
|
uint spellId,
|
|
bool equipped) => new(
|
|
id, 0, name, itemType, 1, 0, 0,
|
|
equipped ? 0x00100000u : 0u,
|
|
0, 0, 0, 1, 0, 0, spellId, 0, 0, 0, false, 0,
|
|
0, 0, 0, 0, 0, 0, 0);
|
|
|
|
private sealed class FakeHost(FakeAutomation automation) : IPluginHost
|
|
{
|
|
public bool HasUi => false;
|
|
public IPluginLogger Log { get; } = new FakeLogger();
|
|
public IGameState State { get; } = new FakeState();
|
|
public IEvents Events { get; } = new FakeEvents();
|
|
public ISelectionService Selection { get; } = new FakeSelection();
|
|
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
|
public IAutomationSurface Automation => automation;
|
|
}
|
|
|
|
private sealed class FakeAutomation :
|
|
IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands,
|
|
IPluginChat, ICombatAutomation
|
|
, IEquipmentAutomation, IItemAutomation, INavigationAutomation,
|
|
IProjectileAutomation, ISelectionAutomation
|
|
{
|
|
public bool IsAvailable { get; set; } = true;
|
|
public ICharacterInfo Character => this;
|
|
public ISpellCatalog Spells => this;
|
|
public IMagicCommands Magic => this;
|
|
public IPluginChat Chat => this;
|
|
public ICombatAutomation Combat => this;
|
|
public IEquipmentAutomation Equipment => this;
|
|
public IItemAutomation Items => this;
|
|
public INavigationAutomation Navigation => this;
|
|
public IProjectileAutomation Projectiles => this;
|
|
public ISelectionAutomation Selection => this;
|
|
public PluginCombatSnapshot CombatSnapshot { get; set; }
|
|
public PluginCombatSnapshot Snapshot => CombatSnapshot;
|
|
public IReadOnlyList<PluginCombatTarget> Targets { get; set; } = [];
|
|
public IReadOnlyList<PluginSpellInfo> KnownAttackSpells { get; set; } = [];
|
|
public IReadOnlyList<PluginSpellInfo> KnownCombatSpells { get; set; } = [];
|
|
public IReadOnlyList<PluginSpellInfo> SpellLookup { get; set; } = [];
|
|
public PluginCastCompletion LastCastCompletion { get; set; }
|
|
public PluginCastCompletion LastCompletion => LastCastCompletion;
|
|
public uint LastBeginTarget { get; private set; }
|
|
public int BeginCount { get; private set; }
|
|
public int ReleaseCount { get; private set; }
|
|
public int AbortCount { get; private set; }
|
|
public (uint Spell, uint Target) LastTargetedCast { get; private set; }
|
|
public uint LastUntargetedCast { get; private set; }
|
|
public List<uint> CastSpellIds { get; } = [];
|
|
public IReadOnlyList<PluginEquipmentItem> EquipmentItems { get; set; } = [];
|
|
public uint LastEquipObjectId { get; private set; }
|
|
public IReadOnlyList<PluginInventoryItem> ItemEntries { get; set; } = [];
|
|
public uint LastUsedItem { get; private set; }
|
|
public (uint Item, uint Target) LastAppliedItem { get; private set; }
|
|
public int ApplyCount { get; private set; }
|
|
public PluginItemUseCompletion LastItemCompletion { get; set; }
|
|
public IReadOnlyList<PluginChatMessage> ChatMessages { get; set; } = [];
|
|
public float LastBeginPower { get; private set; }
|
|
public PluginNavigationSnapshot NavigationSnapshot { get; set; }
|
|
public bool IgnoreModeChanges { get; set; }
|
|
public int ModeChangeRequests { get; private set; }
|
|
PluginNavigationSnapshot INavigationAutomation.Snapshot =>
|
|
NavigationSnapshot;
|
|
public Dictionary<uint, PluginNavigationObject> NavigationObjects { get; } = [];
|
|
public List<PluginMovementIntent> MovementIntents { get; } = [];
|
|
public int ClearMovementCount { get; private set; }
|
|
public PluginProjectilePathResult ProjectilePath { get; set; } =
|
|
new(PluginProjectilePathStatus.Clear);
|
|
public uint LastProjectileTarget { get; private set; }
|
|
public IReadOnlyList<PluginProjectileDebugSample>
|
|
ShownProjectileDebugSamples { get; private set; } = [];
|
|
public List<PluginSelectionAction> SelectionActions { get; } = [];
|
|
bool IEquipmentAutomation.IsAvailable => true;
|
|
bool IEquipmentAutomation.IsBusy => false;
|
|
public IReadOnlyList<PluginEquipmentItem> CaptureOwnedEquipment() =>
|
|
EquipmentItems;
|
|
public PluginEquipmentCommandResult Equip(
|
|
uint objectId,
|
|
uint requestedLocation = 0u)
|
|
{
|
|
LastEquipObjectId = objectId;
|
|
return new(PluginEquipmentCommandStatus.Started);
|
|
}
|
|
bool IItemAutomation.IsAvailable => true;
|
|
bool IItemAutomation.IsBusy => false;
|
|
int IItemAutomation.ActiveOwnedPetCount => 0;
|
|
PluginItemUseCompletion IItemAutomation.LastCompletion => LastItemCompletion;
|
|
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => ItemEntries;
|
|
public PluginItemCommandResult Use(uint objectId)
|
|
{
|
|
LastUsedItem = objectId;
|
|
return new(PluginItemCommandStatus.Started);
|
|
}
|
|
public PluginItemCommandResult Apply(uint objectId, uint targetObjectId)
|
|
{
|
|
LastAppliedItem = (objectId, targetObjectId);
|
|
ApplyCount++;
|
|
return new(PluginItemCommandStatus.Started);
|
|
}
|
|
|
|
public IReadOnlyList<PluginCombatTarget> CaptureHostileTargets(
|
|
float maximumDistance) => Targets;
|
|
public PluginCombatCommandResult EnterDefaultMode() =>
|
|
new(PluginCombatCommandStatus.ModeChangeSent);
|
|
public PluginCombatCommandResult EnterMode(PluginCombatMode mode)
|
|
{
|
|
ModeChangeRequests++;
|
|
if (!IgnoreModeChanges)
|
|
CombatSnapshot = CombatSnapshot with { Mode = mode };
|
|
return new(PluginCombatCommandStatus.ModeChangeSent);
|
|
}
|
|
public PluginCombatCommandResult BeginPhysicalAttack(
|
|
uint targetObjectId, PluginAttackHeight height, float power)
|
|
{
|
|
LastBeginTarget = targetObjectId;
|
|
LastBeginPower = power;
|
|
BeginCount++;
|
|
return new(PluginCombatCommandStatus.Started);
|
|
}
|
|
public PluginCombatCommandResult ReleasePhysicalAttack()
|
|
{
|
|
ReleaseCount++;
|
|
return new(PluginCombatCommandStatus.Released);
|
|
}
|
|
public PluginCombatCommandResult AbortPhysicalAttack()
|
|
{
|
|
AbortCount++;
|
|
return new(PluginCombatCommandStatus.Stopped);
|
|
}
|
|
|
|
public bool TryGetObject(
|
|
uint objectId,
|
|
out PluginNavigationObject value) =>
|
|
NavigationObjects.TryGetValue(objectId, out value);
|
|
public PluginNavigationCommandStatus SetMovementIntent(
|
|
in PluginMovementIntent intent)
|
|
{
|
|
MovementIntents.Add(intent);
|
|
return PluginNavigationCommandStatus.Accepted;
|
|
}
|
|
public PluginNavigationCommandStatus ClearMovementIntent()
|
|
{
|
|
ClearMovementCount++;
|
|
return PluginNavigationCommandStatus.Accepted;
|
|
}
|
|
|
|
bool IProjectileAutomation.IsAvailable => true;
|
|
public PluginProjectilePathResult EvaluatePath(
|
|
uint targetObjectId,
|
|
PluginProjectilePathKind kind,
|
|
PluginAttackHeight targetHeight,
|
|
float projectileRadius,
|
|
float stepDistance,
|
|
int maximumCollisionChecks)
|
|
{
|
|
LastProjectileTarget = targetObjectId;
|
|
return ProjectilePath;
|
|
}
|
|
|
|
public PluginProjectilePathResult EvaluatePathWithDiagnostics(
|
|
uint targetObjectId,
|
|
PluginProjectilePathKind kind,
|
|
PluginAttackHeight targetHeight,
|
|
float projectileRadius,
|
|
float stepDistance,
|
|
int maximumCollisionChecks) => EvaluatePath(
|
|
targetObjectId,
|
|
kind,
|
|
targetHeight,
|
|
projectileRadius,
|
|
stepDistance,
|
|
maximumCollisionChecks);
|
|
|
|
public void ShowDebugSamples(
|
|
IReadOnlyList<PluginProjectileDebugSample> samples) =>
|
|
ShownProjectileDebugSamples = samples.ToArray();
|
|
|
|
public bool Execute(PluginSelectionAction action)
|
|
{
|
|
SelectionActions.Add(action);
|
|
return true;
|
|
}
|
|
|
|
public bool IsCasting { get; set; }
|
|
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready;
|
|
public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) =>
|
|
PluginCastGate.Ready;
|
|
public bool Cast(uint spellId)
|
|
{
|
|
LastUntargetedCast = spellId;
|
|
CastSpellIds.Add(spellId);
|
|
return true;
|
|
}
|
|
public bool Cast(uint spellId, uint targetObjectId)
|
|
{
|
|
LastTargetedCast = (spellId, targetObjectId);
|
|
CastSpellIds.Add(spellId);
|
|
return true;
|
|
}
|
|
public void PostSystemMessage(string text) { }
|
|
public IReadOnlyList<PluginChatMessage> CaptureMessages(
|
|
ulong afterSequence) => ChatMessages
|
|
.Where(message => message.Sequence > afterSequence)
|
|
.ToArray();
|
|
|
|
public bool IsInWorld => IsAvailable;
|
|
public uint ObjectId => 1;
|
|
public uint CurrentHealth => 100;
|
|
public uint MaxHealth => 100;
|
|
public uint CurrentStamina => 100;
|
|
public uint MaxStamina => 100;
|
|
public uint CurrentMana => 100;
|
|
public uint MaxMana => 100;
|
|
public int SummoningMastery => 3;
|
|
public IReadOnlyList<PluginSkillInfo> CharacterSkills { get; set; } =
|
|
[new(54, "Summoning", PluginSkillTraining.Trained, 300)];
|
|
public IReadOnlyList<PluginSkillInfo> Skills => CharacterSkills;
|
|
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
|
|
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
|
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
|
|
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
|
|
{
|
|
foreach (PluginSkillInfo candidate in CharacterSkills)
|
|
{
|
|
if (candidate.SkillId == skillId)
|
|
{
|
|
skill = candidate;
|
|
return true;
|
|
}
|
|
}
|
|
skill = default;
|
|
return false;
|
|
}
|
|
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
|
{
|
|
foreach (PluginSpellInfo spell in KnownAttackSpells)
|
|
{
|
|
if (spell.SpellId == spellId)
|
|
{
|
|
info = spell;
|
|
return true;
|
|
}
|
|
}
|
|
foreach (PluginSpellInfo spell in KnownCombatSpells.Concat(SpellLookup))
|
|
{
|
|
if (spell.SpellId == spellId)
|
|
{
|
|
info = spell;
|
|
return true;
|
|
}
|
|
}
|
|
info = default;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static PluginNavigationSnapshot NavigationAt(float heading) => new(
|
|
IsAvailable: true,
|
|
IsPortalSpace: false,
|
|
LocalObjectId: 1u,
|
|
Position: new PluginNavigationPosition(
|
|
0x7F7F0001,
|
|
0d,
|
|
0d,
|
|
0d,
|
|
heading,
|
|
true),
|
|
IsMoving: false,
|
|
IsAirborne: false);
|
|
|
|
private sealed class FakeLogger : IPluginLogger
|
|
{
|
|
public void Info(string message) { }
|
|
public void Warn(string message) { }
|
|
public void Error(string message, Exception? exception = null) { }
|
|
}
|
|
private sealed class FakeState : IGameState
|
|
{
|
|
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
|
|
}
|
|
private sealed class FakeEvents : IEvents
|
|
{
|
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
public event Action<double> Tick
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
}
|
|
private sealed class FakeSelection : ISelectionService
|
|
{
|
|
public uint? SelectedObjectId => null;
|
|
public uint? PreviousObjectId => null;
|
|
public event Action<SelectionChangedEvent> Changed
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
public bool Select(uint objectId) => true;
|
|
public bool Clear() => true;
|
|
}
|
|
}
|