diff --git a/src/main/java/fr/quatrevieux/araknemu/game/fight/FightHandler.java b/src/main/java/fr/quatrevieux/araknemu/game/fight/FightHandler.java index 5824382b7..f797c856f 100644 --- a/src/main/java/fr/quatrevieux/araknemu/game/fight/FightHandler.java +++ b/src/main/java/fr/quatrevieux/araknemu/game/fight/FightHandler.java @@ -50,11 +50,13 @@ public Fight start(Consumer configuration) { final Fight fight = builder.build(service.newFightId()); service.modules(fight).forEach(fight::register); - fight.nextState(); - fight.dispatcher().register(this); - - service.created(fight); + // Execute the fight creation in another thread to avoid blocking the network thread + fight.execute(() -> { + fight.nextState(); + fight.dispatcher().register(this); + service.created(fight); + }); return fight; } diff --git a/src/main/java/fr/quatrevieux/araknemu/game/fight/FighterList.java b/src/main/java/fr/quatrevieux/araknemu/game/fight/FighterList.java index 6e4b565f7..efeb469df 100644 --- a/src/main/java/fr/quatrevieux/araknemu/game/fight/FighterList.java +++ b/src/main/java/fr/quatrevieux/araknemu/game/fight/FighterList.java @@ -128,6 +128,20 @@ public Stream alive() { return fighters.stream().filter(fighter -> !fighter.dead()); } + /** + * Internal method for remove all given fighters. + * + * This method should only be used to roll back a call of {@link #join(Fighter, FightCell)}, + * no events or other actions will be triggered. + */ + public void removeAll(Collection fighters) { + for (Fighter fighter : fighters) { + if (this.fighters.remove(fighter)) { + fighter.cell().removeFighter(fighter); + } + } + } + /** * Internal method for clear all fighters objects. */ diff --git a/src/main/java/fr/quatrevieux/araknemu/game/fight/map/util/PlacementCellsGenerator.java b/src/main/java/fr/quatrevieux/araknemu/game/fight/map/util/PlacementCellsGenerator.java index b6721db8f..4fe84688c 100644 --- a/src/main/java/fr/quatrevieux/araknemu/game/fight/map/util/PlacementCellsGenerator.java +++ b/src/main/java/fr/quatrevieux/araknemu/game/fight/map/util/PlacementCellsGenerator.java @@ -22,6 +22,7 @@ import fr.arakne.utils.value.helper.RandomUtil; import fr.quatrevieux.araknemu.game.fight.map.FightCell; import fr.quatrevieux.araknemu.game.fight.map.FightMap; +import org.checkerframework.checker.nullness.qual.Nullable; import java.util.List; @@ -56,8 +57,9 @@ private PlacementCellsGenerator(FightMap map, List available, RandomU * The next cell is free and walkable cell * * If there is no more available start place, a random cell will be taken from the entire map + * If there is no more free cell on the map, return null */ - public FightCell next() { + public @Nullable FightCell next() { if (number >= available.size() - 1) { return randomFightCell(); } @@ -68,7 +70,7 @@ public FightCell next() { /** * Returns the next available start cell */ - private FightCell nextAvailableCell() { + private @Nullable FightCell nextAvailableCell() { final FightCell cell = available.get(++number); if (cell.walkable()) { @@ -81,20 +83,22 @@ private FightCell nextAvailableCell() { /** * Get a random cell from the entire map */ - private FightCell randomFightCell() { + private @Nullable FightCell randomFightCell() { final int size = map.size(); if (size < 1) { - throw new IllegalStateException("The map " + map.id() + " is empty"); + return null; } - for (;;) { + for (int i = 0; i < 10; ++i) { final FightCell cell = map.get(random.nextInt(size)); if (cell.walkable()) { return cell; } } + + return null; } /** diff --git a/src/main/java/fr/quatrevieux/araknemu/game/fight/state/PlacementState.java b/src/main/java/fr/quatrevieux/araknemu/game/fight/state/PlacementState.java index e97b50dac..899e59da1 100644 --- a/src/main/java/fr/quatrevieux/araknemu/game/fight/state/PlacementState.java +++ b/src/main/java/fr/quatrevieux/araknemu/game/fight/state/PlacementState.java @@ -83,21 +83,18 @@ public void start(Fight fight) { this.fight = fight; this.cellsGenerators = new HashMap<>(); - for (FightTeam team : fight.teams()) { - cellsGenerators.put( - team, - randomize - ? PlacementCellsGenerator.randomized(fight.map(), team.startPlaces()) - : new PlacementCellsGenerator(fight.map(), team.startPlaces()) - ); - } - fight.dispatcher().register(this); startTime = System.currentTimeMillis(); - // Add all fighters to fight - // Note: fight.fighters() cannot be used because at this state fighters are not yet on fight - addFighters(fight.teams().stream().flatMap(team -> team.fighters().stream()).collect(Collectors.toList())); + try { + // Add all fighters to fight + // Note: fight.fighters() cannot be used because at this state fighters are not yet on fight + addFighters(fight.teams().stream().flatMap(team -> team.fighters().stream()).collect(Collectors.toList())); + } catch (Exception e) { + fight.cancel(); + + throw new FightException("Cannot add fighters", e); + } if (fight.type().hasPlacementTimeLimit()) { timer = fight.schedule(this::innerStartFight, fight.type().placementDuration()); @@ -189,7 +186,13 @@ public synchronized void joinTeam(Fighter fighter, FightTeam team) throws JoinFi } team.join(fighter); - addFighters(Collections.singleton(fighter)); + + try { + addFighters(Collections.singleton(fighter)); + } catch (FightException e) { + team.kick(fighter); + throw new JoinFightException(JoinFightError.CHALLENGE_FULL); + } } @Override @@ -287,13 +290,38 @@ private void punishDeserter(Fighter fighter) { fighter.dispatch(new FightLeaved(rewardsSheet.rewards().get(0))); } + /** + * Add given fighters to the fight + * + * This method may fail, in this case the fighters list will be unchanged, + * so this method can be considered as atomic + * + * @throws FightException When cannot found a cell for a fighter + */ @RequiresNonNull("fight") - @SuppressWarnings("dereference.of.nullable") // cellsGenerators.get(fighter.team()) cannot be null private void addFighters(Collection fighters) { + final Fight fight = this.fight; + final Map cellsGenerators = NullnessUtil.castNonNull(this.cellsGenerators); final FighterList fightersList = fight.fighters(); for (Fighter fighter : fighters) { - fightersList.join(fighter, NullnessUtil.castNonNull(cellsGenerators).get(fighter.team()).next()); + final FightCell joinCell = cellsGenerators + .computeIfAbsent(fighter.team(), team -> randomize + ? PlacementCellsGenerator.randomized(fight.map(), team.startPlaces()) + : new PlacementCellsGenerator(fight.map(), team.startPlaces()) + ) + .next() + ; + + // No free cell available, so cancel the join + // Note: no events are triggers in this case and the state is unchanged + if (joinCell == null) { + fightersList.removeAll(fighters); + + throw new FightException("Cannot found a cell for the fighter"); + } + + fightersList.join(fighter, joinCell); } for (Fighter fighter : fighters) { diff --git a/src/test/java/fr/quatrevieux/araknemu/game/fight/FighterListTest.java b/src/test/java/fr/quatrevieux/araknemu/game/fight/FighterListTest.java index 7617a0fb7..5168f8165 100644 --- a/src/test/java/fr/quatrevieux/araknemu/game/fight/FighterListTest.java +++ b/src/test/java/fr/quatrevieux/araknemu/game/fight/FighterListTest.java @@ -20,6 +20,7 @@ package fr.quatrevieux.araknemu.game.fight; import fr.quatrevieux.araknemu.game.fight.event.FighterRemoved; +import fr.quatrevieux.araknemu.game.fight.exception.FightException; import fr.quatrevieux.araknemu.game.fight.fighter.AbstractFighter; import fr.quatrevieux.araknemu.game.fight.fighter.Fighter; import fr.quatrevieux.araknemu.game.fight.fighter.PlayableFighter; @@ -29,13 +30,16 @@ import java.lang.reflect.Field; import java.sql.SQLException; +import java.util.Collections; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class FighterListTest extends FightBaseCase { @@ -202,4 +206,15 @@ class Foo {} fight.dispatchToAll(new Foo()); assertEquals(1, ai.get()); } + + @Test + void removeAll() throws SQLException { + Fighter fighter = new PlayerFighter(makeSimpleGamePlayer(10)); + fighterList.join(fighter, fight.map().get(146)); + + fighterList.removeAll(Collections.singleton(fighter)); + + assertFalse(fighterList.all().contains(fighter)); + assertFalse(fight.map().get(146).hasFighter()); + } } diff --git a/src/test/java/fr/quatrevieux/araknemu/game/fight/map/util/PlacementCellsGeneratorTest.java b/src/test/java/fr/quatrevieux/araknemu/game/fight/map/util/PlacementCellsGeneratorTest.java index 2923d5e27..7a53ddcc5 100644 --- a/src/test/java/fr/quatrevieux/araknemu/game/fight/map/util/PlacementCellsGeneratorTest.java +++ b/src/test/java/fr/quatrevieux/araknemu/game/fight/map/util/PlacementCellsGeneratorTest.java @@ -19,8 +19,16 @@ package fr.quatrevieux.araknemu.game.fight.map.util; +import fr.arakne.utils.maps.serializer.CellData; +import fr.arakne.utils.value.Dimensions; +import fr.quatrevieux.araknemu.data.value.Geolocation; +import fr.quatrevieux.araknemu.data.world.entity.environment.MapTemplate; import fr.quatrevieux.araknemu.game.GameBaseCase; +import fr.quatrevieux.araknemu.game.exploration.area.AreaService; +import fr.quatrevieux.araknemu.game.exploration.map.ExplorationMap; import fr.quatrevieux.araknemu.game.exploration.map.ExplorationMapService; +import fr.quatrevieux.araknemu.game.exploration.map.cell.CellLoader; +import fr.quatrevieux.araknemu.game.exploration.map.cell.CellLoaderAggregate; import fr.quatrevieux.araknemu.game.fight.FightService; import fr.quatrevieux.araknemu.game.fight.fighter.Fighter; import fr.quatrevieux.araknemu.game.fight.map.FightCell; @@ -31,9 +39,11 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class PlacementCellsGeneratorTest extends GameBaseCase { @@ -92,4 +102,38 @@ void nextWithAllAvailableCellsNotFree() { assertNotEquals(124, cell.id()); assertNotEquals(125, cell.id()); } + + @Test + void nextWithEmptyMap() { + map = new FightMap( + new MapTemplate( + 0, + "", + new Dimensions(0, 0), + "", + new CellData[0], + new int[2][0], + new Geolocation(0, 0), + 0, + false + ) + ); + + PlacementCellsGenerator generator = new PlacementCellsGenerator(map, Collections.emptyList()); + + assertNull(generator.next()); + } + + @Test + void nextWithoutAvailableCell() { + PlacementCellsGenerator generator = new PlacementCellsGenerator(map, Arrays.asList(map.get(123), map.get(124), map.get(125))); + + for (int i = 0; i < map.size(); ++i) { + if (map.get(i).walkable()) { + map.get(i).set(Mockito.mock(Fighter.class)); + } + } + + assertNull(generator.next()); + } } diff --git a/src/test/java/fr/quatrevieux/araknemu/game/fight/state/PlacementStateTest.java b/src/test/java/fr/quatrevieux/araknemu/game/fight/state/PlacementStateTest.java index 3843516b6..9cec0d2dd 100644 --- a/src/test/java/fr/quatrevieux/araknemu/game/fight/state/PlacementStateTest.java +++ b/src/test/java/fr/quatrevieux/araknemu/game/fight/state/PlacementStateTest.java @@ -19,9 +19,16 @@ package fr.quatrevieux.araknemu.game.fight.state; +import fr.arakne.utils.maps.constant.Direction; import fr.quatrevieux.araknemu.core.di.ContainerException; +import fr.quatrevieux.araknemu.data.value.Position; +import fr.quatrevieux.araknemu.data.world.entity.monster.MonsterGroupData; +import fr.quatrevieux.araknemu.data.world.transformer.MonsterListTransformer; +import fr.quatrevieux.araknemu.game.exploration.map.ExplorationMapService; import fr.quatrevieux.araknemu.game.fight.Fight; import fr.quatrevieux.araknemu.game.fight.FightBaseCase; +import fr.quatrevieux.araknemu.game.fight.FightService; +import fr.quatrevieux.araknemu.game.fight.JoinFightError; import fr.quatrevieux.araknemu.game.fight.event.FightCancelled; import fr.quatrevieux.araknemu.game.fight.event.FightJoined; import fr.quatrevieux.araknemu.game.fight.event.FighterAdded; @@ -30,9 +37,12 @@ import fr.quatrevieux.araknemu.game.fight.exception.FightMapException; import fr.quatrevieux.araknemu.game.fight.exception.InvalidFightStateException; import fr.quatrevieux.araknemu.game.fight.exception.JoinFightException; +import fr.quatrevieux.araknemu.game.fight.fighter.FighterFactory; import fr.quatrevieux.araknemu.game.fight.fighter.player.PlayerFighter; import fr.quatrevieux.araknemu.game.fight.map.FightCell; import fr.quatrevieux.araknemu.game.fight.map.FightMap; +import fr.quatrevieux.araknemu.game.fight.team.FightTeam; +import fr.quatrevieux.araknemu.game.fight.team.MonsterGroupTeam; import fr.quatrevieux.araknemu.game.fight.team.SimpleTeam; import fr.quatrevieux.araknemu.game.fight.turn.action.factory.ActionsFactory; import fr.quatrevieux.araknemu.game.fight.type.ChallengeType; @@ -45,6 +55,12 @@ import fr.quatrevieux.araknemu.game.listener.fight.StartFightWhenAllReady; import fr.quatrevieux.araknemu.game.listener.fight.fighter.ClearFighter; import fr.quatrevieux.araknemu.game.listener.fight.fighter.SendFighterRemoved; +import fr.quatrevieux.araknemu.game.monster.environment.FixedCellSelector; +import fr.quatrevieux.araknemu.game.monster.environment.LivingMonsterGroupPosition; +import fr.quatrevieux.araknemu.game.monster.environment.MonsterEnvironmentService; +import fr.quatrevieux.araknemu.game.monster.environment.RandomCellSelector; +import fr.quatrevieux.araknemu.game.monster.group.MonsterGroup; +import fr.quatrevieux.araknemu.game.monster.group.MonsterGroupFactory; import fr.quatrevieux.araknemu.network.game.out.fight.CancelFight; import fr.quatrevieux.araknemu.network.game.out.fight.FighterPositions; import fr.quatrevieux.araknemu.network.game.out.game.AddSprites; @@ -62,6 +78,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicReference; @@ -72,6 +89,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; class PlacementStateTest extends FightBaseCase { private Fight fight; @@ -147,6 +165,74 @@ void start() { assertCount(2, fight.fighters().all()); } + @Test + void startFailedWithNoFreeCellShouldBeCancelled() throws SQLException { + explorationPlayer(); + + FightMap map = loadFightMap(10340); + dataSet + .pushMonsterTemplates() + .pushMonsterSpells() + ; + + final MonsterGroupData data = new MonsterGroupData( + -1, + Duration.ZERO, + 0, + 0, + container.get(MonsterListTransformer.class).unserialize("34x400"), // group of 400 monsters + "", + new Position(0, 0), + false + ); + + MonsterGroupFactory groupFactory = container.get(MonsterGroupFactory.class); + + final LivingMonsterGroupPosition position = new LivingMonsterGroupPosition( + groupFactory, + container.get(MonsterEnvironmentService.class), + container.get(FightService.class), + data, + new RandomCellSelector(), + true + ); + + position.populate(container.get(ExplorationMapService.class).load(10340)); + + MonsterGroup group = groupFactory.create(data, position); + MonsterGroupTeam otherTeam = new MonsterGroupTeam(group, Collections.emptyList(), 1, container.get(FighterFactory.class)); + + fight = new Fight( + 1, + new ChallengeType(configuration.fight()), + map, + new ArrayList<>(Arrays.asList( + fight -> new SimpleTeam(fight, fighter = makePlayerFighter(player), Arrays.asList(map.get(123), map.get(222)), 0), + fight -> otherTeam + )), + new StatesFlow( + new NullState(), + new InitialiseState(), + state = new PlacementState(false), + new ActiveState() + ), + container.get(Logger.class), + ExecutorFactory.createSingleThread(), + container.get(ActionsFactory.Factory.class) + ); + + requestStack.clear(); + + assertThrows(FightException.class, () -> state.start(fight)); + + assertFalse(fight.alive()); + assertCount(0, fight.fighters().all()); + requestStack.assertEmpty(); + + assertTrue(player.isExploring()); + assertFalse(player.isFighting()); + } + @Test void startRandomized() { state = new PlacementState(true); @@ -380,6 +466,92 @@ void joinTeamSuccess() throws SQLException, ContainerException, JoinFightExcepti requestStack.assertLast(new AddSprites(Collections.singleton(newFighter.sprite()))); } + @Test + void joinFailedNoMoreFreeCellAvailable() throws SQLException { + FightMap map = loadFightMap(10340); + dataSet + .pushMonsterTemplates() + .pushMonsterSpells() + ; + + // Get all cells except one for the player + List freeCells = new ArrayList<>(); + + for (int i = 0; i < map.size(); ++i) { + FightCell cell = map.get(i); + + if (cell.walkable() && cell.id() != 123) { + freeCells.add(cell); + } + } + + final MonsterGroupData data = new MonsterGroupData( + -1, + Duration.ZERO, + 0, + 0, + container.get(MonsterListTransformer.class).unserialize("34x" + freeCells.size()), // ensure that the map will be filled + "", + new Position(0, 0), + false + ); + + MonsterGroupFactory groupFactory = container.get(MonsterGroupFactory.class); + + final LivingMonsterGroupPosition position = new LivingMonsterGroupPosition( + groupFactory, + container.get(MonsterEnvironmentService.class), + container.get(FightService.class), + data, + new RandomCellSelector(), + true + ); + + position.populate(container.get(ExplorationMapService.class).load(10340)); + + MonsterGroup group = groupFactory.create(data, position); + MonsterGroupTeam otherTeam = new MonsterGroupTeam(group, freeCells, 1, container.get(FighterFactory.class)); + + fight = new Fight( + 1, + new ChallengeType(configuration.fight()), + map, + new ArrayList<>(Arrays.asList( + fight -> new SimpleTeam(fight, fighter = makePlayerFighter(player), Arrays.asList(map.get(123), map.get(222)), 0), + fight -> otherTeam + )), + new StatesFlow( + new NullState(), + new InitialiseState(), + state = new PlacementState(false), + new ActiveState() + ), + container.get(Logger.class), + ExecutorFactory.createSingleThread(), + container.get(ActionsFactory.Factory.class) + ); + + fight.nextState(); + requestStack.clear(); + + assertTrue(fight.alive()); + assertCount(freeCells.size() + 1, fight.fighters().all()); + + PlayerFighter otherFighter = makePlayerFighter(other); + + try { + state.joinTeam(otherFighter, fighter.team()); + fail("Should not throw exception"); + } catch (JoinFightException e) { + assertEquals(JoinFightError.CHALLENGE_FULL, e.error()); + } + + assertFalse(fight.fighters().all().contains(otherFighter)); + assertTrue(fight.alive()); + assertCount(freeCells.size() + 1, fight.fighters().all()); + requestStack.assertEmpty(); + } + @Test void joinTeamBadState() throws SQLException, ContainerException { fight.nextState();