Deukhoofd d57076374f
All checks were successful
Build / Build (push) Successful in 1m3s
Some initial work on prescient AI, AI runner, and some random fixes
2025-07-05 17:48:51 +02:00

565 lines
20 KiB
C#

using System.Diagnostics.CodeAnalysis;
using PkmnLib.Dynamic.BattleFlow;
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Dynamic.Models;
/// <summary>
/// A battle is a representation of a battle in the Pokemon games. It contains all the information needed
/// to simulate a battle, and can be used to simulate a battle between two parties.
/// </summary>
public interface IBattle : IScriptSource, IDeepCloneable, IDisposable
{
/// <summary>
/// The library the battle uses for handling.
/// </summary>
IDynamicLibrary Library { get; }
/// <summary>
/// A list of all different parties in the battle.
/// </summary>
IReadOnlyList<IBattleParty> Parties { get; }
/// <summary>
/// Whether or not Pokemon can flee from the battle.
/// </summary>
bool CanFlee { get; }
/// <summary>
/// The number of sides in the battle. Typically 2.
/// </summary>
byte NumberOfSides { get; }
/// <summary>
/// The number of Pokemon that can be on each side.
/// </summary>
byte PositionsPerSide { get; }
/// <summary>
/// The name of the environment the battle is taking place in, such as "grass", "cave", etc.
/// This is sometimes referred to as the "terrain" in the games, but is not the same as the
/// <see cref="TerrainName"/> which is a battle condition that can be set by scripts.
/// </summary>
StringKey EnvironmentName { get; }
/// <summary>
/// Whether this battle is a wild battle. In a wild battle, the player can catch the opposing Pokemon,
/// and moves like roar will end the battle instead of switching out the Pokemon.
/// </summary>
bool IsWildBattle { get; }
/// <summary>
/// A list of all sides in the battle.
/// </summary>
IReadOnlyList<IBattleSide> Sides { get; }
/// <summary>
/// The RNG used for the battle.
/// </summary>
IBattleRandom Random { get; }
/// <summary>
/// Whether the battle has ended.
/// </summary>
[MemberNotNull(nameof(Result))]
bool HasEnded { get; }
/// <summary>
/// The result of the battle. If the battle has not ended, this is null.
/// </summary>
BattleResult? Result { get; }
/// <summary>
/// The handler to send all events to.
/// </summary>
EventHook EventHook { get; }
/// <summary>
/// The index of the current turn. Initially 0, until the first turn starts when all choices are made.
/// </summary>
uint CurrentTurnNumber { get; }
/// <summary>
/// A queue of the yet to be executed choices in a turn.
/// </summary>
BattleChoiceQueue? ChoiceQueue { get; }
/// <summary>
/// Get a Pokemon on the battlefield, on a specific side and an index on that side.
/// </summary>
IPokemon? GetPokemon(byte side, byte position);
/// <summary>
/// Returns whether a slot on the battlefield can still be filled. If no party is responsible
/// for that slot, or a party is responsible, but has no remaining Pokemon to throw out anymore,
/// this returns false.
/// </summary>
bool CanSlotBeFilled(byte side, byte position);
/// <summary>
/// Validates whether the battle is still in a non-ended state. If the battle has ended, this
/// properly sets who has won etc.
/// </summary>
void ValidateBattleState();
/// <summary>
/// Forcefully ends the battle. This will set the result to inconclusive and set HasEnded to true.
/// </summary>
void ForceEndBattle();
/// <summary>
/// Checks whether a Pokemon has a forced turn choice. If it does, this returns true and the choice
/// is set in the out parameter. If it does not, this returns false and the out parameter is null.
/// </summary>
bool HasForcedTurn(IPokemon pokemon, [NotNullWhen(true)] out ITurnChoice? choice);
/// <summary>
/// Checks whether a choice is actually possible.
/// </summary>
bool CanUse(ITurnChoice choice);
/// <summary>
/// Try and set the choice for the battle. If the choice is not valid, this returns false.
/// </summary>
bool TrySetChoice(ITurnChoice choice);
/// <summary>
/// The script that handles the current weather of the battle.
/// </summary>
IReadOnlyScriptContainer WeatherScript { get; }
/// <summary>
/// Sets the current weather for the battle. If null is passed, this clears the weather.
/// A duration can be passed to set the duration of the weather in turns. This duration can be modified by
/// other scripts before the weather is set through the <see cref="Script.ChangeWeatherDuration"/> script hook.
/// </summary>
bool SetWeather(StringKey? weatherName, int duration, EventBatchId batchId = default);
/// <summary>
/// Volatile scripts are scripts that are not permanent and can be removed by other scripts.
/// </summary>
public IScriptSet Volatile { get; }
/// <summary>
/// Gets the current weather of the battle. If no weather is present, this returns null.
/// </summary>
StringKey? WeatherName { get; }
/// <summary>
/// Sets the current terrain for the battle. If null is passed, this clears the terrain.
/// </summary>
void SetTerrain(StringKey? terrainName, EventBatchId batchId = default);
/// <summary>
/// Gets the current terrain of the battle. If no terrain is present, this returns null.
/// </summary>
StringKey? TerrainName { get; }
/// <summary>
/// Gets the turn choices of the previous turn. This is a list of lists, where each list represents the choices
/// for a single turn. The outer list is ordered from oldest to newest turn.
/// </summary>
IReadOnlyList<IReadOnlyList<ITurnChoice>> PreviousTurnChoices { get; }
/// <summary>
/// Attempts to capture a Pokemon. This will use the current RNG to determine whether the capture is successful.
/// </summary>
CaptureResult AttempCapture(byte sideIndex, byte position, IItem item);
}
/// <inheritdoc cref="IBattle"/>
public class BattleImpl : ScriptSource, IBattle
{
/// <inheritdoc cref="BattleImpl"/>
/// <param name="library">The library the battle uses for data and dynamic handling.</param>
/// <param name="parties">The parties that will be in the battle.</param>
/// <param name="canFlee">Whether Pokémon are allowed to flee from the battle.</param>
/// <param name="numberOfSides">The number of sides in the battle. Generally 2.</param>
/// <param name="positionsPerSide">The number of spots there are on each side for Pokémon. 1 for singles, 2 for doubles, etc.</param>
/// <param name="isWildBattle">Whether this battle is a wild battle. In a wild battle, the player can catch the opposing Pokémon,
/// and moves like roar will end the battle instead of switching out the Pokémon.</param>
/// <param name="environmentName">The name of the environment the battle is taking place in, such as "grass", "cave", etc.</param>
/// <param name="randomSeed">The seed for the RNG. If null, this uses a time-dependent seed.</param>
public BattleImpl(IDynamicLibrary library, IReadOnlyList<IBattleParty> parties, bool canFlee, byte numberOfSides,
byte positionsPerSide, bool isWildBattle, StringKey environmentName, int? randomSeed = null)
{
Library = library;
Parties = parties;
CanFlee = canFlee;
NumberOfSides = numberOfSides;
PositionsPerSide = positionsPerSide;
IsWildBattle = isWildBattle;
EnvironmentName = environmentName;
Volatile = new ScriptSet(this);
var sides = new IBattleSide[numberOfSides];
for (byte i = 0; i < numberOfSides; i++)
sides[i] = new BattleSideImpl(i, positionsPerSide, this);
Sides = sides;
Random = randomSeed.HasValue ? new BattleRandomImpl(randomSeed.Value) : new BattleRandomImpl();
EventHook = new EventHook();
}
/// <inheritdoc />
public IDynamicLibrary Library { get; }
/// <inheritdoc />
public IReadOnlyList<IBattleParty> Parties { get; }
/// <inheritdoc />
public bool CanFlee { get; }
/// <inheritdoc />
public byte NumberOfSides { get; }
/// <inheritdoc />
public byte PositionsPerSide { get; }
/// <inheritdoc />
public StringKey EnvironmentName { get; }
/// <inheritdoc />
public bool IsWildBattle { get; }
/// <inheritdoc />
public IReadOnlyList<IBattleSide> Sides { get; }
/// <inheritdoc />
public IBattleRandom Random { get; }
/// <inheritdoc />
[MemberNotNull(nameof(Result))]
public bool HasEnded { get; private set; }
/// <inheritdoc />
public BattleResult? Result { get; private set; }
/// <inheritdoc />
public EventHook EventHook { get; }
/// <inheritdoc />
public uint CurrentTurnNumber { get; private set; }
/// <inheritdoc />
public BattleChoiceQueue? ChoiceQueue { get; private set; }
/// <inheritdoc />
public IPokemon? GetPokemon(byte side, byte position) => Sides[side].Pokemon[position];
/// <inheritdoc />
public bool CanSlotBeFilled(byte side, byte position) => Parties.Any(x =>
x.IsResponsibleForIndex(new ResponsibleIndex(side, position)) && x.HasPokemonNotInField());
/// <inheritdoc />
public void ValidateBattleState()
{
if (HasEnded)
return;
var survivingSideExists = false;
IBattleSide? survivingSide = null;
foreach (var side in Sides)
{
if (side.HasFledBattle)
{
Result = BattleResult.Inconclusive;
HasEnded = true;
return;
}
if (!side.IsDefeated())
{
// If we already found a surviving side, the battle is not over yet
if (survivingSideExists)
return;
survivingSideExists = true;
survivingSide = side;
}
}
// If every side is defeated, the battle is a draw
if (!survivingSideExists)
{
Result = BattleResult.Inconclusive;
HasEnded = true;
return;
}
// If only one side is left, that side has won
Result = BattleResult.Conclusive(survivingSide!.Index);
HasEnded = true;
}
/// <inheritdoc />
public void ForceEndBattle()
{
HasEnded = true;
Result = BattleResult.Inconclusive;
}
/// <inheritdoc />
public bool HasForcedTurn(IPokemon pokemon, [NotNullWhen(true)] out ITurnChoice? choice)
{
var battleData = pokemon.BattleData;
if (battleData == null)
{
choice = null;
return false;
}
ITurnChoice? forcedChoice = null;
pokemon.RunScriptHookInterface<IScriptForceTurnSelection>(script =>
script.ForceTurnSelection(this, battleData.SideIndex, battleData.Position, ref forcedChoice));
choice = forcedChoice;
return choice != null;
}
/// <inheritdoc />
public bool CanUse(ITurnChoice choice)
{
if (!choice.User.IsUsable)
return false;
// Always allow moves such as Struggle. If we block this, we can run into an infinite loop
if (Library.MiscLibrary.IsReplacementChoice(choice))
return true;
if (HasForcedTurn(choice.User, out var forcedChoice) && !IsValidForForcedTurn(forcedChoice, choice))
return false;
if (choice is IMoveChoice moveChoice)
{
if (moveChoice.ChosenMove.CurrentPp < 1)
return false;
if (!TargetResolver.IsValidTarget(moveChoice.TargetSide, moveChoice.TargetPosition,
moveChoice.ChosenMove.MoveData.Target, moveChoice.User))
return false;
var preventMove = false;
choice.RunScriptHookInterface<IScriptPreventMoveSelection>(script =>
script.PreventMoveSelection(moveChoice, ref preventMove));
if (preventMove)
return false;
}
return true;
bool IsValidForForcedTurn(ITurnChoice forcedChoice, ITurnChoice choiceToCheck)
{
// If the forced choice is a move choice, we can only use it if the move is the same
if (forcedChoice is IMoveChoice forcedMove && choiceToCheck is IMoveChoice moveChoice)
{
return forcedMove.ChosenMove.MoveData.Name == moveChoice.ChosenMove.MoveData.Name;
}
if (forcedChoice is IPassChoice && choiceToCheck is IPassChoice)
{
return true; // Both are pass choices, so they are valid
}
return forcedChoice.Equals(choiceToCheck);
}
}
/// <inheritdoc />
public bool TrySetChoice(ITurnChoice choice)
{
if (!CanUse(choice))
return false;
if (choice.User.BattleData?.IsOnBattlefield != true)
return false;
var side = Sides[choice.User.BattleData!.SideIndex];
side.SetChoice(choice.User.BattleData!.Position, choice);
CheckChoicesSetAndRun();
return true;
}
private void CheckChoicesSetAndRun()
{
foreach (var side in Sides)
{
if (!side.AllChoicesSet)
return;
if (!side.AllPositionsFilled())
return;
}
var choices = new ITurnChoice[NumberOfSides * PositionsPerSide];
for (var index = 0; index < Sides.Count; index++)
{
var side = Sides[index];
for (byte i = 0; i < PositionsPerSide; i++)
{
var choice = side.SetChoices[i];
if (choice is null)
throw new InvalidOperationException("Choice is null.");
if (choice is IMoveChoice moveChoice)
{
var priority = moveChoice.ChosenMove.MoveData.Priority;
choice.RunScriptHookInterface<IScriptChangePriority>(script =>
script.ChangePriority(moveChoice, ref priority));
moveChoice.Priority = priority;
}
var speed = choice.User.BoostedStats.Speed;
choice.RunScriptHookInterface<IScriptChangeSpeed>(script => script.ChangeSpeed(choice, ref speed));
choice.Speed = speed;
choice.RandomValue = (uint)Random.GetInt();
choices[index * PositionsPerSide + i] = choice;
choices[index * PositionsPerSide + i] = choice;
}
side.ResetChoices();
}
_previousTurnChoices.Add(choices.ToList());
CurrentTurnNumber += 1;
ChoiceQueue = new BattleChoiceQueue(choices);
this.RunTurn();
ChoiceQueue = null;
EventHook.Invoke(new EndTurnEvent());
}
private readonly ScriptContainer _weatherScript = new();
/// <summary>
/// The script that handles the current weather of the battle.
/// </summary>
public IReadOnlyScriptContainer WeatherScript => _weatherScript;
/// <inheritdoc />
public bool SetWeather(StringKey? weatherName, int duration, EventBatchId batchId = default)
{
var preventWeatherChange = false;
this.RunScriptHook(x => x.PreventWeatherChange(weatherName, ref preventWeatherChange));
if (preventWeatherChange)
return false;
var oldWeatherName = WeatherScript.Script?.Name;
if (weatherName.HasValue)
{
if (weatherName == oldWeatherName)
{
// Extend duration of existing weather
if (_weatherScript.Script is ILimitedTurnsScript existingWeatherScript)
{
this.RunScriptHook(x => x.ChangeWeatherDuration(weatherName.Value, ref duration));
if (duration < existingWeatherScript.TurnsRemaining)
return true;
existingWeatherScript.SetTurns(duration);
}
return true;
}
if (!Library.ScriptResolver.TryResolve(ScriptCategory.Weather, weatherName.Value, null, out var script))
throw new InvalidOperationException($"Weather script {weatherName} not found.");
if (script is ILimitedTurnsScript weatherScript)
{
this.RunScriptHook(x => x.ChangeWeatherDuration(weatherName.Value, ref duration));
weatherScript.SetTurns(duration);
}
_weatherScript.Set(script);
script.OnAddedToParent(this);
}
else
{
_weatherScript.Clear();
}
EventHook.Invoke(new WeatherChangeEvent(oldWeatherName, weatherName)
{
BatchId = batchId,
});
Sides.SelectMany(x => x.Pokemon).WhereNotNull()
.RunScriptHook(x => x.OnWeatherChange(this, weatherName, oldWeatherName));
return true;
}
/// <inheritdoc />
public IScriptSet Volatile { get; }
/// <inheritdoc />
public StringKey? WeatherName => WeatherScript.Script?.Name;
private readonly ScriptContainer _terrainScript = new();
/// <inheritdoc />
public void SetTerrain(StringKey? terrainName, EventBatchId batchId = default)
{
var oldTerrainName = TerrainName;
if (terrainName.HasValue)
{
if (!Library.ScriptResolver.TryResolve(ScriptCategory.Terrain, terrainName.Value, null, out var script))
throw new InvalidOperationException($"Terrain script {terrainName} not found.");
_terrainScript.Set(script);
script.OnAddedToParent(this);
}
else
{
_terrainScript.Clear();
}
EventHook.Invoke(new TerrainChangeEvent(oldTerrainName, terrainName)
{
BatchId = batchId,
});
}
/// <inheritdoc />
public StringKey? TerrainName => _terrainScript.Script?.Name;
private readonly List<IReadOnlyList<ITurnChoice>> _previousTurnChoices = new();
/// <inheritdoc />
public IReadOnlyList<IReadOnlyList<ITurnChoice>> PreviousTurnChoices => _previousTurnChoices;
/// <inheritdoc />
public CaptureResult AttempCapture(byte sideIndex, byte position, IItem item)
{
var target = GetPokemon(sideIndex, position);
if (target is not { IsUsable: true })
return CaptureResult.Failed;
var attemptCapture = Library.CaptureLibrary.TryCapture(target, item, Random);
if (attemptCapture.IsCaught)
{
target.MarkAsCaught();
var side = Sides[target.BattleData!.SideIndex];
side.ForceClearPokemonFromField(target.BattleData.Position);
}
EventHook.Invoke(new CaptureAttemptEvent(target, attemptCapture));
return attemptCapture;
}
/// <inheritdoc />
public override int ScriptCount => 3;
/// <inheritdoc />
public override void GetOwnScripts(List<IEnumerable<ScriptContainer>> scripts)
{
scripts.Add(WeatherScript);
scripts.Add(_terrainScript);
scripts.Add(Volatile);
}
/// <inheritdoc />
public override void CollectScripts(List<IEnumerable<ScriptContainer>> scripts) => GetOwnScripts(scripts);
/// <inheritdoc />
public void Dispose()
{
foreach (var party in Parties)
{
foreach (var pokemon in party.Party.WhereNotNull())
{
pokemon.ClearBattleData();
}
}
_weatherScript.Clear();
_terrainScript.Clear();
Volatile.Clear();
}
}