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