This commit is contained in:
139
.claude/skills/bulbapedia-tests/SKILL.md
Normal file
139
.claude/skills/bulbapedia-tests/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
name: bulbapedia-tests
|
||||||
|
description: Fetches move/ability descriptions from Bulbapedia and translates them into atomic TUnit tests in the Gen7 test project. Use when asked to write Bulbapedia-based tests for moves or abilities, e.g. "test tackle" or "write tests for all moves starting with a".
|
||||||
|
argument-hint: <target, e.g. "tackle" | "ability intimidate" | "all moves starting with a">
|
||||||
|
allowed-tools: WebFetch(domain:bulbapedia.bulbagarden.net), Read, Glob, Grep, Write(Plugins/PkmnLib.Plugin.Gen7.Tests/**), Edit(Plugins/PkmnLib.Plugin.Gen7.Tests/**), Bash(dotnet build:*), Bash(dotnet test:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bulbapedia → Gen7 unit tests
|
||||||
|
|
||||||
|
Generate atomic unit tests for Gen7 move/ability script implementations, using their
|
||||||
|
Bulbapedia descriptions as the source of truth for correct behavior.
|
||||||
|
|
||||||
|
## Hard constraints (tool whitelist)
|
||||||
|
|
||||||
|
- **Web access**: only `https://bulbapedia.bulbagarden.net/...` via WebFetch. Never fetch any
|
||||||
|
other domain.
|
||||||
|
- **File modifications**: only inside `Plugins/PkmnLib.Plugin.Gen7.Tests/`. Never modify the
|
||||||
|
implementation project (`Plugins/PkmnLib.Plugin.Gen7/`) or anything else — even if you find a
|
||||||
|
bug there. Reading any file in the repo is allowed and encouraged.
|
||||||
|
- **Shell**: only `dotnet build` and `dotnet test`.
|
||||||
|
|
||||||
|
## Step 1 — Resolve targets from the argument
|
||||||
|
|
||||||
|
The argument describes what to test. Interpret it and resolve it to a list of implemented
|
||||||
|
script classes:
|
||||||
|
|
||||||
|
- A single name (`tackle`, `ability intimidate`) → that one script. Default to moves; the words
|
||||||
|
"ability"/"abilities" switch to abilities.
|
||||||
|
- A pattern (`all moves starting with a`, `abilities containing 'veil'`) → enumerate with Glob
|
||||||
|
against the implementation folders:
|
||||||
|
- Moves: `Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/*.cs`
|
||||||
|
- Abilities: `Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/*.cs`
|
||||||
|
- Only test scripts that actually exist in those folders. If a requested target has no
|
||||||
|
implementation file, don't invent one — list it as skipped in the final report.
|
||||||
|
- Skip base/helper classes (e.g. `BaseChargeMove.cs`) unless explicitly requested; they are
|
||||||
|
covered through the concrete moves.
|
||||||
|
|
||||||
|
For each target, check whether `Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/{Moves,Abilities}/<ClassName>Tests.cs`
|
||||||
|
already exists. If so, extend it with missing behaviors (via Edit) instead of overwriting it,
|
||||||
|
and don't duplicate behaviors that already have a test.
|
||||||
|
|
||||||
|
## Step 2 — Fetch the Bulbapedia description
|
||||||
|
|
||||||
|
This fetch procedure is verified to work; follow it exactly.
|
||||||
|
|
||||||
|
**Primary: WebFetch the rendered wiki page.** URL conventions (spaces become underscores,
|
||||||
|
each word capitalized as on Bulbapedia; the `(move)` suffix is lowercase, `(Ability)` is
|
||||||
|
capitalized):
|
||||||
|
|
||||||
|
- Moves: `https://bulbapedia.bulbagarden.net/wiki/<Move_Name>_(move)` — e.g. `Tackle_(move)`, `Fury_Cutter_(move)`
|
||||||
|
- Abilities: `https://bulbapedia.bulbagarden.net/wiki/<Ability_Name>_(Ability)` — e.g. `Intimidate_(Ability)`
|
||||||
|
|
||||||
|
Prompt the fetch with: *"Extract the full Effect section verbatim, including all per-generation
|
||||||
|
subsections."* The Effect section is structured as a base description plus generation-delta
|
||||||
|
subsections, e.g. for Tackle: base text "Tackle inflicts damage and has no secondary effect.",
|
||||||
|
then `Generations V and VI`: "power was increased from 35 to 50...", then
|
||||||
|
`Generation VII onwards`: "power was decreased from 50 to 40."
|
||||||
|
|
||||||
|
**Fallback / exact wording: the MediaWiki API, also via WebFetch.** When the summarized page
|
||||||
|
extract seems lossy or you need the exact sentence to quote, fetch the raw wikitext (the Effect
|
||||||
|
section is normally section index 1):
|
||||||
|
|
||||||
|
- `https://bulbapedia.bulbagarden.net/w/api.php?action=parse&page=Tackle_(move)&prop=wikitext&format=json§ion=1`
|
||||||
|
|
||||||
|
**Name resolution**: if unsure of the exact page title, use the API's opensearch:
|
||||||
|
|
||||||
|
- `https://bulbapedia.bulbagarden.net/w/api.php?action=opensearch&search=Fury_C&limit=5&format=json`
|
||||||
|
→ returns matching titles and URLs (e.g. `Fury Cutter (move)`).
|
||||||
|
|
||||||
|
**Deriving Gen VII behavior**: this codebase implements **Generation VII**. The Effect section
|
||||||
|
is written as deltas — start from the base description and apply every subsection whose range
|
||||||
|
includes Gen VII (`Generation VII onwards`, `Generations V to VII`, `Generation VI onwards`, ...);
|
||||||
|
ignore subsections entirely outside it (`Generation I`, `Generation VIII`, side games like
|
||||||
|
Legends: Arceus). Ability pages often use range headers like "Generations V to VII" — take the
|
||||||
|
range containing VII. Also note interactions the page calls out (e.g. "does not activate if a
|
||||||
|
Pokémon with Damp is on the field") — those are test cases too.
|
||||||
|
|
||||||
|
## Step 3 — Read the implementation
|
||||||
|
|
||||||
|
Read the script class under `Plugins/PkmnLib.Plugin.Gen7/Scripts/{Moves,Abilities}/<ClassName>.cs`
|
||||||
|
to learn which hooks it overrides (`ChangeBasePower`, `OnIncomingHit`, `OnFaint`, ...) and what
|
||||||
|
its public surface looks like. Tests drive those hook methods directly — do not test behavior
|
||||||
|
the script class isn't responsible for (type effectiveness, generic damage formula, or static
|
||||||
|
data like base power and accuracy; `DataTests/` already covers move/ability data).
|
||||||
|
|
||||||
|
## Step 4 — Write atomic tests
|
||||||
|
|
||||||
|
Follow the existing conventions — `Scripts/Moves/DrainTests.cs` and
|
||||||
|
`Scripts/Abilities/AftermathTests.cs` are the reference examples (`DrainTests.cs` also shows
|
||||||
|
`<see>` usage and a real `[TestFailing]`), `Scripts/Moves/AcrobaticsTests.cs` a minimal one:
|
||||||
|
|
||||||
|
- File: `Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/<ClassName>Tests.cs` or
|
||||||
|
`.../Scripts/Abilities/<ClassName>Tests.cs`.
|
||||||
|
- Namespace: `PkmnLib.Plugin.Gen7.Tests.Scripts.Moves` / `...Abilities`. `TUnit` and
|
||||||
|
`NSubstitute` are global usings; add the `PkmnLib.*` usings the test needs.
|
||||||
|
- **One behavior per test** (atomic). Name: `Method_Scenario_ExpectedOutcome`, e.g.
|
||||||
|
`ChangeBasePower_UserNotHoldingItem_BasePowerDoubles`.
|
||||||
|
- Each test gets an XML doc comment **quoting the Bulbapedia sentence it verifies**:
|
||||||
|
`/// Bulbapedia: "When a Pokémon with this Ability faints from damage caused by a move that makes contact, ..."`.
|
||||||
|
- In doc comments, refer to other classes and members with `<see>` tags instead of plain text —
|
||||||
|
the class-level comment references the script under test
|
||||||
|
(`/// Tests for the <see cref="Aftermath"/> ability.`), and test comments reference types or
|
||||||
|
members they mention (`/// If the attacker has no <see cref="IPokemon.BattleData"/>, ...`).
|
||||||
|
- Arrange/Act/Assert with `Substitute.For<IExecutingMove>()`, `Substitute.For<IPokemon>()`, etc.
|
||||||
|
Assertions are TUnit style: `await Assert.That(actual).IsEqualTo(expected)`.
|
||||||
|
- Use `[Arguments(...)]` rows for numeric variations (damage fractions, rounding/integer
|
||||||
|
division, overflow guards).
|
||||||
|
- Cover the negative cases the description implies (non-contact move → no trigger, holding an
|
||||||
|
item → no boost, attacker already fainted, null `BattleData`, ...).
|
||||||
|
- `LibraryHelpers.LoadLibrary()` is available when a test genuinely needs the full Gen7 library
|
||||||
|
(e.g. looking up real move data); prefer pure mocks otherwise.
|
||||||
|
|
||||||
|
## Step 5 — Build, run, and mark failures
|
||||||
|
|
||||||
|
1. Build: `dotnet build Plugins/PkmnLib.Plugin.Gen7.Tests`. Compile errors are always
|
||||||
|
test-authoring mistakes — fix them; the attribute cannot rescue non-compiling code.
|
||||||
|
2. Run the new tests: `dotnet test Plugins/PkmnLib.Plugin.Gen7.Tests` (TUnit runs on
|
||||||
|
Microsoft.Testing.Platform; to run a single class, pass
|
||||||
|
`-- --treenode-filter '/*/*/<ClassName>Tests/*'`).
|
||||||
|
3. For each failing test, decide who is wrong:
|
||||||
|
- **The test is wrong** (misread the page, wrong mock setup, asserting behavior of a
|
||||||
|
different generation) → fix the test and rerun.
|
||||||
|
- **The implementation is wrong** (test correctly encodes Gen VII Bulbapedia behavior but the
|
||||||
|
script does something else) → keep the correct assertion and add the marker so it is
|
||||||
|
skipped but tracked:
|
||||||
|
```csharp
|
||||||
|
[Test, TestFailing("Aftermath deals 1/2 max HP instead of 1/4 (Bulbapedia: '¼ of its own maximum HP')")]
|
||||||
|
```
|
||||||
|
`TestFailingAttribute` lives in `PkmnLib.Plugin.Gen7.Tests` and takes the failure reason as
|
||||||
|
its argument — describe concretely what the implementation does wrong.
|
||||||
|
- Never make a wrong test pass by asserting the buggy behavior, and never touch the
|
||||||
|
implementation to make a test pass.
|
||||||
|
4. Rerun until every test either passes or carries `[TestFailing(...)]`.
|
||||||
|
|
||||||
|
## Step 6 — Report
|
||||||
|
|
||||||
|
Summarize: tests written per target, tests marked `[TestFailing]` with their reasons (these are
|
||||||
|
implementation bugs the user needs to fix), and targets skipped because no Gen7 implementation
|
||||||
|
exists.
|
||||||
@@ -93,7 +93,7 @@ public class BattleChoiceQueue : IDeepCloneable
|
|||||||
if (index == -1)
|
if (index == -1)
|
||||||
return false;
|
return false;
|
||||||
if (index == _currentIndex)
|
if (index == _currentIndex)
|
||||||
return true;
|
return false; // Already next
|
||||||
var choice = _choices[index];
|
var choice = _choices[index];
|
||||||
_choices[index] = null;
|
_choices[index] = null;
|
||||||
// Push all choices back
|
// Push all choices back
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using PkmnLib.Dynamic.Models;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||||
|
using PkmnLib.Static;
|
||||||
|
|
||||||
|
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="Acupressure"/> move script.
|
||||||
|
/// Gen VII Bulbapedia behavior: "Acupressure chooses one of the target's stats at random and raises it by
|
||||||
|
/// two stages. It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed,
|
||||||
|
/// accuracy, or evasion stat but will not attempt to raise a stat that is already maximized, meaning that
|
||||||
|
/// the move will fail if all stats are maximized."
|
||||||
|
/// </summary>
|
||||||
|
public class AcupressureTests
|
||||||
|
{
|
||||||
|
private static (Acupressure script, IExecutingMove move, IPokemon target, IBattleRandom random, IHitData hitData)
|
||||||
|
CreateTestSetup()
|
||||||
|
{
|
||||||
|
var script = new Acupressure();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
var hitData = Substitute.For<IHitData>();
|
||||||
|
move.GetHitData(target, 0).Returns(hitData);
|
||||||
|
|
||||||
|
var random = Substitute.For<IBattleRandom>();
|
||||||
|
var battle = Substitute.For<IBattle>();
|
||||||
|
battle.Random.Returns(random);
|
||||||
|
var battleData = Substitute.For<IPokemonBattleData>();
|
||||||
|
battleData.Battle.Returns(battle);
|
||||||
|
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
user.BattleData.Returns(battleData);
|
||||||
|
move.User.Returns(user);
|
||||||
|
|
||||||
|
target.StatBoost.Returns(new StatBoostStatisticSet());
|
||||||
|
|
||||||
|
return (script, move, target, random, hitData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to extract the stat argument of a ChangeStatBoost call received by the target.
|
||||||
|
/// </summary>
|
||||||
|
private static object?[]? GetStatBoostCallArgs(IPokemon target)
|
||||||
|
{
|
||||||
|
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
|
||||||
|
return call?.GetArguments();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Acupressure chooses one of the target's stats at random and raises it by two stages."
|
||||||
|
/// When the random roll selects Attack, the target's Attack is raised by exactly two stages.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_RandomSelectsAttack_RaisesAttackByTwoStages()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, random, _) = CreateTestSetup();
|
||||||
|
// Attack is the first entry in the pool of raisable stats (HP is excluded).
|
||||||
|
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(0);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var args = GetStatBoostCallArgs(target);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.Attack);
|
||||||
|
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed,
|
||||||
|
/// accuracy, or evasion stat" — HP is not one of the raisable stats. When the random roll returns the lowest
|
||||||
|
/// value of its range, the selected stat must be Attack, not HP.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_RandomMinimumRoll_SelectsAttackNotHp()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, random, _) = CreateTestSetup();
|
||||||
|
// Return the inclusive lower bound of whatever range is requested.
|
||||||
|
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(ci => ci.ArgAt<int>(0));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var args = GetStatBoostCallArgs(target);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.Attack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed,
|
||||||
|
/// accuracy, or evasion stat". Accuracy and evasion must be selectable: when the random roll returns the
|
||||||
|
/// highest value of its range, the selected stat should be one of the stats beyond Speed in the
|
||||||
|
/// <see cref="Statistic"/> enum (Evasion or Accuracy).
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_RandomMaximumRoll_CanSelectAccuracyOrEvasion()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, random, _) = CreateTestSetup();
|
||||||
|
// Return the highest value of whatever range is requested (upper bound is exclusive).
|
||||||
|
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(ci => ci.ArgAt<int>(1) - 1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert - the highest selectable stat should be Evasion or Accuracy
|
||||||
|
var args = GetStatBoostCallArgs(target);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
var stat = (Statistic)args![0]!;
|
||||||
|
await Assert.That(stat is Statistic.Evasion or Statistic.Accuracy).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "the move will fail if all stats are maximized." All raisable stats (Attack through accuracy;
|
||||||
|
/// HP boosts cannot be raised by any move and stay at 0) are at +6, so the move must fail without attempting
|
||||||
|
/// a stat change.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_AllRaisableStatsMaximized_Fails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, _, hitData) = CreateTestSetup();
|
||||||
|
var statBoost = new StatBoostStatisticSet(0, 6, 6, 6, 6, 6) { Evasion = 6, Accuracy = 6 };
|
||||||
|
target.StatBoost.Returns(statBoost);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.Received(1).Fail();
|
||||||
|
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "will not attempt to raise a stat that is already maximized". With the target's Attack at +6
|
||||||
|
/// and other stats available, Attack must never be the raised stat, regardless of what the random roll
|
||||||
|
/// returns. The rolls cover every index of the six-stat selection pool that remains.
|
||||||
|
/// </summary>
|
||||||
|
[Test, Arguments(0), Arguments(1), Arguments(2), Arguments(3), Arguments(4), Arguments(5)]
|
||||||
|
public async Task OnSecondaryEffect_StatAlreadyMaximized_NeverRaisesThatStat(int roll)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, random, _) = CreateTestSetup();
|
||||||
|
var statBoost = new StatBoostStatisticSet(0, 6, 0, 0, 0, 0);
|
||||||
|
target.StatBoost.Returns(statBoost);
|
||||||
|
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(roll);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert - a stat is raised, but never the maximized Attack
|
||||||
|
var args = GetStatBoostCallArgs(target);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
await Assert.That((Statistic)args![0]!).IsNotEqualTo(Statistic.Attack);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AfterYouTests.cs
Normal file
140
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AfterYouTests.cs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
using PkmnLib.Dynamic.Models;
|
||||||
|
using PkmnLib.Dynamic.Models.Choices;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||||
|
|
||||||
|
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="AfterYou"/> move script.
|
||||||
|
/// Gen VII Bulbapedia behavior (Generations V to VII): "The target will move next on the current turn,
|
||||||
|
/// ignoring priority. [...] It fails if the target has already moved on the same turn. [...] After You fails
|
||||||
|
/// if the order remains the same after using After You."
|
||||||
|
/// </summary>
|
||||||
|
public class AfterYouTests
|
||||||
|
{
|
||||||
|
private static IMoveChoice CreateChoice(IPokemon user, uint speed)
|
||||||
|
{
|
||||||
|
var choice = Substitute.For<IMoveChoice>();
|
||||||
|
choice.User.Returns(user);
|
||||||
|
choice.Speed.Returns(speed);
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (AfterYou script, IExecutingMove move, IHitData hitData) CreateTestSetup(BattleChoiceQueue? queue)
|
||||||
|
{
|
||||||
|
var script = new AfterYou();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var hitData = Substitute.For<IHitData>();
|
||||||
|
move.GetHitData(Arg.Any<IPokemon>(), Arg.Any<byte>()).Returns(hitData);
|
||||||
|
|
||||||
|
var battle = Substitute.For<IBattle>();
|
||||||
|
battle.ChoiceQueue.Returns(queue);
|
||||||
|
var battleData = Substitute.For<IPokemonBattleData>();
|
||||||
|
battleData.Battle.Returns(battle);
|
||||||
|
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
user.BattleData.Returns(battleData);
|
||||||
|
move.User.Returns(user);
|
||||||
|
|
||||||
|
return (script, move, hitData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "The target will move next on the current turn, ignoring priority."
|
||||||
|
/// The target's choice is moved in front of a faster Pokémon's remaining choice, and the move does not fail.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_TargetLaterInQueue_TargetMovesNext()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
var other = Substitute.For<IPokemon>();
|
||||||
|
// Sorted by speed: user (100), other (75), target (50)
|
||||||
|
var queue = new BattleChoiceQueue([
|
||||||
|
CreateChoice(user, 100), CreateChoice(other, 75), CreateChoice(target, 50),
|
||||||
|
]);
|
||||||
|
// The user's own choice is executing, so it has been dequeued already.
|
||||||
|
queue.Dequeue();
|
||||||
|
|
||||||
|
var (script, move, hitData) = CreateTestSetup(queue);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert - the target is now the next Pokémon to move, ahead of the faster one
|
||||||
|
await Assert.That(queue.Peek()!.User).IsEqualTo(target);
|
||||||
|
hitData.DidNotReceive().Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "It fails if the target has already moved on the same turn."
|
||||||
|
/// The target's choice is no longer in the queue, so the move fails.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void OnSecondaryEffect_TargetAlreadyMoved_Fails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
// Sorted by speed: target (100), user (50)
|
||||||
|
var queue = new BattleChoiceQueue([
|
||||||
|
CreateChoice(target, 100), CreateChoice(user, 50),
|
||||||
|
]);
|
||||||
|
// The target already moved this turn, and the user's own choice is executing.
|
||||||
|
queue.Dequeue();
|
||||||
|
queue.Dequeue();
|
||||||
|
|
||||||
|
var (script, move, hitData) = CreateTestSetup(queue);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.Received(1).Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "After You fails if the order remains the same after using After You."
|
||||||
|
/// The target was already the next Pokémon to move, so using After You changes nothing and the move
|
||||||
|
/// must fail.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void OnSecondaryEffect_TargetAlreadyNext_Fails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
// Sorted by speed: user (100), target (50)
|
||||||
|
var queue = new BattleChoiceQueue([
|
||||||
|
CreateChoice(user, 100), CreateChoice(target, 50),
|
||||||
|
]);
|
||||||
|
// The user's own choice is executing; the target is already next.
|
||||||
|
queue.Dequeue();
|
||||||
|
|
||||||
|
var (script, move, hitData) = CreateTestSetup(queue);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.Received(1).Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: when the battle has no active choice queue, the script returns without throwing
|
||||||
|
/// and without failing the hit.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void OnSecondaryEffect_NoChoiceQueue_DoesNothing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, hitData) = CreateTestSetup(null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.DidNotReceive().Fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
205
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AssistTests.cs
Normal file
205
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AssistTests.cs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
using PkmnLib.Dynamic.Models;
|
||||||
|
using PkmnLib.Dynamic.Models.Choices;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||||
|
using PkmnLib.Static.Moves;
|
||||||
|
using PkmnLib.Static.Utils;
|
||||||
|
|
||||||
|
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="Assist"/> move script.
|
||||||
|
/// Gen VII Bulbapedia behavior: "The Assist user executes a move that is randomly selected from all the
|
||||||
|
/// eligible moves known by other Pokémon in the same party. If the same move is known by multiple Pokémon,
|
||||||
|
/// each of them counts [...]. Assist can call a move that currently has no PP or is known by a fainted
|
||||||
|
/// Pokémon. [...] If the other Pokémon in the user's party do not know any eligible moves, Assist fails."
|
||||||
|
/// </summary>
|
||||||
|
public class AssistTests
|
||||||
|
{
|
||||||
|
private static IPokemon CreatePokemonWithMoves(params string[] moveNames)
|
||||||
|
{
|
||||||
|
var pokemon = Substitute.For<IPokemon>();
|
||||||
|
var moves = moveNames.Select(name =>
|
||||||
|
{
|
||||||
|
var learned = Substitute.For<ILearnedMove>();
|
||||||
|
var data = Substitute.For<IMoveData>();
|
||||||
|
data.Name.Returns(new StringKey(name));
|
||||||
|
learned.MoveData.Returns(data);
|
||||||
|
return (ILearnedMove?)learned;
|
||||||
|
}).ToArray();
|
||||||
|
pokemon.Moves.Returns(moves);
|
||||||
|
return pokemon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Assist script, IMoveChoice choice, IPokemon user, IBattleRandom random) CreateTestSetup(
|
||||||
|
params IPokemon?[] otherPartyMembers)
|
||||||
|
{
|
||||||
|
var script = new Assist();
|
||||||
|
var user = CreatePokemonWithMoves("tackle");
|
||||||
|
|
||||||
|
var members = new List<IPokemon?> { user };
|
||||||
|
members.AddRange(otherPartyMembers);
|
||||||
|
|
||||||
|
var party = Substitute.For<IPokemonParty>();
|
||||||
|
party.GetEnumerator().Returns(_ => members.GetEnumerator());
|
||||||
|
var battleParty = Substitute.For<IBattleParty>();
|
||||||
|
battleParty.Party.Returns(party);
|
||||||
|
|
||||||
|
var random = Substitute.For<IBattleRandom>();
|
||||||
|
var battle = Substitute.For<IBattle>();
|
||||||
|
battle.Parties.Returns(new[] { battleParty });
|
||||||
|
battle.Random.Returns(random);
|
||||||
|
var battleData = Substitute.For<IPokemonBattleData>();
|
||||||
|
battleData.Battle.Returns(battle);
|
||||||
|
user.BattleData.Returns(battleData);
|
||||||
|
|
||||||
|
var choice = Substitute.For<IMoveChoice>();
|
||||||
|
choice.User.Returns(user);
|
||||||
|
|
||||||
|
return (script, choice, user, random);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "The Assist user executes a move that is randomly selected from all the eligible moves known
|
||||||
|
/// by other Pokémon in the same party."
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task ChangeMove_AllyKnowsEligibleMove_SelectsThatMove()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, choice, _, random) = CreateTestSetup(CreatePokemonWithMoves("growl"));
|
||||||
|
random.GetInt(Arg.Any<int>()).Returns(0);
|
||||||
|
StringKey moveName = "assist";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.ChangeMove(choice, ref moveName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
|
||||||
|
choice.DidNotReceive().Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "If the other Pokémon in the user's party do not know any eligible moves, Assist fails."
|
||||||
|
/// The user's own moves are not eligible: with no other party members, the move fails even though the
|
||||||
|
/// user itself knows a copyable move.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task ChangeMove_NoOtherPartyMembers_Fails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, choice, _, _) = CreateTestSetup();
|
||||||
|
StringKey moveName = "assist";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.ChangeMove(choice, ref moveName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
choice.Received(1).Fail();
|
||||||
|
await Assert.That(moveName).IsEqualTo(new StringKey("assist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia lists moves that Assist cannot call (e.g. Protect, Counter, moves with a semi-invulnerable
|
||||||
|
/// turn). An ally that only knows ineligible moves provides no options, so Assist fails.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task ChangeMove_AllyOnlyKnowsIneligibleMoves_Fails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, choice, _, _) = CreateTestSetup(CreatePokemonWithMoves("protect", "counter", "dig"));
|
||||||
|
StringKey moveName = "assist";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.ChangeMove(choice, ref moveName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
choice.Received(1).Fail();
|
||||||
|
await Assert.That(moveName).IsEqualTo(new StringKey("assist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "If the same move is known by multiple Pokémon, each of them counts and therefore the move
|
||||||
|
/// is more likely to be called." Two allies knowing Tackle plus one knowing Growl yields three entries in
|
||||||
|
/// the random selection pool.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void ChangeMove_SameMoveKnownByMultipleAllies_EachCounts()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, choice, _, random) = CreateTestSetup(CreatePokemonWithMoves("pound"),
|
||||||
|
CreatePokemonWithMoves("pound"), CreatePokemonWithMoves("growl"));
|
||||||
|
random.GetInt(Arg.Any<int>()).Returns(0);
|
||||||
|
StringKey moveName = "assist";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.ChangeMove(choice, ref moveName);
|
||||||
|
|
||||||
|
// Assert - the selection pool contains three moves (pound twice, growl once)
|
||||||
|
random.Received(1).GetInt(3);
|
||||||
|
choice.DidNotReceive().Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Assist can call a move that [...] is known by a fainted Pokémon."
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task ChangeMove_AllyIsFainted_MoveStillEligible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var faintedAlly = CreatePokemonWithMoves("growl");
|
||||||
|
faintedAlly.IsUsable.Returns(false);
|
||||||
|
var (script, choice, _, random) = CreateTestSetup(faintedAlly);
|
||||||
|
random.GetInt(Arg.Any<int>()).Returns(0);
|
||||||
|
StringKey moveName = "assist";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.ChangeMove(choice, ref moveName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
|
||||||
|
choice.DidNotReceive().Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Assist can call a move that currently has no PP".
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task ChangeMove_AllyMoveHasNoPp_MoveStillEligible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var ally = CreatePokemonWithMoves("growl");
|
||||||
|
ally.Moves[0]!.CurrentPp.Returns((byte)0);
|
||||||
|
var (script, choice, _, random) = CreateTestSetup(ally);
|
||||||
|
random.GetInt(Arg.Any<int>()).Returns(0);
|
||||||
|
StringKey moveName = "assist";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.ChangeMove(choice, ref moveName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
|
||||||
|
choice.DidNotReceive().Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: outside of battle (no battle data) the script returns without failing the choice.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task ChangeMove_NoBattleData_DoesNothing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = new Assist();
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
user.BattleData.Returns((IPokemonBattleData?)null);
|
||||||
|
var choice = Substitute.For<IMoveChoice>();
|
||||||
|
choice.User.Returns(user);
|
||||||
|
StringKey moveName = "assist";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.ChangeMove(choice, ref moveName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
choice.DidNotReceive().Fail();
|
||||||
|
await Assert.That(moveName).IsEqualTo(new StringKey("assist"));
|
||||||
|
}
|
||||||
|
}
|
||||||
108
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AttractTests.cs
Normal file
108
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AttractTests.cs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
using PkmnLib.Dynamic.Models;
|
||||||
|
using PkmnLib.Dynamic.ScriptHandling;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
|
||||||
|
using PkmnLib.Static.Species;
|
||||||
|
using PkmnLib.Static.Utils;
|
||||||
|
|
||||||
|
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="Attract"/> move script.
|
||||||
|
/// Gen VII Bulbapedia behavior: "The target becomes infatuated with the user if they have opposite genders.
|
||||||
|
/// [...] Attract has no effect if the user and target have the same gender, or if either of them is a
|
||||||
|
/// gender-unknown Pokémon (such as Magnemite). It also fails when used on a Pokémon that is already infatuated."
|
||||||
|
/// </summary>
|
||||||
|
public class AttractTests
|
||||||
|
{
|
||||||
|
private static (Attract script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile, IHitData hitData)
|
||||||
|
CreateTestSetup(Gender userGender, Gender targetGender)
|
||||||
|
{
|
||||||
|
var script = new Attract();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
var hitData = Substitute.For<IHitData>();
|
||||||
|
move.GetHitData(target, 0).Returns(hitData);
|
||||||
|
|
||||||
|
var targetVolatile = Substitute.For<IScriptSet>();
|
||||||
|
target.Volatile.Returns(targetVolatile);
|
||||||
|
target.Gender.Returns(targetGender);
|
||||||
|
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
user.Gender.Returns(userGender);
|
||||||
|
move.User.Returns(user);
|
||||||
|
|
||||||
|
return (script, move, target, targetVolatile, hitData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "The target becomes infatuated with the user if they have opposite genders."
|
||||||
|
/// </summary>
|
||||||
|
[Test, Arguments(Gender.Male, Gender.Female), Arguments(Gender.Female, Gender.Male)]
|
||||||
|
public void OnSecondaryEffect_OppositeGenders_TargetBecomesInfatuated(Gender userGender, Gender targetGender)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(userGender, targetGender);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
targetVolatile.Received(1).Add(Arg.Any<Infatuated>());
|
||||||
|
hitData.DidNotReceive().Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Attract has no effect if the user and target have the same gender".
|
||||||
|
/// </summary>
|
||||||
|
[Test, Arguments(Gender.Male, Gender.Male), Arguments(Gender.Female, Gender.Female)]
|
||||||
|
public void OnSecondaryEffect_SameGender_Fails(Gender userGender, Gender targetGender)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(userGender, targetGender);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.Received(1).Fail();
|
||||||
|
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Attract has no effect [...] if either of them is a gender-unknown Pokémon (such as Magnemite)."
|
||||||
|
/// Covers a genderless user, a genderless target, and both being genderless.
|
||||||
|
/// </summary>
|
||||||
|
[Test, Arguments(Gender.Genderless, Gender.Female), Arguments(Gender.Male, Gender.Genderless),
|
||||||
|
Arguments(Gender.Genderless, Gender.Genderless)]
|
||||||
|
public void OnSecondaryEffect_GenderlessPokemon_Fails(Gender userGender, Gender targetGender)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(userGender, targetGender);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.Received(1).Fail();
|
||||||
|
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "It also fails when used on a Pokémon that is already infatuated."
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void OnSecondaryEffect_TargetAlreadyInfatuated_Fails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(Gender.Male, Gender.Female);
|
||||||
|
targetVolatile.Contains(new StringKey("infatuated")).Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.Received(1).Fail();
|
||||||
|
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using PkmnLib.Dynamic.Models;
|
||||||
|
using PkmnLib.Dynamic.ScriptHandling;
|
||||||
|
using PkmnLib.Dynamic.ScriptHandling.Registry;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Side;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Weather;
|
||||||
|
using PkmnLib.Static.Utils;
|
||||||
|
|
||||||
|
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="AuroraVeil"/> move script.
|
||||||
|
/// Gen VII Bulbapedia behavior: "Aurora Veil reduces the damage done to the user by physical and special
|
||||||
|
/// moves for five turns [...]. The move cannot be activated unless hail [...] is present. [...] Holding
|
||||||
|
/// Light Clay extends the duration from 5 to 8 turns."
|
||||||
|
/// </summary>
|
||||||
|
public class AuroraVeilTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Test helper script that extends the Aurora Veil duration through the custom trigger, the same way the
|
||||||
|
/// Light Clay item script does.
|
||||||
|
/// </summary>
|
||||||
|
[Script(ScriptCategory.Pokemon, "test_aurora_veil_duration_extender")]
|
||||||
|
private class DurationExtender : Script, IScriptCustomTrigger
|
||||||
|
{
|
||||||
|
public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
|
||||||
|
{
|
||||||
|
if (eventName == CustomTriggers.AuroraVeilDuration && args is CustomTriggers.AuroraVeilDurationArgs d)
|
||||||
|
d.Duration = 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (AuroraVeil script, IExecutingMove move, IPokemon target, IPokemon user, IScriptSet sideScripts,
|
||||||
|
IHitData hitData) CreateTestSetup(StringKey? weather)
|
||||||
|
{
|
||||||
|
var script = new AuroraVeil();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
var hitData = Substitute.For<IHitData>();
|
||||||
|
move.GetHitData(target, 0).Returns(hitData);
|
||||||
|
|
||||||
|
var battle = Substitute.For<IBattle>();
|
||||||
|
battle.WeatherName.Returns(weather);
|
||||||
|
|
||||||
|
var sideScripts = Substitute.For<IScriptSet>();
|
||||||
|
// Mimic the real ScriptSet: invoke the factory and hand back a container holding the new script.
|
||||||
|
sideScripts.StackOrAdd(Arg.Any<StringKey>(), Arg.Any<Func<Script?>>())
|
||||||
|
.Returns(ci => new ScriptContainer(ci.Arg<Func<Script?>>()()!));
|
||||||
|
var side = Substitute.For<IBattleSide>();
|
||||||
|
side.VolatileScripts.Returns(sideScripts);
|
||||||
|
battle.Sides.Returns(new[] { side });
|
||||||
|
|
||||||
|
var battleData = Substitute.For<IPokemonBattleData>();
|
||||||
|
battleData.Battle.Returns(battle);
|
||||||
|
battleData.SideIndex.Returns((byte)0);
|
||||||
|
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
user.BattleData.Returns(battleData);
|
||||||
|
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
|
||||||
|
move.User.Returns(user);
|
||||||
|
|
||||||
|
return (script, move, target, user, sideScripts, hitData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to extract the effect script instance created through StackOrAdd on the side's volatile scripts.
|
||||||
|
/// </summary>
|
||||||
|
private static AuroraVeilEffect? GetAddedEffect(IScriptSet sideScripts)
|
||||||
|
{
|
||||||
|
var call = sideScripts.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "StackOrAdd");
|
||||||
|
if (call == null)
|
||||||
|
return null;
|
||||||
|
var factory = (Func<Script?>)call.GetArguments()[1]!;
|
||||||
|
return factory() as AuroraVeilEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "The move cannot be activated unless hail [...] is present."
|
||||||
|
/// Without any weather, the move fails and no effect is added to the side.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_NoWeather_Fails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.Received(1).Fail();
|
||||||
|
await Assert.That(sideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "The move cannot be activated unless hail [...] is present."
|
||||||
|
/// Under a different weather (e.g. rain), the move also fails.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_NonHailWeather_Fails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(new StringKey("rain"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
hitData.Received(1).Fail();
|
||||||
|
await Assert.That(sideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Aurora Veil reduces the damage done to the user by physical and special moves for five
|
||||||
|
/// turns". During hail, the Aurora Veil effect is added to the user's side with a duration of 5 turns.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_Hail_AddsEffectWithFiveTurns()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(ScriptUtils.ResolveName<Hail>());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var effect = GetAddedEffect(sideScripts);
|
||||||
|
await Assert.That(effect).IsNotNull();
|
||||||
|
await Assert.That(effect!.NumberOfTurns).IsEqualTo(5);
|
||||||
|
hitData.DidNotReceive().Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "If a Light Clay is held when Aurora Veil is used, it will extend the duration of Aurora
|
||||||
|
/// Veil from 5 to 8 turns." The duration extension flows through the AuroraVeilDuration custom trigger;
|
||||||
|
/// a script on the user that sets the duration to 8 results in an effect lasting 8 turns.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_DurationExtendedByTrigger_AddsEffectWithEightTurns()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, target, user, sideScripts, _) = CreateTestSetup(ScriptUtils.ResolveName<Hail>());
|
||||||
|
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>
|
||||||
|
{
|
||||||
|
new ScriptContainer(new DurationExtender()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert - the created effect is initialized with the extended duration
|
||||||
|
var call = sideScripts.ReceivedCalls().First(c => c.GetMethodInfo().Name == "StackOrAdd");
|
||||||
|
var effect = (AuroraVeilEffect)((Func<Script?>)call.GetArguments()[1]!)()!;
|
||||||
|
await Assert.That(effect.NumberOfTurns).IsEqualTo(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: outside of battle (no battle data) the script returns without throwing.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_NoBattleData_DoesNothing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = new AuroraVeil();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
user.BattleData.Returns((IPokemonBattleData?)null);
|
||||||
|
move.User.Returns(user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert - no hit data was touched
|
||||||
|
await Assert.That(move.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "GetHitData")).IsFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using PkmnLib.Dynamic.Events;
|
||||||
|
using PkmnLib.Dynamic.Models;
|
||||||
|
using PkmnLib.Dynamic.ScriptHandling;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
|
||||||
|
using PkmnLib.Static;
|
||||||
|
|
||||||
|
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="Autotomize"/> move script.
|
||||||
|
/// Gen VII Bulbapedia behavior: "Autotomize raises the user's Speed stat by two stages and (if the user
|
||||||
|
/// successfully changes its Speed) decreases its weight by 220 lbs. (100 kg). If the user successfully
|
||||||
|
/// changes its weight, the message '<Pokémon> became nimble!' is displayed. Autotomize cannot decrease
|
||||||
|
/// the user's weight below the minimum 0.2 lbs (0.1 kg); if the user's weight would drop below the minimum,
|
||||||
|
/// it becomes the minimum instead. Weight loss from Autotomize stacks".
|
||||||
|
/// </summary>
|
||||||
|
public class AutotomizeTests
|
||||||
|
{
|
||||||
|
private static (Autotomize script, IExecutingMove move, IPokemon user, IScriptSet userVolatile, EventHook eventHook)
|
||||||
|
CreateTestSetup(float weightInKg, bool speedRaiseSucceeds, AutotomizeEffect? existingEffect = null)
|
||||||
|
{
|
||||||
|
var script = new Autotomize();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
|
||||||
|
var eventHook = new EventHook();
|
||||||
|
var battle = Substitute.For<IBattle>();
|
||||||
|
battle.EventHook.Returns(eventHook);
|
||||||
|
var battleData = Substitute.For<IPokemonBattleData>();
|
||||||
|
battleData.Battle.Returns(battle);
|
||||||
|
|
||||||
|
var userVolatile = Substitute.For<IScriptSet>();
|
||||||
|
userVolatile.Get<AutotomizeEffect>().Returns(existingEffect);
|
||||||
|
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
user.BattleData.Returns(battleData);
|
||||||
|
user.Volatile.Returns(userVolatile);
|
||||||
|
user.WeightInKg.Returns(weightInKg);
|
||||||
|
user.ChangeStatBoost(Arg.Any<Statistic>(), Arg.Any<sbyte>(), Arg.Any<bool>(), Arg.Any<bool>())
|
||||||
|
.Returns(speedRaiseSucceeds);
|
||||||
|
move.User.Returns(user);
|
||||||
|
|
||||||
|
return (script, move, user, userVolatile, eventHook);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ReceivedStackOrAdd(IScriptSet scriptSet) =>
|
||||||
|
scriptSet.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Autotomize raises the user's Speed stat by two stages". The stat change is self-inflicted.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void OnSecondaryEffect_RaisesUserSpeedByTwoStages()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, user, _, _) = CreateTestSetup(100f, true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
user.Received(1).ChangeStatBoost(Statistic.Speed, 2, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "(if the user successfully changes its Speed) decreases its weight by 220 lbs. (100 kg).
|
||||||
|
/// If the user successfully changes its weight, the message '<Pokémon> became nimble!' is displayed."
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_SpeedRaiseSucceeds_AddsWeightReductionAndShowsMessage()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, _, userVolatile, eventHook) = CreateTestSetup(100f, true);
|
||||||
|
DialogEvent? capturedEvent = null;
|
||||||
|
eventHook.Handler += (_, args) =>
|
||||||
|
{
|
||||||
|
if (args is DialogEvent dialogEvent)
|
||||||
|
capturedEvent = dialogEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
|
||||||
|
await Assert.That(capturedEvent).IsNotNull();
|
||||||
|
await Assert.That(capturedEvent!.Message).IsEqualTo("pokemon_became_nimble");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: the weight decrease only happens "if the user successfully changes its Speed". When the
|
||||||
|
/// Speed stat cannot be raised (already at +6), no weight reduction is applied and no message is shown.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_SpeedRaiseFails_NoWeightReductionOrMessage()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (script, move, _, userVolatile, eventHook) = CreateTestSetup(100f, false);
|
||||||
|
var eventFired = false;
|
||||||
|
eventHook.Handler += (_, _) => eventFired = true;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsFalse();
|
||||||
|
await Assert.That(eventFired).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Autotomize cannot decrease the user's weight below the minimum 0.2 lbs (0.1 kg); if the
|
||||||
|
/// user's weight would drop below the minimum, it becomes the minimum instead." A first use on a Pokémon
|
||||||
|
/// lighter than 100 kg still applies the weight reduction (the weight clamps to the minimum).
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_FirstUseOnLightPokemon_StillAppliesWeightReduction()
|
||||||
|
{
|
||||||
|
// Arrange - 50 kg Pokémon, no previous Autotomize use
|
||||||
|
var (script, move, _, userVolatile, _) = CreateTestSetup(50f, true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Weight loss from Autotomize stacks, so using it multiple times will continue to decrease
|
||||||
|
/// the user's weight accordingly until it reaches the minimum weight" and "if the user's weight would drop
|
||||||
|
/// below the minimum, it becomes the minimum instead." A Pokémon that still weighs more than the minimum
|
||||||
|
/// after two stacks must receive another stack (its weight drops to the 0.1 kg minimum).
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_WeightWouldDropBelowMinimum_StillReducesToMinimum()
|
||||||
|
{
|
||||||
|
// Arrange - the user already has two stacks and currently weighs 50 kg
|
||||||
|
var existingEffect = new AutotomizeEffect();
|
||||||
|
existingEffect.Stack();
|
||||||
|
var (script, move, _, userVolatile, _) = CreateTestSetup(50f, true, existingEffect);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||||
|
|
||||||
|
// Assert - the weight should still be reduced (to the minimum)
|
||||||
|
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Autotomize cannot decrease the user's weight below the minimum 0.2 lbs (0.1 kg)".
|
||||||
|
/// A Pokémon already at the minimum weight cannot lose more weight, so no new stack is added and no
|
||||||
|
/// message is shown; the Speed boost itself is still applied.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_AlreadyAtMinimumWeight_NoFurtherReductionButSpeedStillRaised()
|
||||||
|
{
|
||||||
|
// Arrange - the user already has a stack and is at the minimum weight
|
||||||
|
var (script, move, user, userVolatile, eventHook) = CreateTestSetup(0.1f, true, new AutotomizeEffect());
|
||||||
|
var eventFired = false;
|
||||||
|
eventHook.Handler += (_, _) => eventFired = true;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
user.Received(1).ChangeStatBoost(Statistic.Speed, 2, true, false);
|
||||||
|
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsFalse();
|
||||||
|
await Assert.That(eventFired).IsFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
using PkmnLib.Dynamic.Models;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||||
|
using PkmnLib.Static;
|
||||||
|
using PkmnLib.Static.Utils;
|
||||||
|
using TUnit.Assertions.AssertConditions.Throws;
|
||||||
|
|
||||||
|
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="ChangeTargetSpecialDefense"/> script, which implements the secondary effect of Acid.
|
||||||
|
/// Behavior is verified against the Bulbapedia page for Acid.
|
||||||
|
/// Gen VII behavior: "Acid inflicts damage to all adjacent opponents" and "has a 10% chance of lowering the
|
||||||
|
/// target's Special Defense stat by one stage". The 10% proc chance and the targeting are handled by the
|
||||||
|
/// generic secondary effect data (chance: 10 in Moves.jsonc); this script is responsible for applying the
|
||||||
|
/// stat change once the effect procs.
|
||||||
|
/// </summary>
|
||||||
|
public class ChangeTargetSpecialDefenseTests
|
||||||
|
{
|
||||||
|
private static ChangeTargetSpecialDefense CreateInitializedScript(int amount = -1)
|
||||||
|
{
|
||||||
|
var script = new ChangeTargetSpecialDefense();
|
||||||
|
script.OnInitialize(new Dictionary<StringKey, object?> { { "amount", amount } });
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to extract the arguments of the ChangeStatBoost call received by a substitute target.
|
||||||
|
/// </summary>
|
||||||
|
private static object?[]? GetStatBoostCallArgs(IPokemon target)
|
||||||
|
{
|
||||||
|
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
|
||||||
|
return call?.GetArguments();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Acid now has a 10% chance of lowering the target's Special Defense stat by one stage instead."
|
||||||
|
/// (Generation IV onwards.) When the secondary effect procs, the target's Special Defense is lowered by one stage.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_TargetIsOpponent_LowersSpecialDefenseByOneStage()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = CreateInitializedScript();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
move.User.Returns(user);
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var args = GetStatBoostCallArgs(target);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.SpecialDefense);
|
||||||
|
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia (Generation IV onwards): "lowering the target's Special Defense stat by one stage instead."
|
||||||
|
/// The word "instead" marks the change from the Gen I/II Defense drop — the script must not touch
|
||||||
|
/// the target's (physical) Defense stat.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_TargetIsOpponent_DoesNotLowerDefense()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = CreateInitializedScript();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
move.User.Returns(user);
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert - exactly one stat change, and it is not Defense
|
||||||
|
var calls = target.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").ToList();
|
||||||
|
await Assert.That(calls.Count).IsEqualTo(1);
|
||||||
|
await Assert.That((Statistic)calls[0].GetArguments()[0]!).IsNotEqualTo(Statistic.Defense);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "lowering the target's Special Defense stat".
|
||||||
|
/// The stat drop is inflicted on an opponent, so it must not be marked as self-inflicted
|
||||||
|
/// (self-inflicted drops bypass effects that prevent stat lowering by opponents, e.g. Clear Body/Mist).
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_TargetIsOpponent_NotSelfInflicted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = CreateInitializedScript();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
move.User.Returns(user);
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var args = GetStatBoostCallArgs(target);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
await Assert.That((bool)args![2]!).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "a 10% chance of lowering the target's Special Defense stat".
|
||||||
|
/// The drop is an ordinary stat change, not a forced one — it must respect prevention effects,
|
||||||
|
/// so the script may not pass force = true.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_TargetIsOpponent_StatDropIsNotForced()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = CreateInitializedScript();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
move.User.Returns(user);
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var args = GetStatBoostCallArgs(target);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
await Assert.That((bool)args![3]!).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: the shared ChangeTargetStats base marks the stat change as self-inflicted
|
||||||
|
/// when the target of the secondary effect is the move's own user.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_TargetIsUser_SelfInflicted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = CreateInitializedScript();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
move.User.Returns(user);
|
||||||
|
|
||||||
|
// Act - the user is hit by its own move (e.g. redirected)
|
||||||
|
script.OnSecondaryEffect(move, user, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var args = GetStatBoostCallArgs(user);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
await Assert.That((bool)args![2]!).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: the 'amount' parameter from the move data (amount: -1 for Acid in Moves.jsonc)
|
||||||
|
/// is parsed by OnInitialize and passed through to the stat change unmodified.
|
||||||
|
/// </summary>
|
||||||
|
[Test, Arguments(-1), Arguments(-2)]
|
||||||
|
public async Task OnInitialize_AmountParameter_PassedToStatBoost(int amount)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = CreateInitializedScript(amount);
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
move.User.Returns(user);
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var args = GetStatBoostCallArgs(target);
|
||||||
|
await Assert.That(args).IsNotNull();
|
||||||
|
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: OnInitialize rejects a null parameter dictionary.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnInitialize_NullParameters_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = new ChangeTargetSpecialDefense();
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.That(() => script.OnInitialize(null)).ThrowsExactly<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: OnInitialize rejects parameters without the required 'amount' entry.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnInitialize_MissingAmount_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var script = new ChangeTargetSpecialDefense();
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.That(() => script.OnInitialize(new Dictionary<StringKey, object?>()))
|
||||||
|
.ThrowsExactly<ArgumentException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
243
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DrainTests.cs
Normal file
243
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DrainTests.cs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
using PkmnLib.Dynamic.Models;
|
||||||
|
using PkmnLib.Dynamic.ScriptHandling;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Abilities;
|
||||||
|
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||||
|
using PkmnLib.Static.Utils;
|
||||||
|
|
||||||
|
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="Drain"/> script, which implements Absorb (and the other HP-draining moves).
|
||||||
|
/// Behavior is verified against the Bulbapedia page for Absorb.
|
||||||
|
/// </summary>
|
||||||
|
public class DrainTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a fully mocked test setup for Drain tests.
|
||||||
|
/// </summary>
|
||||||
|
private static (Drain drain, IExecutingMove move, IPokemon target, IPokemon user) CreateTestSetup(uint damage,
|
||||||
|
bool holdsBigRoot = false, Script[]? targetScripts = null)
|
||||||
|
{
|
||||||
|
var drain = new Drain();
|
||||||
|
var move = Substitute.For<IExecutingMove>();
|
||||||
|
var target = Substitute.For<IPokemon>();
|
||||||
|
var hitData = Substitute.For<IHitData>();
|
||||||
|
hitData.Damage.Returns(damage);
|
||||||
|
move.GetHitData(target, 0).Returns(hitData);
|
||||||
|
|
||||||
|
// RunScriptHook iterates the target's scripts; give the mock a real iterator so the
|
||||||
|
// hook pass runs (empty unless the test attaches scripts such as Liquid Ooze).
|
||||||
|
var containers = (targetScripts ?? []).Select(IEnumerable<ScriptContainer> (s) => new ScriptContainer(s))
|
||||||
|
.ToArray();
|
||||||
|
target.GetScripts().Returns(_ => new ScriptIterator(containers));
|
||||||
|
|
||||||
|
var user = Substitute.For<IPokemon>();
|
||||||
|
if (holdsBigRoot)
|
||||||
|
user.HasHeldItem("big_root").Returns(true);
|
||||||
|
move.User.Returns(user);
|
||||||
|
|
||||||
|
return (drain, move, target, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to extract the heal amount from the user's received Heal calls.
|
||||||
|
/// </summary>
|
||||||
|
private static uint? GetHealAmount(IPokemon user)
|
||||||
|
{
|
||||||
|
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
|
||||||
|
return call != null ? (uint)call.GetArguments()[0]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to extract the damage amount from the user's received Damage calls.
|
||||||
|
/// </summary>
|
||||||
|
private static uint? GetDamageAmount(IPokemon user)
|
||||||
|
{
|
||||||
|
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||||
|
return call != null ? (uint)call.GetArguments()[0]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to extract the damage source from the user's received Damage calls.
|
||||||
|
/// </summary>
|
||||||
|
private static DamageSource? GetDamageSource(IPokemon user)
|
||||||
|
{
|
||||||
|
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||||
|
return call != null ? (DamageSource)call.GetArguments()[1]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "Absorb inflicts damage, and up to 50% of the damage dealt to the target is restored to the
|
||||||
|
/// user as HP".
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_DamageDealt_UserHealsHalfOfDamage()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (drain, move, target, user) = CreateTestSetup(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(GetHealAmount(user)).IsEqualTo(50u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP".
|
||||||
|
/// Tests various damage values to ensure proper integer truncation of the halved amount.
|
||||||
|
/// </summary>
|
||||||
|
[Test, Arguments(200u, 100u), Arguments(51u, 25u), Arguments(99u, 49u), Arguments(3u, 1u)]
|
||||||
|
public async Task OnSecondaryEffect_DamageDealt_HealCalculation(uint damage, uint expectedHeal)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (drain, move, target, user) = CreateTestSetup(damage);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(GetHealAmount(user)).IsEqualTo(expectedHeal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP (but not less
|
||||||
|
/// than 1 HP)".
|
||||||
|
/// A move dealing 1 damage should still restore 1 HP.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_DamageOfOne_HealsAtLeastOneHp()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (drain, move, target, user) = CreateTestSetup(1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(GetHealAmount(user)).IsEqualTo(1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "If the user is holding a Big Root, the HP restored is increased by 30% (making the restored
|
||||||
|
/// HP 65% of the damage dealt)."
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_UserHoldingBigRoot_HealIncreasedByThirtyPercent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (drain, move, target, user) = CreateTestSetup(100, true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert - 65% of 100 damage
|
||||||
|
await Assert.That(GetHealAmount(user)).IsEqualTo(65u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "When used on a Pokémon with the Liquid Ooze Ability, the user will lose the amount of HP it
|
||||||
|
/// would have gained instead."
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_TargetHasLiquidOoze_UserLosesHpInsteadOfHealing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (drain, move, target, user) = CreateTestSetup(100, targetScripts: [new LiquidOoze()]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert - user takes the 50 HP it would have gained, and is not healed
|
||||||
|
await Assert.That(GetDamageAmount(user)).IsEqualTo(50u);
|
||||||
|
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "the user will lose the amount of HP it would have gained instead."
|
||||||
|
/// The HP loss caused by Liquid Ooze is indirect damage, not move damage.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_TargetHasLiquidOoze_UsesMiscDamageSource()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (drain, move, target, user) = CreateTestSetup(100, targetScripts: [new LiquidOoze()]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(GetDamageSource(user)).IsEqualTo(DamageSource.Misc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "the user will lose the amount of HP it would have gained instead."
|
||||||
|
/// With a Big Root held, the amount it would have gained is 65% of the damage, so that is what is lost.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_LiquidOozeAndBigRoot_UserLosesBoostedAmount()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (drain, move, target, user) = CreateTestSetup(100, true, [new LiquidOoze()]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(GetDamageAmount(user)).IsEqualTo(65u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: without initialization parameters the drain modifier defaults to 50%, matching Absorb.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnInitialize_NullParameters_DrainModifierDefaultsToHalf()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var drain = new Drain();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnInitialize(null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(drain.DrainModifier).IsEqualTo(0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Technical test: a "drain_modifier" parameter overrides the default drain fraction.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnInitialize_DrainModifierParameter_OverridesDefault()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var drain = new Drain();
|
||||||
|
var parameters = new Dictionary<StringKey, object?> { { "drain_modifier", 0.75f } };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnInitialize(parameters);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(drain.DrainModifier).IsEqualTo(0.75f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP".
|
||||||
|
/// Integration check: initializing the script with Absorb's actual effect parameters from the Gen7 data
|
||||||
|
/// results in a 50% drain.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task OnSecondaryEffect_InitializedWithAbsorbData_HealsHalfOfDamage()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var library = LibraryHelpers.LoadLibrary();
|
||||||
|
await Assert.That(library.StaticLibrary.Moves.TryGet("absorb", out var absorb)).IsTrue();
|
||||||
|
var (drain, move, target, user) = CreateTestSetup(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
drain.OnInitialize(absorb!.SecondaryEffect!.Parameters);
|
||||||
|
drain.OnSecondaryEffect(move, target, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await Assert.That(GetHealAmount(user)).IsEqualTo(50u);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace PkmnLib.Plugin.Gen7.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a test as known to be failing, so that it will be skipped when running all tests, while still allowing us
|
||||||
|
/// to know that we should still fix it.
|
||||||
|
/// </summary>
|
||||||
|
public class TestFailingAttribute(string failureReason) : SkipAttribute("Test is currently failing: " + failureReason)
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -14,15 +14,16 @@ public class Acupressure : Script, IScriptOnSecondaryEffect
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
|
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
|
||||||
{
|
{
|
||||||
|
var possibleStats = target.StatBoost.Where(x => x.statistic != Statistic.Hp && x.value < 6).ToArray();
|
||||||
// If the target has no stats to raise, the move fails
|
// If the target has no stats to raise, the move fails
|
||||||
if (target.StatBoost.All(s => s.value == 6))
|
if (!possibleStats.Any())
|
||||||
{
|
{
|
||||||
move.GetHitData(target, hit).Fail();
|
move.GetHitData(target, hit).Fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose a random stat to raise. 0 is HP, so we start at 1.
|
var index = move.User.BattleData!.Battle.Random.GetInt(0, possibleStats.Length);
|
||||||
var stat = (Statistic)move.User.BattleData!.Battle.Random.GetInt(1, (int)Statistic.Speed + 1);
|
var stat = possibleStats[index].statistic;
|
||||||
target.ChangeStatBoost(stat, 2, false, false);
|
target.ChangeStatBoost(stat, 2, false, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,11 +23,10 @@ public class Autotomize : Script, IScriptOnSecondaryEffect
|
|||||||
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
|
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
|
||||||
{
|
{
|
||||||
var user = move.User;
|
var user = move.User;
|
||||||
|
if (!user.ChangeStatBoost(Statistic.Speed, 2, true, false))
|
||||||
|
return;
|
||||||
|
|
||||||
var existingEffect = user.Volatile.Get<AutotomizeEffect>();
|
if (!(user.WeightInKg > 0.1f))
|
||||||
var stacks = existingEffect?.Stacks ?? 0;
|
|
||||||
|
|
||||||
if (!user.ChangeStatBoost(Statistic.Speed, 2, true, false) || !(user.WeightInKg - 100f * stacks >= 0.1f))
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
user.Volatile.StackOrAdd(ScriptUtils.ResolveName<AutotomizeEffect>(), () => new AutotomizeEffect());
|
user.Volatile.StackOrAdd(ScriptUtils.ResolveName<AutotomizeEffect>(), () => new AutotomizeEffect());
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ public class Drain : Script, IScriptOnInitialize, IScriptOnSecondaryEffect
|
|||||||
target.RunScriptHook<IScriptCustomTrigger>(x => x.CustomTrigger(CustomTriggers.ModifyDrain, args));
|
target.RunScriptHook<IScriptCustomTrigger>(x => x.CustomTrigger(CustomTriggers.ModifyDrain, args));
|
||||||
invert = args.Invert;
|
invert = args.Invert;
|
||||||
healed = args.Healed;
|
healed = args.Healed;
|
||||||
|
if (healed == 0)
|
||||||
|
healed = 1;
|
||||||
|
|
||||||
if (invert)
|
if (invert)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user