Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ai): Improve scripting API #348

Merged
merged 1 commit into from
May 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ RATE_XP=1.0
RATE_DROP=1.0

ADMIN_COMMAND_SCRIPT_PATH=scripts/commands
AI_SCRIPT_PATH=scripts/ai
17 changes: 17 additions & 0 deletions config.ini.dist
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,23 @@ fight.rate.drop = ${DROP_RATE:-1.0}
; > Default value : 10
;fight.initialErosion = 10

; AI scripts
; ----------
; > Does scripts are enabled for AI ?
; > Default value : true
;fight.ai.scripts.enable = true
; > Get the path where AI scripts are stored
; > Default value : "scripts/ai"
fight.ai.scripts.path = "${AI_SCRIPT_PATH:-scripts/ai}"
; > Enable hot reloading of AI scripts
; > When enabled, modifications of scripts will be taken into account without restarting the server
; > when a new fight is started.
; > It's advised to use this feature only in development environment, as it may have a performance impact.
; > Note that it's not necessary to enable this feature to allow loading of new scripts,
; > because when a new AI is requested, all scripts are reloaded.
; > Default value : false
;fight.ai.scripts.hot-reload = false

; Auto save
; ---------
; > The interval between two automatic save of connected players
Expand Down
48 changes: 29 additions & 19 deletions scripts/ai/Random.groovy
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fr.quatrevieux.araknemu.game.fight.ai.action.builder.GeneratorBuilder
import fr.quatrevieux.araknemu.game.fight.ai.action.util.CastSpell
import fr.quatrevieux.araknemu.game.fight.ai.action.util.Movement
import fr.quatrevieux.araknemu.game.fight.ai.factory.AbstractAiBuilderFactory
import fr.quatrevieux.araknemu.game.fight.ai.factory.scripting.AbstractScriptingAiBuilderFactory
import fr.quatrevieux.araknemu.game.fight.ai.simulation.CastSimulation
import fr.quatrevieux.araknemu.game.fight.ai.simulation.Simulator

