Skip to content

Commit

Permalink
Merge pull request #348 from vincent4vx/feature-improve-ai-scripting
Browse files Browse the repository at this point in the history
feat(ai): Improve scripting API
  • Loading branch information
vincent4vx authored May 25, 2024
2 parents ab7ab10 + 94398ae commit c00947d
Show file tree
Hide file tree
Showing 13 changed files with 531 additions and 24 deletions.
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);
}
}

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

0 comments on commit c00947d

Please sign in to comment.