diff --git a/data/conf/zones.xsd b/data/conf/zones.xsd
index 1fd34be1f91..569b5553765 100644
--- a/data/conf/zones.xsd
+++ b/data/conf/zones.xsd
@@ -25,6 +25,7 @@
maxOccurs="unbounded"/>
+
@@ -178,4 +179,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/CHANGES.txt b/doc/CHANGES.txt
index 51a39be1f5f..b9162fa97b9 100644
--- a/doc/CHANGES.txt
+++ b/doc/CHANGES.txt
@@ -9,6 +9,7 @@ Changelog
- new achievements:
- 30 Minutes or Less: Deliver 25 hot pizzas
- Balduin allows requesting different item for Ultimate Collector quest
+- support added for dynamic creature spawning
*web client*
- fixed text extending past edge of notification bubbles
diff --git a/src/games/stendhal/server/core/config/ZonesXMLLoader.java b/src/games/stendhal/server/core/config/ZonesXMLLoader.java
index 0798dfd9d2c..26c742d8ebf 100644
--- a/src/games/stendhal/server/core/config/ZonesXMLLoader.java
+++ b/src/games/stendhal/server/core/config/ZonesXMLLoader.java
@@ -4,7 +4,7 @@
* $Id$
*/
/***************************************************************************
- * (C) Copyright 2003-2023 - Stendhal *
+ * (C) Copyright 2003-2024 - Stendhal *
***************************************************************************
***************************************************************************
* *
@@ -38,6 +38,7 @@
import games.stendhal.common.tiled.StendhalMapStructure;
import games.stendhal.server.core.config.zone.AttributesXMLReader;
import games.stendhal.server.core.config.zone.ConfiguratorXMLReader;
+import games.stendhal.server.core.config.zone.CreatureSpawnsXMLReader;
import games.stendhal.server.core.config.zone.EntitySetupXMLReader;
import games.stendhal.server.core.config.zone.PortalSetupXMLReader;
import games.stendhal.server.core.config.zone.RegionNameSubstitutionHelper;
@@ -60,6 +61,8 @@ public final class ZonesXMLLoader {
/** Zone attributes reader. */
private static final SetupXMLReader attributesReader = new AttributesXMLReader();
+ /** Dynamically spawned creatures. */
+ private static final SetupXMLReader creatureSpawnsReader = new CreatureSpawnsXMLReader();
/**
* The ConfiguratorDescriptor XML reader.
*/
@@ -417,6 +420,13 @@ public ZoneDesc readZone(final Element element) {
continue;
} else if (tag.equals("associated")) {
desc.setAssociatedZones(child.getAttribute("zones"));
+ } else if (tag.equals("spawns")) {
+ for (final Element spawn: XMLUtil.getElements(child)) {
+ final String spawnType = spawn.getTagName();
+ if ("creatures".equals(spawnType)) {
+ setupDesc = creatureSpawnsReader.read(spawn);
+ }
+ }
} else {
logger.warn("Zone [" + name + "] has unknown element: " + tag);
continue;
diff --git a/src/games/stendhal/server/core/config/zone/CreatureSpawnsXMLReader.java b/src/games/stendhal/server/core/config/zone/CreatureSpawnsXMLReader.java
new file mode 100644
index 00000000000..e022d93c166
--- /dev/null
+++ b/src/games/stendhal/server/core/config/zone/CreatureSpawnsXMLReader.java
@@ -0,0 +1,56 @@
+/***************************************************************************
+ * Copyright © 2024 - Faiumoni e. V. *
+ ***************************************************************************
+ ***************************************************************************
+ * *
+ * This program is free software; you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation; either version 2 of the License, or *
+ * (at your option) any later version. *
+ * *
+ ***************************************************************************/
+package games.stendhal.server.core.config.zone;
+
+import java.util.Map;
+
+import org.apache.log4j.Logger;
+import org.w3c.dom.Element;
+
+import games.stendhal.common.MathHelper;
+import games.stendhal.server.core.engine.StendhalRPZone;
+import games.stendhal.server.entity.mapstuff.spawner.DynamicCreatureSpawner;
+
+
+/**
+ * Parses info for dynamically spawned creatures.
+ */
+public class CreatureSpawnsXMLReader extends SetupXMLReader {
+
+ private static Logger logger = Logger.getLogger(CreatureSpawnsXMLReader.class);
+
+
+ @Override
+ public SetupDescriptor read(final Element element) {
+ final CreatureSpawnsDescriptor desc = new CreatureSpawnsDescriptor();
+ readParameters(desc, element);
+ return desc;
+ }
+
+ private static class CreatureSpawnsDescriptor extends SetupDescriptor {
+ @Override
+ public void setup(final StendhalRPZone zone) {
+ final DynamicCreatureSpawner spawner = new DynamicCreatureSpawner(zone);
+ final Map params = getParameters();
+ for (final String name: params.keySet()) {
+ final int max = MathHelper.parseIntDefault(params.get(name), 0);
+ if (max < 0) {
+ logger.warn("Max must be a positive integer, not registering dynamic spawn for creature \"" + name + "\"");
+ continue;
+ }
+ spawner.register(name, max);
+ }
+ // NOTE: does zone need to know about dynamic spawner?
+ zone.setDynamicSpawner(spawner);
+ }
+ }
+}
diff --git a/src/games/stendhal/server/core/engine/StendhalRPZone.java b/src/games/stendhal/server/core/engine/StendhalRPZone.java
index e7081d73835..d32d5d520de 100644
--- a/src/games/stendhal/server/core/engine/StendhalRPZone.java
+++ b/src/games/stendhal/server/core/engine/StendhalRPZone.java
@@ -39,6 +39,7 @@
import games.stendhal.common.Direction;
import games.stendhal.common.Line;
import games.stendhal.common.MathHelper;
+import games.stendhal.common.Rand;
import games.stendhal.common.filter.FilterCriteria;
import games.stendhal.common.grammar.Grammar;
import games.stendhal.common.tiled.LayerDefinition;
@@ -64,6 +65,7 @@
import games.stendhal.server.entity.mapstuff.portal.OneWayPortalDestination;
import games.stendhal.server.entity.mapstuff.portal.Portal;
import games.stendhal.server.entity.mapstuff.spawner.CreatureRespawnPoint;
+import games.stendhal.server.entity.mapstuff.spawner.DynamicCreatureSpawner;
import games.stendhal.server.entity.mapstuff.spawner.PassiveEntityRespawnPoint;
import games.stendhal.server.entity.mapstuff.spawner.PassiveEntityRespawnPointFactory;
import games.stendhal.server.entity.mapstuff.spawner.SheepFood;
@@ -73,6 +75,7 @@
import games.stendhal.server.entity.npc.TrainingDummyFactory;
import games.stendhal.server.entity.player.Player;
import games.stendhal.server.util.StringUtils;
+import marauroa.common.Pair;
import marauroa.common.game.IRPZone;
import marauroa.common.game.RPObject;
import marauroa.common.game.RPSlot;
@@ -113,8 +116,12 @@ public class StendhalRPZone extends MarauroaRPZone {
*/
private final List sheepFoods;
+ /** Statically spawned creatures. */
private final List respawnPoints;
+ /** Dynamically spawned creatures. */
+ private DynamicCreatureSpawner dynamicSpawner;
+
private final List plantGrowers;
private final List playersAndFriends;
@@ -311,6 +318,30 @@ public void remove(final CreatureRespawnPoint point) {
respawnPoints.remove(point);
}
+ /**
+ * Retrieves the dynamic creature spawner associated with this zone.
+ *
+ * @return
+ * Creature spawner instance.
+ */
+ public DynamicCreatureSpawner getDynamicSpawner() {
+ return dynamicSpawner;
+ }
+
+ /**
+ * Sets the dynamic creature spawner associated with this zone.
+ *
+ * @param spawner
+ * Creature spawner instance.
+ */
+ public void setDynamicSpawner(final DynamicCreatureSpawner spawner) {
+ if (dynamicSpawner != null) {
+ dynamicSpawner.remove();
+ }
+ dynamicSpawner = spawner;
+ dynamicSpawner.init();
+ }
+
/**
* Retrieves growers in this zone.
*/
@@ -1517,6 +1548,7 @@ public void nextTurn() {
os.append("playersAndFriends: " + playersAndFriends.size() + "\n");
os.append("portals: " + portals.size() + "\n");
os.append("respawnPoints: " + respawnPoints.size() + "\n");
+ os.append("dynamicRespawns: " + dynamicSpawner.maxTotal() + "\n");
os.append("sheepFoods: " + sheepFoods.size() + "\n");
os.append("objects: " + objects.size() + "\n");
logger.info(os);
@@ -1971,4 +2003,24 @@ public boolean passes(final Entity e) {
}
return (WeatherEntity) entities.get(0);
}
+
+ /**
+ * Attempts to find a position where an entity can be spawned.
+ *
+ * @param entity
+ * Entity attempting to spawn.
+ * @return
+ * Appropriate position or `null` if none found.
+ */
+ public Pair getRandomSpawnPosition(final Entity entity) {
+ final short retries = 50;
+ for (short t = 0; t < retries; t++) {
+ final int x = Rand.rand(getWidth());
+ final int y = Rand.rand(getHeight());
+ if (!collides(entity, x, y)) {
+ return new Pair<>(x, y);
+ }
+ }
+ return null;
+ }
}
diff --git a/src/games/stendhal/server/entity/mapstuff/spawner/DynamicCreatureSpawner.java b/src/games/stendhal/server/entity/mapstuff/spawner/DynamicCreatureSpawner.java
new file mode 100644
index 00000000000..564b1f7895e
--- /dev/null
+++ b/src/games/stendhal/server/entity/mapstuff/spawner/DynamicCreatureSpawner.java
@@ -0,0 +1,344 @@
+/***************************************************************************
+ * Copyright © 2024 - Faiumoni e. V. *
+ ***************************************************************************
+ ***************************************************************************
+ * *
+ * This program is free software; you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation; either version 2 of the License, or *
+ * (at your option) any later version. *
+ * *
+ ***************************************************************************/
+package games.stendhal.server.entity.mapstuff.spawner;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.apache.log4j.Logger;
+
+import games.stendhal.common.MathHelper;
+import games.stendhal.common.Rand;
+import games.stendhal.common.Triple;
+import games.stendhal.server.core.engine.SingletonRepository;
+import games.stendhal.server.core.engine.StendhalRPZone;
+import games.stendhal.server.core.rp.StendhalRPAction;
+import games.stendhal.server.core.rule.defaultruleset.DefaultCreature;
+import games.stendhal.server.entity.creature.Creature;
+import games.stendhal.server.util.Observer;
+import marauroa.common.Pair;
+
+
+/**
+ * Class that spawn multiple creatures at random points in a zone.
+ */
+public class DynamicCreatureSpawner extends LinkedHashMap> implements CreatureSpawner {
+
+ private static final Logger logger = Logger.getLogger(DynamicCreatureSpawner.class);
+
+ /** Zone associated with this spawner. */
+ private final StendhalRPZone zone;
+ /** Creatures to be spawned at turn reached. */
+ final List> queued;
+
+ final List observers;
+
+ /** Denotes whether turn notifier is active. */
+ private int spawnsAt = -1;
+
+
+ /**
+ * Creates a new spawner.
+ *
+ * @param zone
+ */
+ public DynamicCreatureSpawner(final StendhalRPZone zone) {
+ this.zone = zone;
+ this.observers = new LinkedList();
+ this.queued = new LinkedList>();
+ }
+
+ /**
+ * Starts the spawning cycle of all registered creatures.
+ */
+ public void init() {
+ for (final String name: keySet()) {
+ queue(name);
+ }
+ // NOTE: Do we want to queue all possible spawns of a creature simultaneously instead of
+ // sequentially? E.g. if max active = 10 queue up all 10 instances instead of only the
+ // first.
+ startNotifier();
+ }
+
+ /**
+ * Disables associated turn notifiers for clean removal.
+ */
+ public void remove() {
+ SingletonRepository.getTurnNotifier().dontNotify(this);
+ // NOTE: should active spawned creatures also be removed?
+ }
+
+ /**
+ * Adds a creature to spawn list.
+ *
+ * Registration is represented in a map indexed by creature name. The value is a triple of
+ * consisting of the following integer representations:
+ * - first: respawn time
+ * - second: max active
+ * - third: actual acitve
+ *
+ * @param name
+ * Creature name.
+ * @param max
+ * Maximum instances allowed at one time from this spawner.
+ */
+ public void register(final String name, final int max) {
+ if (max < 1) {
+ logger.warn("Max must be a positive integer");
+ return;
+ }
+ final DefaultCreature creature = SingletonRepository.getEntityManager().getDefaultCreature(name);
+ if (creature == null) {
+ logger.warn("Creature \"" + name + "\" not found");
+ return;
+ }
+ put(name, new Triple(creature.getRespawnTime(), max, 0));
+ }
+
+ /**
+ * Retrieves respawn time value of a creature.
+ *
+ * @param name
+ * Creature name.
+ * @return
+ * Rate at which creature respawns.
+ */
+ private int getRespawnTime(final String name) {
+ if (containsKey(name)) {
+ return get(name).getFirst();
+ }
+ return 0;
+ }
+
+ /**
+ * Retrieves maximum allowed active spawned instances.
+ *
+ * @param name
+ * Creature name.
+ * @return
+ * Maximum allowed creatures at one time.
+ */
+ private int getActiveMax(final String name) {
+ if (containsKey(name)) {
+ return get(name).getSecond();
+ }
+ return 0;
+ }
+
+ /**
+ * Retrieves the current number of active creatures from this spawner.
+ *
+ * @param name
+ * Creature name.
+ * @return
+ * Active creatures.
+ */
+ private int getActiveCount(final String name) {
+ if (containsKey(name)) {
+ return get(name).getThird();
+ }
+ return 0;
+ }
+
+ /**
+ * Retrieves the total number of active creatures that can be spawned at the same time.
+ *
+ * @return
+ * Max total active creatures.
+ */
+ public int maxTotal() {
+ int total = 0;
+ for (final Triple tr: values()) {
+ total += tr.getSecond();
+ }
+ return total;
+ }
+
+ /**
+ * Adds a new creature instance to zone at a random position.
+ *
+ * @param name
+ * Creature name.
+ */
+ private boolean spawn(final String name) {
+ final DefaultCreature defaultCreature = SingletonRepository.getEntityManager().getDefaultCreature(name);
+ if (defaultCreature == null) {
+ logger.error("Default creature \"" + name + "\" not found");
+ return false;
+ }
+ final Creature creature = defaultCreature.getCreatureRandomizeStats();
+ try {
+ // find a suitable location on map for spawning
+ final Pair pos = zone.getRandomSpawnPosition(creature);
+ if (pos == null) {
+ throw new Exception("No suitable position for dynamic spawning available in zone " + zone.getName());
+ }
+ final int x = pos.first();
+ final int y = pos.second();
+ // NOTE: behavior copied from `CreatureRespawnPoint`, not sure what it does
+ creature.registerObjectsForNotification(observers);
+ if (StendhalRPAction.placeat(zone, creature, x, y)) {
+ onSpawned(creature);
+ return true;
+ } else {
+ // could not place the creature anywhere so treat it like it died
+ onRemoved(creature);
+ logger.warn("Could not spawn " + creature.getName() + " near " + zone.getName() + " " + x + " " + y);
+ }
+ } catch (final Exception e) {
+ logger.error("Error spawning entity " + creature, e);
+ }
+ return false;
+ }
+
+ /**
+ * Adds creature to spawn queue.
+ *
+ * @param name
+ * Creature name.
+ */
+ private void queue(final String name) {
+ if (getActiveCount(name) >= getActiveMax(name)) {
+ // maximum number of creatures is active so don't spawn more
+ return;
+ }
+ final int nextTurn = MathHelper.clamp(Rand.randExponential(getRespawnTime(name)), MIN_RESPAWN_TIME, MAX_RESPAWN_TIME);
+ queued.add(new Pair<>(nextTurn, name));
+ }
+
+ /**
+ * Removes next entry from the queue.
+ */
+ private void unqueue() {
+ final int idx = queued.indexOf(getNextQueued(false));
+ if (idx > -1) {
+ queued.remove(idx);
+ }
+ }
+
+ /**
+ * Gets the next entry in the queue.
+ *
+ * @param copy
+ * If {@code true} make a copy before returning.
+ * @return
+ * Attributes of next entry.
+ */
+ private Pair getNextQueued(final boolean copy) {
+ Pair next = null;
+ // NOTE: might be more efficient to get first value of sorted list
+ for (final Pair p: queued) {
+ final int t = p.first();
+ if (next == null || t < next.first()) {
+ next = p;
+ }
+ }
+ if (copy && next != null) {
+ return new Pair<>(next.first(), next.second());
+ }
+ return next;
+ }
+
+ /**
+ * Increments the known number of active creature instances.
+ *
+ * @param name
+ * Creature name.
+ * @param amount
+ * Number to adjust by (usually 1 or -1).
+ */
+ private void incActive(final String name, final short amount) {
+ if (!containsKey(name)) {
+ logger.warn("Creature \"" + name + "\" not registered");
+ return;
+ }
+ final Triple oldValue = get(name);
+ put(name, new Triple(oldValue.getFirst(), oldValue.getSecond(), oldValue.getThird() + amount));
+ }
+
+ @Override
+ public void onSpawned(final Creature spawned) {
+ spawned.init();
+ spawned.setSpawner(this);
+ final String name = spawned.getName();
+ incActive(name, (short) 1);
+ // spawning succeeded so reset active spawning property
+ spawnsAt = -1;
+ // add another to queue
+ queue(name);
+ // don't restart notifier here as it will be done in {@code TurnListener.onTurnReached}
+ }
+
+ @Override
+ public void onRemoved(final Creature removed) {
+ final String name = removed.getName();
+ incActive(name, (short) -1);
+ // add another to queue
+ queue(name);
+ // restart notifier if needed
+ startNotifier();
+ }
+
+ /**
+ * Starts turn notifier for next queued entry.
+ */
+ private void startNotifier() {
+ final Pair next = getNextQueued(false);
+ if (next == null) {
+ return;
+ }
+ final int turns = next.first();
+ if (spawnsAt > -1 && turns >= spawnsAt) {
+ // don't override earlier spawning
+ return;
+ }
+ spawnsAt = turns;
+ SingletonRepository.getTurnNotifier().notifyInTurns(turns, this);
+ }
+
+ @Override
+ public void onTurnReached(final int currentTurn) {
+ final Pair next = getNextQueued(true);
+ if (next != null) {
+ final String nextName = next.second();
+ /* this check doesn't work because currentTurn is total turns since server started &
+ * next.first() is turns since time of queue
+ if (next.first() != currentTurn) {
+ logger.warn("Creature \"" + nextName + "\" scheduled to spawn at turn " + nextTurn + " but spawning at " + currentTurn);
+ }
+ */
+ // remove from queue
+ unqueue();
+ spawn(nextName);
+ } else {
+ logger.warn("Turn reached but nothing queued for spawn");
+ }
+
+ // restart notifier if another entry is in queue
+ startNotifier();
+ if (spawnsAt > -1 && queued.size() > 0) {
+ logger.warn("Queue not empty but not scheduled for spawn");
+ }
+ }
+
+ @Override
+ public void addObserver(final Observer observer) {
+ observers.add(observer);
+ }
+
+ @Override
+ public void removeObserver(final Observer observer) {
+ observers.remove(observer);
+ }
+}