Expand All @@ -27,12 +26,12 @@ import fr.quatrevieux.araknemu.game.fight.ai.simulation.Simulator
/**
* Example of a random AI
*
* To create a new AI type you have to extends AbstractAiBuilderFactory and override one of the configure method
* To create a new AI type you have to extends AbstractScriptingAiBuilderFactory and override one of the configure method
* The AI name will be the class name in upper case. If you want to change the name, you can override the name() method
*
* Note: be aware of the maximum length of the name. By default the maximum length is 12 characters.
*/
class Random extends AbstractAiBuilderFactory {
class Random extends AbstractScriptingAiBuilderFactory {
final Simulator simulator
final java.util.Random random

Expand All @@ -43,27 +42,38 @@ class Random extends AbstractAiBuilderFactory {
this.random = new java.util.Random()
}


// Override this method to configure the AI
// Note: you can also override configure(GeneratorBuilder builder, PlayableFighter fighter) instead of this one to have access to the fighter
// Note: you can also override configure(PlayableFighter fighter) instead of this one to have access to the fighter
@Override
protected void configure(GeneratorBuilder builder) {
protected void configure() {
// Now you can add actions to the AI pipeline
// The pipeline defines the priority of the actions
// If the first action can be executed, it will be executed until it can't
// And then the second action will be executed, and so on
builder
.add(new Movement({ Math.random() }, { true }))
.add(new CastSpell(simulator, new CastSpell.SimulationSelector() {
@Override
boolean valid(CastSimulation simulation) {
random.nextBoolean()
}
add(new CastSpell(simulator, new CastSpell.SimulationSelector() {
@Override
boolean valid(CastSimulation simulation) {
random.nextBoolean()
}

@Override
double score(CastSimulation simulation) {
Math.random()
}
}))

// You can also add conditions to the pipeline
// The first parameter is a predicate that takes the AI as parameter and returns a boolean
// The second parameter will be used to build the AI if the predicate returns true
whether({ random.nextBoolean() }) {
moveNearAllies()

// Call "otherwise" to add an action that will be executed if the condition is false
otherwise {
moveFarEnemies()
}
}

@Override
double score(CastSimulation simulation) {
Math.random()
}
}))
add(new Movement({ Math.random() }, { true }))
}
}
2 changes: 1 addition & 1 deletion src/main/java/fr/quatrevieux/araknemu/game/GameModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@
import fr.quatrevieux.araknemu.game.fight.ai.factory.ChainAiFactory;
import fr.quatrevieux.araknemu.game.fight.ai.factory.DoubleAiFactory;
import fr.quatrevieux.araknemu.game.fight.ai.factory.ListAiFactoryLoader;
import fr.quatrevieux.araknemu.game.fight.ai.factory.ScriptingAiLoader;
import fr.quatrevieux.araknemu.game.fight.ai.factory.scripting.ScriptingAiLoader;
import fr.quatrevieux.araknemu.game.fight.ai.factory.type.Aggressive;
import fr.quatrevieux.araknemu.game.fight.ai.factory.type.Blocking;
import fr.quatrevieux.araknemu.game.fight.ai.factory.type.Fixed;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/*
* This file is part of Araknemu.
*
* Araknemu is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Araknemu is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Araknemu. If not, see <https://www.gnu.org/licenses/>.
*
* Copyright (c) 2017-2024 Vincent Quatrevieux
*/

package fr.quatrevieux.araknemu.game.fight.ai.factory.scripting;

import fr.quatrevieux.araknemu.game.fight.ai.AI;
import fr.quatrevieux.araknemu.game.fight.ai.FighterAI;
import fr.quatrevieux.araknemu.game.fight.ai.action.builder.ConditionalBuilder;
import fr.quatrevieux.araknemu.game.fight.ai.action.builder.GeneratorBuilder;
import fr.quatrevieux.araknemu.game.fight.ai.factory.NamedAiFactory;
import fr.quatrevieux.araknemu.game.fight.fighter.PlayableFighter;
import groovy.lang.Closure;
import groovy.lang.DelegatesTo;

import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;

/**
* Base class to use for create AI on groovy scripts
*
* It allows to call generator builder methods directly on the factory, by overriding the configure() method,
* and also simplify the creation of conditional actions.
*
* One of the configure() or configure(PlayableFighter fighter) method must be implemented.
* By default, the AI name will be the class name in uppercase, without package.
*
* Example:
* <pre>{@code
* class MyAI extends AbstractScriptingAiBuilderFactory {
* Simulator simulator
*
* // Inject dependencies on the constructor (any services can be injected)
* MyAI(Simulator simulator) {
* this.simulator = simulator
* }
*
* // Configure the AI
* @Override
* protected void configure() {
* attackFromBestCell() // You can call generator builder methods directly
* add(new MyCustomAction()) // Use add() to add custom actions
*
* add { ai, actions -> // Add can be used with a closure (takes as argument the AI and the actions factory)
* // ...
* return Optional.of(actions.cast().create(ai.fighter(), spell, target))
* }
*
* whether({ fighter().life().current() < 50 }) { // Use whether() to create conditional actions
* heal() // Actions defined in the body will be executed if the condition is true
* moveFarEnemies()
*
* otherwise { // Use otherwise() to define actions executed if the condition is false
* boostSelf()
* moveNearEnemy()
* }
* }
* }
* }
* }</pre>
*
* @see fr.quatrevieux.araknemu.game.fight.ai.factory.AbstractAiBuilderFactory To define AI without groovy
*/
public abstract class AbstractScriptingAiBuilderFactory extends GeneratorBuilder implements NamedAiFactory<PlayableFighter>, Cloneable {
@Override
public final Optional<AI> create(PlayableFighter fighter) {
final AbstractScriptingAiBuilderFactory self = clone();

self.configure(fighter);

return Optional.of(new FighterAI(fighter, fighter.fight(), self.build()));
}

/**
* Configure the AI for the given fighter
*/
protected void configure(PlayableFighter fighter) {
configure();
}

/**
* Build the AI actions pipeline
*/
protected void configure() {
throw new UnsupportedOperationException("One of configure() or configure(PlayableFighter fighter) method must be implemented");
}

/**
* Build a conditional action
*
* Usage:
* <pre>{@code
* // Define the condition
* whether({ turn().points().actionPoints() > 10 }) {
* // Actions to execute if the condition is true
* teleportNearEnemy()
* add(new CastGivenSpell(147))
*
* // Define the "else" part
* otherwise {
* // Actions to execute if the condition is false
* boostSelf()
* moveFarEnemies()
* }
* }
*
* // which is equivalent to
* when({ it.turn().points().actionPoints() > 10 }) {
* it.success { GeneratorBuilder b ->
* b.teleportNearEnemy()
* b.add(new CastGivenSpell(147))
* }
*
* it.otherwise { GeneratorBuilder b ->
* b.boostSelf()
* b.moveFarEnemies()
* }
* }
* }</pre>
*
* @param condition The condition predicate. Takes as argument the AI, which is the delegate of the closure.
* @param body The conditional actions builder. Takes as argument the builder, which is the delegate of the closure.
*
* @return the current builder instance for chaining
*
* @see GeneratorBuilder#when(Predicate, Consumer) The equivalent method for non-groovy code
*/
public final AbstractScriptingAiBuilderFactory whether(@DelegatesTo(AI.class) Closure<Boolean> condition, @DelegatesTo(WhetherBody.class) Closure<?> body) {
when(
ai -> {
condition.setDelegate(ai);
condition.setResolveStrategy(Closure.DELEGATE_FIRST);

return condition.call(ai);
},
cb -> new WhetherBody(cb).configure(body)
);

return this;
}

@Override
protected AbstractScriptingAiBuilderFactory clone() {
try {
return (AbstractScriptingAiBuilderFactory) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);

Check warning on line 163 in src/main/java/fr/quatrevieux/araknemu/game/fight/ai/factory/scripting/AbstractScriptingAiBuilderFactory.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/fr/quatrevieux/araknemu/game/fight/ai/factory/scripting/AbstractScriptingAiBuilderFactory.java#L162-L163

Added lines #L162 - L163 were not covered by tests
}
}

public static final class WhetherBody extends GeneratorBuilder {
private final ConditionalBuilder builder;

public WhetherBody(ConditionalBuilder builder) {
this.builder = builder;
}

private void configure(@DelegatesTo(WhetherBody.class) Closure<?> configurator) {
configurator.setDelegate(this);
configurator.setResolveStrategy(Closure.DELEGATE_FIRST);
configurator.call(this);

builder.success(this.build());
}

/**
* Build the "else" part of the conditional action
*
* The configurator closure will be called with the builder as delegate,
* so you can call generator builder methods directly
*
* @param configurator Configuration closure. Takes as argument the builder.
*/
public void otherwise(@DelegatesTo(GeneratorBuilder.class) Closure<?> configurator) {
builder.otherwise(b -> {
configurator.setDelegate(b);
configurator.setResolveStrategy(Closure.DELEGATE_FIRST);
configurator.call(b);
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
* Copyright (c) 2017-2024 Vincent Quatrevieux
*/

package fr.quatrevieux.araknemu.game.fight.ai.factory;
package fr.quatrevieux.araknemu.game.fight.ai.factory.scripting;

import fr.quatrevieux.araknemu.core.di.Instantiator;
import fr.quatrevieux.araknemu.core.scripting.ScriptLoader;
import fr.quatrevieux.araknemu.game.fight.ai.factory.AiFactoryLoader;
import fr.quatrevieux.araknemu.game.fight.ai.factory.NamedAiFactory;
import fr.quatrevieux.araknemu.game.fight.fighter.ActiveFighter;
import org.apache.logging.log4j.Logger;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
import fr.quatrevieux.araknemu.game.fight.ai.factory.ChainAiFactory;
import fr.quatrevieux.araknemu.game.fight.ai.factory.DoubleAiFactory;
import fr.quatrevieux.araknemu.game.fight.ai.factory.ListAiFactoryLoader;
import fr.quatrevieux.araknemu.game.fight.ai.factory.ScriptingAiLoader;
import fr.quatrevieux.araknemu.game.fight.ai.factory.scripting.ScriptingAiLoader;
import fr.quatrevieux.araknemu.game.fight.ai.simulation.Simulator;
import fr.quatrevieux.araknemu.game.fight.fighter.DefaultFighterFactory;
import fr.quatrevieux.araknemu.game.fight.fighter.FighterFactory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@

import fr.quatrevieux.araknemu.core.scripting.HotReloadableScript;
import fr.quatrevieux.araknemu.game.GameBaseCase;
import fr.quatrevieux.araknemu.game.fight.ai.factory.scripting.ScriptingAiLoader;
import fr.quatrevieux.araknemu.game.fight.ai.simulation.Simulator;
import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
Expand All @@ -33,7 +33,6 @@
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import static org.junit.jupiter.api.Assertions.assertEquals;
Expand Down
Loading
Loading