diff --git a/PkmnLib.Dynamic/Models/LearnedMove.cs b/PkmnLib.Dynamic/Models/LearnedMove.cs
index 490d323..532ceda 100644
--- a/PkmnLib.Dynamic/Models/LearnedMove.cs
+++ b/PkmnLib.Dynamic/Models/LearnedMove.cs
@@ -42,6 +42,11 @@ public enum MoveLearnMethod
/// The move is learned by using a move sketch.
///
Sketch,
+
+ ///
+ /// The move is temporarily copied through Mimic.
+ ///
+ Mimic,
}
///
diff --git a/PkmnLib.Dynamic/Models/Pokemon.cs b/PkmnLib.Dynamic/Models/Pokemon.cs
index cbc26e7..a24735b 100644
--- a/PkmnLib.Dynamic/Models/Pokemon.cs
+++ b/PkmnLib.Dynamic/Models/Pokemon.cs
@@ -167,10 +167,18 @@ public interface IPokemon : IScriptSource, IDeepCloneable
///
/// The moves the Pokemon has learned. This is of a set length of . Empty move slots
- /// are null.
+ /// are null. If a move slot is temporarily replaced (see ), this shows the
+ /// replacement move.
///
IReadOnlyList Moves { get; }
+ ///
+ /// The permanently learned moves of the Pokemon, ignoring any temporary replacements made through
+ /// . This is of a set length of . Empty move
+ /// slots are null.
+ ///
+ IReadOnlyList BaseMoves { get; }
+
///
/// Checks whether the Pokemon has a specific move in its current moveset.
///
@@ -181,6 +189,14 @@ public interface IPokemon : IScriptSource, IDeepCloneable
///
void SwapMoves(byte index1, byte index2);
+ ///
+ /// Temporarily replaces the move in the given slot until the Pokemon leaves the battlefield. The permanently
+ /// learned move in the slot is left untouched and becomes visible again automatically. Used by effects such as
+ /// Mimic.
+ ///
+ /// Thrown when the move is not found in the move library.
+ void LearnTemporaryMove(StringKey moveName, MoveLearnMethod method, byte index);
+
///
/// Whether or not the Pokemon is allowed to gain experience.
///
@@ -772,8 +788,19 @@ public class PokemonImpl : ScriptSource, IPokemon
private readonly ILearnedMove?[] _learnedMoves = new ILearnedMove[Const.MovesCount];
+ ///
+ /// Battle-only per-slot overrides of . The permanent moveset is never mutated by
+ /// temporary moves; discarding this array is all that is needed to restore the original moves.
+ ///
+ private ILearnedMove?[]? _temporaryMoves;
+
///
- public IReadOnlyList Moves => _learnedMoves;
+ public IReadOnlyList Moves => _temporaryMoves == null
+ ? _learnedMoves
+ : _temporaryMoves.Select((move, index) => move ?? _learnedMoves[index]).ToArray();
+
+ ///
+ public IReadOnlyList BaseMoves => _learnedMoves;
///
public bool HasMove(StringKey moveName) => Moves.Any(move => move?.MoveData.Name == moveName);
@@ -783,10 +810,9 @@ public class PokemonImpl : ScriptSource, IPokemon
{
if (index1 >= Const.MovesCount || index2 >= Const.MovesCount)
return;
- var move1 = _learnedMoves[index1];
- var move2 = _learnedMoves[index2];
- _learnedMoves[index1] = move2;
- _learnedMoves[index2] = move1;
+ (_learnedMoves[index1], _learnedMoves[index2]) = (_learnedMoves[index2], _learnedMoves[index1]);
+ if (_temporaryMoves != null)
+ (_temporaryMoves[index1], _temporaryMoves[index2]) = (_temporaryMoves[index2], _temporaryMoves[index1]);
}
///
@@ -1286,6 +1312,18 @@ public class PokemonImpl : ScriptSource, IPokemon
_learnedMoves[index] = new LearnedMoveImpl(move, method);
}
+ ///
+ public void LearnTemporaryMove(StringKey moveName, MoveLearnMethod method, byte index)
+ {
+ if (index >= Const.MovesCount)
+ throw new ArgumentOutOfRangeException(nameof(index), $"Move slot {index} is out of range.");
+ if (!Library.StaticLibrary.Moves.TryGet(moveName, out var move))
+ throw new KeyNotFoundException($"Move {moveName} not found.");
+
+ _temporaryMoves ??= new ILearnedMove?[Const.MovesCount];
+ _temporaryMoves[index] = new LearnedMoveImpl(move, method);
+ }
+
///
public bool HasStatus(StringKey status) => StatusScript.Script?.Name == status;
@@ -1367,6 +1405,7 @@ public class PokemonImpl : ScriptSource, IPokemon
if (!onBattleField)
{
Volatile.Clear();
+ _temporaryMoves = null;
HeightInMeters = Form.Height;
Types = Form.Types;
OverrideAbility = null;
@@ -1388,6 +1427,7 @@ public class PokemonImpl : ScriptSource, IPokemon
var battleData = BattleData;
BattleData = null;
Volatile.Clear();
+ _temporaryMoves = null;
HeightInMeters = Form.Height;
Types = Form.Types;
OverrideAbility = null;
diff --git a/PkmnLib.Dynamic/Models/Serialized/SerializedPokemon.cs b/PkmnLib.Dynamic/Models/Serialized/SerializedPokemon.cs
index 5743e6d..8bcaef7 100644
--- a/PkmnLib.Dynamic/Models/Serialized/SerializedPokemon.cs
+++ b/PkmnLib.Dynamic/Models/Serialized/SerializedPokemon.cs
@@ -33,7 +33,7 @@ public record SerializedPokemon
Nature = pokemon.Nature.Name;
Nickname = pokemon.Nickname;
Ability = pokemon.Form.GetAbility(pokemon.AbilityIndex);
- Moves = pokemon.Moves.Select(move =>
+ Moves = pokemon.BaseMoves.Select(move =>
{
if (move == null)
return null;
diff --git a/PkmnLib.Tests/Dynamic/PokemonTemporaryMoveTests.cs b/PkmnLib.Tests/Dynamic/PokemonTemporaryMoveTests.cs
new file mode 100644
index 0000000..3deec7c
--- /dev/null
+++ b/PkmnLib.Tests/Dynamic/PokemonTemporaryMoveTests.cs
@@ -0,0 +1,82 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Static;
+using PkmnLib.Static.Species;
+using PkmnLib.Tests.Integration;
+
+namespace PkmnLib.Tests.Dynamic;
+
+///
+/// Tests for the temporary move overlay (), used by effects such as
+/// Mimic. The permanently learned moves must never be mutated by a temporary move, and the overlay must be
+/// discarded by the engine itself when the Pokemon leaves the battlefield.
+///
+public class PokemonTemporaryMoveTests
+{
+ private static IPokemon CreatePokemon()
+ {
+ var library = LibraryHelpers.LoadLibrary();
+ if (!library.StaticLibrary.Species.TryGet("bulbasaur", out var species))
+ throw new InvalidOperationException("Failed to load bulbasaur species.");
+ var pokemon = new PokemonImpl(library, species, species.GetDefaultForm(), new AbilityIndex
+ {
+ Index = 0,
+ IsHidden = false,
+ }, 50, 0, Gender.Male, 0, "hardy");
+ pokemon.LearnMove("tackle", MoveLearnMethod.LevelUp, 0);
+ return pokemon;
+ }
+
+ [Test]
+ public async Task LearnTemporaryMove_ReplacesMoveInMovesButNotInBaseMoves()
+ {
+ var pokemon = CreatePokemon();
+
+ pokemon.LearnTemporaryMove("swords_dance", MoveLearnMethod.Mimic, 0);
+
+ await Assert.That(pokemon.Moves[0]!.MoveData.Name.ToString()).IsEqualTo("swords_dance");
+ await Assert.That(pokemon.Moves[0]!.LearnMethod).IsEqualTo(MoveLearnMethod.Mimic);
+ await Assert.That(pokemon.BaseMoves[0]!.MoveData.Name.ToString()).IsEqualTo("tackle");
+ }
+
+ [Test]
+ public async Task SetOnBattlefield_LeavingField_RestoresOriginalMoveWithItsPP()
+ {
+ var pokemon = CreatePokemon();
+ pokemon.SetBattleData(Substitute.For(), 0);
+ pokemon.SetOnBattlefield(true);
+
+ // Use the original move once, so we can verify its PP survives the temporary replacement untouched.
+ var originalMove = pokemon.Moves[0]!;
+ originalMove.TryUse();
+ var expectedPp = originalMove.CurrentPp;
+
+ pokemon.LearnTemporaryMove("swords_dance", MoveLearnMethod.Mimic, 0);
+ pokemon.SetOnBattlefield(false);
+
+ await Assert.That(ReferenceEquals(pokemon.Moves[0], originalMove)).IsTrue();
+ await Assert.That(pokemon.Moves[0]!.CurrentPp).IsEqualTo(expectedPp);
+ }
+
+ [Test]
+ public async Task ClearBattleData_RestoresOriginalMove()
+ {
+ var pokemon = CreatePokemon();
+ pokemon.SetBattleData(Substitute.For(), 0);
+ pokemon.LearnTemporaryMove("swords_dance", MoveLearnMethod.Mimic, 0);
+
+ pokemon.ClearBattleData();
+
+ await Assert.That(pokemon.Moves[0]!.MoveData.Name.ToString()).IsEqualTo("tackle");
+ }
+
+ [Test]
+ public async Task Serialize_WithActiveTemporaryMove_WritesOriginalMove()
+ {
+ var pokemon = CreatePokemon();
+ pokemon.LearnTemporaryMove("swords_dance", MoveLearnMethod.Mimic, 0);
+
+ var serialized = pokemon.Serialize();
+
+ await Assert.That(serialized.Moves[0]!.MoveName.ToString()).IsEqualTo("tackle");
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MimicTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MimicTests.cs
index c0fc2bc..f511182 100644
--- a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MimicTests.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MimicTests.cs
@@ -15,10 +15,6 @@ namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
///
public class MimicTests
{
- private const string NotImplementedReason =
- "Mimic is not implemented: Mimic.cs contains only a FIXME comment and implements no script hooks, so " +
- "the move has no effect (Bulbapedia: 'Mimic copies a move from the target')";
-
///
/// Creates a mocked whose move data has the given name.
///
@@ -46,9 +42,12 @@ public class MimicTests
move.GetHitData(target, 0).Returns(hitData);
// The user knows another move in slot 0, and Mimic in slot 1; Mimic is the chosen move.
+ // The moves are created before the Returns call, as creating substitutes inside Returns arguments
+ // resets NSubstitute's last-call tracking.
var mimicMove = CreateLearnedMove("mimic");
move.ChosenMove.Returns(mimicMove);
- user.Moves.Returns(new[] { CreateLearnedMove(userOtherMove), mimicMove });
+ var userMoves = new[] { CreateLearnedMove(userOtherMove), mimicMove };
+ user.Moves.Returns(userMoves);
var battleData = Substitute.For();
if (targetLastUsedMove != null)
@@ -85,11 +84,11 @@ public class MimicTests
hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail");
///
- /// Helper to extract the arguments of the user's received call.
+ /// Helper to extract the arguments of the user's received call.
///
private static (StringKey moveName, byte index)? GetLearnedMove(IPokemon user)
{
- var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "LearnMove");
+ var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "LearnTemporaryMove");
if (call == null)
return null;
return ((StringKey)call.GetArguments()[0]!, (byte)call.GetArguments()[2]!);
@@ -100,7 +99,7 @@ public class MimicTests
/// place" and (Generation II onwards) "Mimic now copies the target's last used move."
/// The target's last used move must be learned in the move slot that holds Mimic.
///
- [Test, TestFailing(NotImplementedReason)]
+ [Test]
public async Task OnSecondaryEffect_TargetUsedMove_UserLearnsTargetsLastMoveInMimicsSlot()
{
// Arrange
@@ -121,7 +120,7 @@ public class MimicTests
/// Bulbapedia: (Generation II onwards) "Mimic now copies the target's last used move."
/// If the target has not used a move yet, there is nothing to copy and the hit must fail.
///
- [Test, TestFailing(NotImplementedReason)]
+ [Test]
public async Task OnSecondaryEffect_TargetHasNotUsedAMove_FailsHit()
{
// Arrange
@@ -141,8 +140,8 @@ public class MimicTests
/// Metronome, or any move the user already knows.", (Generation IV onwards) "Mimic will fail to copy
/// Chatter.", and (Generation V onwards) "Mimic is no longer able to copy Transform."
///
- [Test, TestFailing(NotImplementedReason), Arguments("sketch"), Arguments("transform"), Arguments("struggle"),
- Arguments("metronome"), Arguments("chatter")]
+ [Test, Arguments("sketch"), Arguments("transform"), Arguments("struggle"), Arguments("metronome"),
+ Arguments("chatter")]
public async Task OnSecondaryEffect_TargetsLastMoveIsUncopyable_FailsHit(string uncopyableMove)
{
// Arrange
@@ -160,7 +159,7 @@ public class MimicTests
///
/// Bulbapedia: (Generation II onwards) "It now fails to copy ... any move the user already knows."
///
- [Test, TestFailing(NotImplementedReason)]
+ [Test]
public async Task OnSecondaryEffect_UserAlreadyKnowsTargetsLastMove_FailsHit()
{
// Arrange
@@ -175,4 +174,22 @@ public class MimicTests
await Assert.That(ReceivedFail(hitData)).IsTrue();
await Assert.That(GetLearnedMove(user).HasValue).IsFalse();
}
+
+ ///
+ /// Bulbapedia: (Generation II onwards) "The copied move will also only have 5 PP".
+ /// After learning the copy, its current PP must be set to 5.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetUsedMove_CopiedMoveGetsFivePP()
+ {
+ // Arrange
+ var mimic = await GetSecondaryEffectHook();
+ var (move, user, target, _) = CreateTestSetup("tackle");
+
+ // Act
+ mimic.OnSecondaryEffect(move, target, 0);
+
+ // Assert - the move in Mimic's slot (slot 1) has its PP set to 5.
+ user.Moves[1]!.Received(1).SetCurrentPP(5);
+ }
}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Mimic.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Mimic.cs
index 761caf9..6596546 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Mimic.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Mimic.cs
@@ -1,7 +1,43 @@
+using PkmnLib.Plugin.Gen7.Scripts.Utils;
+
namespace PkmnLib.Plugin.Gen7.Scripts.Moves;
+///
+/// Mimic copies the target's last used move into the move slot Mimic occupies, with 5 PP, until the user
+/// leaves the field. It fails if the target has not used a move, if that move is Sketch, Transform, Struggle,
+/// Metronome, or Chatter, or if the user already knows it.
+///
+/// Bulbapedia - Mimic
+///
[Script(ScriptCategory.Move, "mimic")]
-public class Mimic : Script
+public class Mimic : Script, IScriptOnSecondaryEffect
{
- // FIXME: support for temporarily copying moves to a move slot.
+ ///
+ public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
+ {
+ var moveSlot = move.User.Moves.IndexOf(move.ChosenMove);
+ if (moveSlot == -1)
+ {
+ move.GetHitData(target, hit).Fail();
+ return;
+ }
+
+ var lastMove = target.BattleData?.LastMoveChoice;
+ if (lastMove == null || !lastMove.ChosenMove.MoveData.CanCopyMove())
+ {
+ move.GetHitData(target, hit).Fail();
+ return;
+ }
+
+ var copiedMoveName = lastMove.ChosenMove.MoveData.Name;
+ if (move.User.Moves.Any(m => m?.MoveData.Name == copiedMoveName))
+ {
+ move.GetHitData(target, hit).Fail();
+ return;
+ }
+
+ move.User.LearnTemporaryMove(copiedMoveName, MoveLearnMethod.Mimic, (byte)moveSlot);
+ // The copied move only has 5 PP, or its own maximum PP if that is lower.
+ move.User.Moves[moveSlot]?.SetCurrentPP(5);
+ }
}
\ No newline at end of file