diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java new file mode 100644 index 00000000..dd6bb4b9 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java @@ -0,0 +1,183 @@ +package net.onelitefeather.cygnus.common.config; + +/** + * Settings for the creek, the second figure next to the slender. + *

+ * All numbers that control the creek's behavior live here, so they can be tuned in a playtest + * without changing code. The constructor rejects combinations that would break the behavior, + * for example a stalk distance range that runs backwards, or a hiding spot inside the view cone. + *

+ * + * @param enabled whether the creek takes part in a round + * @param activeWithLastSurvivor whether the creek keeps going when only one survivor is left + * @param sightRange how far away a survivor can notice the creek, in blocks + * @param sightViewAngle the maximum angle from a survivor's view center at which the + * creek counts as seen, in degrees + * @param wanderPauseMillis how long the creek stops when spotted while wandering + * @param wanderSpeed movement speed while wandering, in blocks per tick + * @param huntSpeed movement speed while hunting, in blocks per tick; must be above + * a walking survivor's 0.216 + * @param stalkThreshold the dread at which the creek starts stalking a survivor + * @param huntThreshold the dread at which a stalk turns into a hunt + * @param stalkMinDistance minimum distance to the stalked survivor, in blocks + * @param stalkMaxDistance maximum distance to the stalked survivor, in blocks + * @param stalkMinAngle minimum angle from the stalked survivor's view direction, in degrees + * @param stalkMaxAngle maximum angle from the stalked survivor's view direction, in degrees + * @param stalkRevealMillis how long the creek stays visible before it teleports away + * @param stalkMinSeconds minimum length of a stalk + * @param stalkMaxSeconds maximum length of a stalk + * @param huntMaxSeconds maximum length of a hunt + * @param catchDistance distance at which the creek catches a survivor, in blocks + * @param vanishMinSeconds shortest vanish time, used at full dread + * @param vanishMaxSeconds longest vanish time, used at no dread + * @param respawnMinDistance minimum distance to every survivor when reappearing, in blocks + * @param personalSpace how close a survivor may come outside a hunt, in blocks + * @param stuckMillis how long the creek may make no progress before taking a shortcut + * @param dreadPageWeight dread share from found pages + * @param dreadTimeWeight dread share from elapsed round time + * @param dreadIsolationWeight dread share from being alone + * @param isolationRadius distance to the nearest survivor at which someone counts as alone + * @param betrayalCatchCount from this catch on, a survivor is always revealed to the slender + * @param betrayalChance chance of a reveal on earlier catches + * @param betrayalGlowSeconds how long a revealed survivor glows for the slender + * @param slownessSeconds how long a caught survivor is slowed + * @param routeLinkDistance how close two route ends have to be to count as linked, in blocks + * @param randomStopChance chance that the creek stops for a moment after reaching a waypoint + * @param randomStopMinMillis shortest random stop, in milliseconds + * @param randomStopMaxMillis longest random stop, in milliseconds + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public record CreekConfig( + boolean enabled, + boolean activeWithLastSurvivor, + int sightRange, + int sightViewAngle, + int wanderPauseMillis, + double wanderSpeed, + double huntSpeed, + double stalkThreshold, + double huntThreshold, + int stalkMinDistance, + int stalkMaxDistance, + int stalkMinAngle, + int stalkMaxAngle, + int stalkRevealMillis, + int stalkMinSeconds, + int stalkMaxSeconds, + int huntMaxSeconds, + double catchDistance, + int vanishMinSeconds, + int vanishMaxSeconds, + int respawnMinDistance, + int personalSpace, + int stuckMillis, + double dreadPageWeight, + double dreadTimeWeight, + double dreadIsolationWeight, + int isolationRadius, + int betrayalCatchCount, + double betrayalChance, + int betrayalGlowSeconds, + int slownessSeconds, + double routeLinkDistance, + double randomStopChance, + int randomStopMinMillis, + int randomStopMaxMillis +) { + + /** + * The default settings: enabled, and tuned for a round of about fifteen minutes. + */ + public static final CreekConfig DEFAULT = new CreekConfig( + true, true, + 48, 35, + 1500, 0.07D, 0.25D, + 0.25D, 0.6D, + 20, 35, 40, 70, 700, 45, 90, + 30, 1.5D, + 20, 40, 30, 15, 3000, + 0.6D, 0.3D, 0.1D, 25, + 2, 0.15D, 6, 4, + 3.0D, + 0.15D, 1500, 4000 + ); + + /** + * Checks the values. + * + * @throws IllegalArgumentException if a value is out of range or two values contradict each other + */ + public CreekConfig { + atLeast("sightRange", sightRange, 1); + between("sightViewAngle", sightViewAngle, 1, GameConfig.Glitch.MAX_VIEW_ANGLE); + atLeast("wanderPauseMillis", wanderPauseMillis, 0); + positive("wanderSpeed", wanderSpeed); + positive("huntSpeed", huntSpeed); + positive("stalkThreshold", stalkThreshold); + between("huntThreshold", huntThreshold, 0.0D, 1.0D); + below("stalkThreshold", stalkThreshold, "huntThreshold", huntThreshold); + atLeast("personalSpace", personalSpace, 1); + below("personalSpace", personalSpace, "stalkMinDistance", stalkMinDistance); + below("stalkMinDistance", stalkMinDistance, "stalkMaxDistance", stalkMaxDistance); + // A hiding spot inside the view cone would count as seen right away. + below("sightViewAngle", sightViewAngle, "stalkMinAngle", stalkMinAngle); + below("stalkMinAngle", stalkMinAngle, "stalkMaxAngle", stalkMaxAngle); + between("stalkMaxAngle", stalkMaxAngle, 1, 180); + atLeast("stalkRevealMillis", stalkRevealMillis, 0); + atLeast("stalkMinSeconds", stalkMinSeconds, 1); + notAbove("stalkMinSeconds", stalkMinSeconds, "stalkMaxSeconds", stalkMaxSeconds); + atLeast("huntMaxSeconds", huntMaxSeconds, 1); + positive("catchDistance", catchDistance); + atLeast("vanishMinSeconds", vanishMinSeconds, 0); + notAbove("vanishMinSeconds", vanishMinSeconds, "vanishMaxSeconds", vanishMaxSeconds); + atLeast("respawnMinDistance", respawnMinDistance, 1); + atLeast("stuckMillis", stuckMillis, 1); + atLeast("dreadPageWeight", dreadPageWeight, 0.0D); + atLeast("dreadTimeWeight", dreadTimeWeight, 0.0D); + atLeast("dreadIsolationWeight", dreadIsolationWeight, 0.0D); + atLeast("isolationRadius", isolationRadius, 1); + atLeast("betrayalCatchCount", betrayalCatchCount, 1); + between("betrayalChance", betrayalChance, 0.0D, 1.0D); + atLeast("betrayalGlowSeconds", betrayalGlowSeconds, 1); + atLeast("slownessSeconds", slownessSeconds, 0); + positive("routeLinkDistance", routeLinkDistance); + between("randomStopChance", randomStopChance, 0.0D, 1.0D); + atLeast("randomStopMinMillis", randomStopMinMillis, 0); + notAbove("randomStopMinMillis", randomStopMinMillis, "randomStopMaxMillis", randomStopMaxMillis); + } + + private static void atLeast(String name, double value, double minimum) { + if (value < minimum) { + throw new IllegalArgumentException(name + " (" + value + ") must be at least " + minimum); + } + } + + private static void positive(String name, double value) { + if (value <= 0.0D) { + throw new IllegalArgumentException(name + " (" + value + ") must be above 0"); + } + } + + private static void between(String name, double value, double minimum, double maximum) { + if (value < minimum || value > maximum) { + throw new IllegalArgumentException( + name + " (" + value + ") must be between " + minimum + " and " + maximum); + } + } + + private static void below(String lowerName, double lower, String upperName, double upper) { + if (lower >= upper) { + throw new IllegalArgumentException( + lowerName + " (" + lower + ") must be below " + upperName + " (" + upper + ")"); + } + } + + private static void notAbove(String lowerName, double lower, String upperName, double upper) { + if (lower > upper) { + throw new IllegalArgumentException( + lowerName + " (" + lower + ") must not be above " + upperName + " (" + upper + ")"); + } + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java index 628e3686..1769cda0 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java @@ -21,6 +21,7 @@ * @param damageSound the sound a player hears when hit * @param glitch how the sight of the slender tears a survivor's view * @param slenderStatic the static the slender hears while pages are found + * @param creek the creek, the second figure next to the slender * @param lobbyAtmosphereShare how far the lobby's atmosphere is taken towards the map's own: {@code 0} * leaves the vanilla overworld, {@code 1} is exactly the map's atmosphere * @author theEvilReaper @@ -36,6 +37,7 @@ public record GameConfig( DamageSound damageSound, Glitch glitch, SlenderStatic slenderStatic, + CreekConfig creek, float lobbyAtmosphereShare ) { @@ -53,16 +55,12 @@ public record GameConfig( * Spawning immediately at {@code GameStartEvent} let survivors grab a page before they had even * moved from the spawn point. The delay gives them time to spread across the map first. *

- * - * @since 2.15.0 */ public static final int PAGE_SPAWN_DELAY = 10; /** * How many seconds {@link #PAGE_SPAWN_DELAY} may randomly shift up or down, re-rolled every * round, so the moment the first pages appear stays unpredictable. - * - * @since 2.15.0 */ public static final int PAGE_SPAWN_DELAY_JITTER = 2; @@ -72,16 +70,12 @@ public record GameConfig( * The page then has to come back on the spot it was found on. Without the delay it would be * collectible again right away, letting a survivor pick up page after page on the same spot. *

- * - * @since 2.15.0 */ public static final int PAGE_RESPAWN_DELAY = 15; /** * How many seconds {@link #PAGE_RESPAWN_DELAY} may randomly shift up or down, re-rolled for every * hidden page, so waiting next to the spot does not pay off. - * - * @since 2.15.0 */ public static final int PAGE_RESPAWN_DELAY_JITTER = 5; @@ -112,6 +106,7 @@ public record GameConfig( DamageSound.DEFAULT, Glitch.DEFAULT, SlenderStatic.DEFAULT, + CreekConfig.DEFAULT, DEFAULT_LOBBY_ATMOSPHERE_SHARE ); diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java index d9bc692b..85998cf9 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java @@ -46,6 +46,7 @@ *
  • slenderStaticFranticInterval
  • *
  • slenderStaticMinVolume
  • *
  • slenderStaticMaxVolume
  • + *
  • creek.* (see {@link CreekConfig})
  • * *

    * If a property can not be found in the file, the default value will be used. @@ -66,6 +67,7 @@ public final class GameConfigReader { private static final String PAGE_PROXIMITY_SOUND_KEY = "pageProximitySound"; private static final String DAMAGE_SOUND_KEY = "damageSound"; private static final String SLENDER_STATIC_SOUND_KEY = "slenderStaticSound"; + private static final String CREEK_PREFIX = "creek."; private final Path path; @@ -147,6 +149,7 @@ public GameConfig getConfig() { getFloat(properties, "slenderStaticMinVolume", slenderStatic.minVolume()), getFloat(properties, "slenderStaticMaxVolume", slenderStatic.maxVolume()) ), + getCreek(properties), getFloat(properties, "lobbyAtmosphereShare", GameConfig.DEFAULT.lobbyAtmosphereShare()) ); } @@ -186,6 +189,80 @@ private float getFloat(Properties properties, String key, float defaultValue) { } } + /** + * Reads a decimal with double precision. The creek's values are compared with each other, + * and float rounding would turn 0.6 into 0.6000000238. + * + * @param properties the loaded properties + * @param key the key to read + * @param defaultValue the value to use when the key is absent or unreadable + * @return the parsed value + */ + private double getDouble(Properties properties, String key, double defaultValue) { + String value = properties.getProperty(key); + if (value == null) { + return defaultValue; + } + try { + return Double.parseDouble(value.trim()); + } catch (NumberFormatException _) { + CONFIG_LOGGER.warn("Failed to parse decimal config value for key '{}': '{}'. Falling back to default: {}", key, value, defaultValue); + return defaultValue; + } + } + + /** + * Reads the creek settings. All keys start with {@value #CREEK_PREFIX}. + *

    + * Like every other group, an unreadable value falls back to its default, while values that + * contradict each other are rejected by {@link CreekConfig}. + *

    + * + * @param properties the loaded properties + * @return the creek settings, never {@code null} + * @throws IllegalArgumentException if the values do not fit together + */ + private CreekConfig getCreek(Properties properties) { + CreekConfig d = CreekConfig.DEFAULT; + return new CreekConfig( + getBoolean(properties, CREEK_PREFIX + "enabled", d.enabled()), + getBoolean(properties, CREEK_PREFIX + "activeWithLastSurvivor", d.activeWithLastSurvivor()), + getInt(properties, CREEK_PREFIX + "sightRange", d.sightRange()), + getInt(properties, CREEK_PREFIX + "sightViewAngle", d.sightViewAngle()), + getInt(properties, CREEK_PREFIX + "wanderPauseMillis", d.wanderPauseMillis()), + getDouble(properties, CREEK_PREFIX + "wanderSpeed", d.wanderSpeed()), + getDouble(properties, CREEK_PREFIX + "huntSpeed", d.huntSpeed()), + getDouble(properties, CREEK_PREFIX + "stalkThreshold", d.stalkThreshold()), + getDouble(properties, CREEK_PREFIX + "huntThreshold", d.huntThreshold()), + getInt(properties, CREEK_PREFIX + "stalkMinDistance", d.stalkMinDistance()), + getInt(properties, CREEK_PREFIX + "stalkMaxDistance", d.stalkMaxDistance()), + getInt(properties, CREEK_PREFIX + "stalkMinAngle", d.stalkMinAngle()), + getInt(properties, CREEK_PREFIX + "stalkMaxAngle", d.stalkMaxAngle()), + getInt(properties, CREEK_PREFIX + "stalkRevealMillis", d.stalkRevealMillis()), + getInt(properties, CREEK_PREFIX + "stalkMinSeconds", d.stalkMinSeconds()), + getInt(properties, CREEK_PREFIX + "stalkMaxSeconds", d.stalkMaxSeconds()), + getInt(properties, CREEK_PREFIX + "huntMaxSeconds", d.huntMaxSeconds()), + getDouble(properties, CREEK_PREFIX + "catchDistance", d.catchDistance()), + getInt(properties, CREEK_PREFIX + "vanishMinSeconds", d.vanishMinSeconds()), + getInt(properties, CREEK_PREFIX + "vanishMaxSeconds", d.vanishMaxSeconds()), + getInt(properties, CREEK_PREFIX + "respawnMinDistance", d.respawnMinDistance()), + getInt(properties, CREEK_PREFIX + "personalSpace", d.personalSpace()), + getInt(properties, CREEK_PREFIX + "stuckMillis", d.stuckMillis()), + getDouble(properties, CREEK_PREFIX + "dreadPageWeight", d.dreadPageWeight()), + getDouble(properties, CREEK_PREFIX + "dreadTimeWeight", d.dreadTimeWeight()), + getDouble(properties, CREEK_PREFIX + "dreadIsolationWeight", d.dreadIsolationWeight()), + getInt(properties, CREEK_PREFIX + "isolationRadius", d.isolationRadius()), + getInt(properties, CREEK_PREFIX + "betrayalCatchCount", d.betrayalCatchCount()), + getDouble(properties, CREEK_PREFIX + "betrayalChance", d.betrayalChance()), + getInt(properties, CREEK_PREFIX + "betrayalGlowSeconds", d.betrayalGlowSeconds()), + getInt(properties, CREEK_PREFIX + "slownessSeconds", d.slownessSeconds()), + getDouble(properties, CREEK_PREFIX + "routeLinkDistance", d.routeLinkDistance()), + getDouble(properties, CREEK_PREFIX + "randomStopChance", d.randomStopChance()), + getInt(properties, CREEK_PREFIX + "randomStopMinMillis", d.randomStopMinMillis()), + getInt(properties, CREEK_PREFIX + "randomStopMaxMillis", d.randomStopMaxMillis()) + ); + } + /** * Reads a flag from the properties. Anything other than {@code true} or {@code false} is not a * decision the operator made on purpose, so it falls back to the default instead of silently diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekLinks.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekLinks.java new file mode 100644 index 00000000..802316b3 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekLinks.java @@ -0,0 +1,107 @@ +package net.onelitefeather.cygnus.common.creek; + +import net.minestom.server.coordinate.Vec; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Where the creek routes of a map link up, worked out once up front. + *

    + * An end of a route is linked to the nearest point of every other route that is at most the link + * distance away. That point may be the other route's end, or somewhere in its middle, where the two + * routes cross. Links work both ways. Two points in the middle of routes never link, and the two + * ends of the same route never link to each other. + *

    + *

    + * The game walks the creek along these links, and the setup marks them, so both always agree. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekLinks { + + /** + * One point of a route. + * + * @param route the route's index + * @param index the point's index within the route + */ + public record Node(int route, int index) { + } + + private final Map> links; + + private CreekLinks(Map> links) { + this.links = links; + } + + /** + * Works out the links between the routes of a map. + * + * @param routes the routes + * @param distance how close an end has to be to a point of another route to link to it, in blocks + * @return the links + */ + public static CreekLinks of(List routes, double distance) { + Map> links = new HashMap<>(); + for (int route = 0; route < routes.size(); route++) { + List points = routes.get(route).points(); + if (points.isEmpty()) continue; + for (int endIndex : new int[]{0, points.size() - 1}) { + Node end = new Node(route, endIndex); + Vec position = points.get(endIndex).position(); + for (int other = 0; other < routes.size(); other++) { + if (other == route) continue; + Node nearest = nearest(routes.get(other), other, position, distance); + if (nearest == null) continue; + link(links, end, nearest); + link(links, nearest, end); + } + } + } + Map> frozen = new HashMap<>(); + links.forEach((node, linked) -> frozen.put(node, List.copyOf(linked))); + return new CreekLinks(Map.copyOf(frozen)); + } + + /** + * The points linked to a point, always in the same order. + * + * @param node the point + * @return the linked points, empty if there are none + */ + public List of(Node node) { + return this.links.getOrDefault(node, List.of()); + } + + /** + * The point of a route closest to a position, as long as it is within the distance. + */ + private static @Nullable Node nearest(CreekRoute route, int routeIndex, Vec position, double distance) { + List points = route.points(); + Node nearest = null; + double best = distance; + for (int index = 0; index < points.size(); index++) { + double away = points.get(index).position().distance(position); + if (away <= best) { + best = away; + nearest = new Node(routeIndex, index); + } + } + return nearest; + } + + /** + * Adds a link once. Two ends close together are found from both sides. + */ + private static void link(Map> links, Node from, Node to) { + List linked = links.computeIfAbsent(from, _ -> new ArrayList<>()); + if (!linked.contains(to)) linked.add(to); + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java new file mode 100644 index 00000000..92677a61 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java @@ -0,0 +1,41 @@ +package net.onelitefeather.cygnus.common.creek; + +import net.minestom.server.coordinate.Vec; + +import java.util.List; + +/** + * A named path for the creek. The first point is the start, the last one the end. + * + * @param name the route's name, used in logs and in the debug line + * @param points the waypoints in walking order + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public record CreekRoute(String name, List points) { + + /** A route needs at least a start and an end. */ + public static final int MIN_POINTS = 2; + + /** + * Fills in what a hand-written file may leave out, so a broken route can be reported by name + * instead of breaking the whole file. + */ + @SuppressWarnings("ConstantValue") + public CreekRoute { + name = name == null ? "" : name.trim(); + points = points == null ? List.of() : List.copyOf(points); + } + + /** + * Creates a route whose points have no pauses. + * + * @param name the route's name + * @param positions where the creek's feet stand, in walking order + * @return the route + */ + public static CreekRoute ofPositions(String name, List positions) { + return new CreekRoute(name, positions.stream().map(CreekWaypoint::of).toList()); + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java new file mode 100644 index 00000000..87873824 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java @@ -0,0 +1,151 @@ +package net.onelitefeather.cygnus.common.creek; + +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; +import net.onelitefeather.cygnus.common.map.GameMap; +import net.onelitefeather.cygnus.common.util.GsonHelper; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Reads and writes the {@code creek.json} next to a map's {@code map.json}. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekRoutesFile { + + private static final Logger LOGGER = LoggerFactory.getLogger(CreekRoutesFile.class); + private static final String FILE_NAME = "creek.json"; + private static final Type ROUTES_TYPE = TypeToken.getParameterized(List.class, CreekRoute.class).getType(); + + private CreekRoutesFile() { + // Nothing to do here + } + + /** + * Returns the {@code creek.json} path that belongs to a map file. + * + * @param mapFile the map file + * @return the sibling path, whether it exists or not + */ + public static Path resolve(Path mapFile) { + return mapFile.resolveSibling(FILE_NAME); + } + + /** + * Loads the routes the game can use. Invalid routes are skipped with a warning that names them. + * + * @param mapFile the map file the routes belong to + * @return the valid routes, empty if the file does not exist + */ + public static List load(Path mapFile) { + Path file = resolve(mapFile); + return valid(read(file), file); + } + + /** + * Loads every route as written, including unfinished ones. Meant for the setup, which has to + * show routes that are still being built. + * + * @param mapFile the map file the routes belong to + * @return all routes, empty if the file does not exist + */ + public static List loadUnchecked(Path mapFile) { + return read(resolve(mapFile)); + } + + /** + * Writes the routes to the sibling {@code creek.json}. + * + * @param mapFile the map file the routes belong to + * @param routes the routes to write + */ + public static void save(Path mapFile, List routes) { + Path file = resolve(mapFile); + try { + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + try (var writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) { + GsonHelper.GSON.toJson(routes, ROUTES_TYPE, writer); + } + } catch (IOException exception) { + throw new UncheckedIOException("Failed to save creek routes to " + file, exception); + } + } + + /** + * Returns a copy of the map carrying the valid routes from the sibling {@code creek.json}. + * + * @param mapFile the map file {@code map} was loaded from + * @param map the map + * @return the map with its routes + */ + public static GameMap loadInto(Path mapFile, GameMap map) { + return map.withCreekRoutes(load(mapFile)); + } + + private static List read(Path file) { + if (!Files.exists(file)) { + return List.of(); + } + try (var reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + List routes; + try { + routes = GsonHelper.GSON.fromJson(reader, ROUTES_TYPE); + } catch (JsonSyntaxException exception) { + throw new JsonSyntaxException("Failed to parse creek routes from " + file + ": " + exception.getMessage(), exception); + } + if (routes == null) return List.of(); + return routes.stream().filter(route -> route != null).toList(); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to load creek routes from " + file, exception); + } + } + + private static List valid(List routes, Path file) { + Set names = new HashSet<>(); + List valid = new ArrayList<>(routes.size()); + for (CreekRoute route : routes) { + String problem = problemOf(route, names); + if (problem != null) { + LOGGER.warn("Skipping creek route '{}' in {}: {}", route.name(), file, problem); + continue; + } + names.add(route.name()); + valid.add(route); + } + return List.copyOf(valid); + } + + @SuppressWarnings("ConstantValue") + private static @Nullable String problemOf(CreekRoute route, Set names) { + if (route.name().isEmpty()) return "the name is empty"; + if (route.points().size() < CreekRoute.MIN_POINTS) { + return "it needs at least " + CreekRoute.MIN_POINTS + " points"; + } + // Gson leaves the position out of a hand-written point, or of one in the older flat layout. + if (route.points().stream().anyMatch(point -> point.position() == null)) { + return "a point has no position"; + } + if (route.points().stream().anyMatch(point -> point.pauseMillis() < 0)) { + return "a point has a negative pause"; + } + if (names.contains(route.name())) return "another route already uses this name"; + return null; + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java new file mode 100644 index 00000000..c418842c --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java @@ -0,0 +1,40 @@ +package net.onelitefeather.cygnus.common.creek; + +import net.minestom.server.coordinate.Vec; + +/** + * One point of a creek route: where the creek's feet stand, and how long it waits there. + *

    + * In the JSON the position is a nested object written by the position adapter, as in + * {@code {"position":{"x":…,"y":…,"z":…},"pauseMillis":…}}. A point without {@code pauseMillis} + * loads with a pause of 0. A point without a position is reported by {@link CreekRoutesFile}. + *

    + * + * @param position where the creek's feet stand + * @param pauseMillis how long the creek waits after reaching this point, in milliseconds + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public record CreekWaypoint(Vec position, int pauseMillis) { + + /** + * Creates a waypoint without a pause. + * + * @param position where the creek's feet stand + * @return the waypoint + */ + public static CreekWaypoint of(Vec position) { + return new CreekWaypoint(position, 0); + } + + /** + * Returns a copy of this waypoint with another pause. + * + * @param pauseMillis the new pause, in milliseconds + * @return the copy + */ + public CreekWaypoint withPause(int pauseMillis) { + return new CreekWaypoint(this.position, pauseMillis); + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/package-info.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/package-info.java new file mode 100644 index 00000000..b340af0a --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/package-info.java @@ -0,0 +1,7 @@ +/** + * The creek routes of a map: named paths the creek walks along, stored next to the map file. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.common.creek; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/MapAtmosphere.java b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/MapAtmosphere.java index d8343cef..9209aa7f 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/MapAtmosphere.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/MapAtmosphere.java @@ -15,7 +15,8 @@ * for every one of them. *

    *

    - * The components are declared as {@link Color} rather than {@link RGBLike} on purpose. Gson cannot + * The components are declared as {@link Color} rather than + * {@link net.kyori.adventure.util.RGBLike RGBLike} on purpose. Gson cannot * deserialize an interface without being told which implementation to pick, and a map file that * fails to parse would take the whole service down with it. *

    diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMap.java b/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMap.java index 9f6a870f..3b24a5a4 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMap.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMap.java @@ -1,5 +1,6 @@ package net.onelitefeather.cygnus.common.map; +import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.common.dimension.MapAtmosphere; import net.theevilreaper.aves.map.BaseMap; import net.minestom.server.coordinate.Pos; @@ -26,6 +27,8 @@ public final class GameMap extends BaseMap { private final transient Set pageFaces; private final Set survivorSpawns; private final @Nullable MapAtmosphere atmosphere; + // transient like pageFaces: the routes live in a sibling creek.json (see CreekRoutesFile) + private final transient List creekRoutes; /** * Creates a new instance from the {@link GameMap} with the given values. @@ -46,12 +49,38 @@ public GameMap( Set survivorSpawns, List builders, @Nullable MapAtmosphere atmosphere + ) { + this(name, spawn, slenderSpawn, pageFaces, survivorSpawns, builders, atmosphere, List.of()); + } + + /** + * Creates a map that also carries creek routes. + * + * @param name the name of the map + * @param spawn the spawn position for the players + * @param slenderSpawn the spawn position for the slender + * @param pageFaces the faces for the pages + * @param survivorSpawns the spawn positions for the survivors + * @param builders the builders for the map + * @param atmosphere the fog and sky look of the map, or {@code null} for the overworld default + * @param creekRoutes the routes the creek walks along + */ + public GameMap( + String name, + Pos spawn, + Pos slenderSpawn, + Set pageFaces, + Set survivorSpawns, + List builders, + @Nullable MapAtmosphere atmosphere, + List creekRoutes ) { super(name, spawn, builders); this.slenderSpawn = slenderSpawn; this.pageFaces = Set.copyOf(pageFaces); this.survivorSpawns = Set.copyOf(survivorSpawns); this.atmosphere = atmosphere; + this.creekRoutes = List.copyOf(creekRoutes); } /** @@ -92,4 +121,33 @@ public Set getSurvivorSpawns() { public @Nullable MapAtmosphere getAtmosphere() { return atmosphere; } + + /** + * Returns the routes the creek walks along. + * + * @return the routes, empty if the map has none + */ + public List getCreekRoutes() { + return creekRoutes; + } + + /** + * Returns a copy of this map with other page faces. + * + * @param pageFaces the new page faces + * @return the copy + */ + public GameMap withPageFaces(Set pageFaces) { + return new GameMap(name(), spawn(), slenderSpawn, pageFaces, survivorSpawns, builders(), atmosphere, creekRoutes); + } + + /** + * Returns a copy of this map with other creek routes. + * + * @param creekRoutes the new routes + * @return the copy + */ + public GameMap withCreekRoutes(List creekRoutes) { + return new GameMap(name(), spawn(), slenderSpawn, pageFaces, survivorSpawns, builders(), atmosphere, creekRoutes); + } } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMapBuilder.java b/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMapBuilder.java index f6d9ffc2..4e6d9a1d 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMapBuilder.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMapBuilder.java @@ -3,15 +3,22 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.minestom.server.utils.Direction; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import net.onelitefeather.cygnus.common.dimension.MapAtmosphere; import net.onelitefeather.cygnus.common.page.PageResource; import net.theevilreaper.aves.map.BaseMapBuilder; import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.TreeSet; +import java.util.stream.Collectors; public final class GameMapBuilder extends BaseMapBuilder { @@ -19,6 +26,7 @@ public final class GameMapBuilder extends BaseMapBuilder { private @Nullable MapAtmosphere atmosphere; private final Set pageFaces; private final Set survivorSpawns; + private final Map> creekRoutes; /** * Creates a new instance of the builder class @@ -31,6 +39,7 @@ public GameMapBuilder() { .thenComparingInt(Pos::blockY) .thenComparingInt(Pos::blockZ) ); + this.creekRoutes = new LinkedHashMap<>(); } /** @@ -49,6 +58,9 @@ public GameMapBuilder(GameMap gameMap) { this.survivorSpawns.addAll(gameMap.getSurvivorSpawns()); this.pageFaces = new HashSet<>(gameMap.getPageFaces()); this.atmosphere = gameMap.getAtmosphere(); + // Mutable on purpose: the setup adds, edits and removes routes. A name seen twice keeps the last route. + this.creekRoutes = gameMap.getCreekRoutes().stream().collect(Collectors.toMap( + CreekRoute::name, route -> new ArrayList<>(route.points()), (first, last) -> last, LinkedHashMap::new)); } /** @@ -91,7 +103,7 @@ public void setSlenderSpawn(@Nullable Pos slenderSpawn) { */ @Override public GameMap build() { - return new GameMap(name, spawn, slenderSpawn, pageFaces, survivorSpawns, builders, atmosphere); + return new GameMap(name, spawn, slenderSpawn, pageFaces, survivorSpawns, builders, atmosphere, getCreekRoutes()); } /** @@ -168,4 +180,143 @@ public void setAtmosphere(@Nullable MapAtmosphere atmosphere) { public @Nullable MapAtmosphere getAtmosphere() { return atmosphere; } + + /** + * Adds an empty creek route. + * + * @param name the route's name + * @return {@code false} if the name is empty or already taken + */ + public boolean addCreekRoute(String name) { + String trimmed = name.trim(); + if (trimmed.isEmpty() || this.creekRoutes.containsKey(trimmed)) return false; + this.creekRoutes.put(trimmed, new ArrayList<>()); + return true; + } + + /** + * Removes a creek route. + * + * @param name the route's name + * @return {@code false} if there was no such route + */ + public boolean removeCreekRoute(String name) { + return this.creekRoutes.remove(name) != null; + } + + /** + * Returns whether a creek route with this name exists. + * + * @param name the route's name + * @return {@code true} if it exists + */ + public boolean hasCreekRoute(String name) { + return this.creekRoutes.containsKey(name); + } + + /** + * Appends a point to a creek route. Once the route has an end, its pause moves to the new end. + * + * @param name the route's name + * @param point the new last point + * @return {@code false} if there was no such route + */ + public boolean addCreekPoint(String name, Vec point) { + List points = this.creekRoutes.get(name); + if (points == null) return false; + CreekWaypoint added = CreekWaypoint.of(point); + if (points.size() >= CreekRoute.MIN_POINTS) { + CreekWaypoint oldEnd = points.getLast(); + points.set(points.size() - 1, oldEnd.withPause(0)); + added = added.withPause(oldEnd.pauseMillis()); + } + points.add(added); + return true; + } + + /** + * Removes the last point of a creek route. If the route still has an end, the pause moves to it. + * + * @param name the route's name + * @return {@code false} if there was no such route or it had no points + */ + public boolean removeLastCreekPoint(String name) { + List points = this.creekRoutes.get(name); + if (points == null || points.isEmpty()) return false; + CreekWaypoint removed = points.removeLast(); + if (points.size() >= CreekRoute.MIN_POINTS) { + int lastIndex = points.size() - 1; + points.set(lastIndex, points.get(lastIndex).withPause(removed.pauseMillis())); + } + return true; + } + + /** + * Removes the point of a creek route that sits at the given position. If several do, the one + * added last goes. The pauses of the start and the end stay with the new start and end. + * + * @param name the route's name + * @param point the position of the point to remove + * @return the number of the removed point, counting from 1, or {@code -1} if there was none + */ + public int removeCreekPoint(String name, Vec point) { + List points = this.creekRoutes.get(name); + if (points == null) return -1; + int index = -1; + for (int i = points.size() - 1; i >= 0; i--) { + if (points.get(i).position().equals(point)) { + index = i; + break; + } + } + if (index < 0) return -1; + if (index == points.size() - 1) { + this.removeLastCreekPoint(name); + return index + 1; + } + CreekWaypoint removed = points.remove(index); + if (index == 0) { + points.set(0, points.getFirst().withPause(removed.pauseMillis())); + } + return index + 1; + } + + /** + * Sets how long the creek waits at the start or the end of a route. + * + * @param name the route's name + * @param atStart {@code true} for the first point, {@code false} for the last + * @param millis the pause, in milliseconds + * @return {@code false} without such a route, with a negative pause, without a point at the + * start, or with fewer than two points for the end + */ + public boolean setCreekEndPause(String name, boolean atStart, int millis) { + List points = this.creekRoutes.get(name); + if (points == null || millis < 0) return false; + if (atStart ? points.isEmpty() : points.size() < CreekRoute.MIN_POINTS) return false; + int index = atStart ? 0 : points.size() - 1; + points.set(index, points.get(index).withPause(millis)); + return true; + } + + /** + * Returns the points of a creek route. + * + * @param name the route's name + * @return the points, empty if there is no such route + */ + public List getCreekRoutePoints(String name) { + return List.copyOf(this.creekRoutes.getOrDefault(name, List.of())); + } + + /** + * Returns all creek routes in the order they were added. + * + * @return the routes + */ + public List getCreekRoutes() { + return this.creekRoutes.entrySet().stream() + .map(entry -> new CreekRoute(entry.getKey(), entry.getValue())) + .toList(); + } } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFacesFile.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFacesFile.java index 0b682d77..c33cbbb5 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFacesFile.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFacesFile.java @@ -97,14 +97,6 @@ public static GameMap loadInto(Path mapFile, GameMap map) { Set merged = new HashSet<>(map.getPageFaces()); merged.addAll(load(mapFile)); - return new GameMap( - map.name(), - map.spawn(), - map.getSlenderSpawn(), - merged, - map.getSurvivorSpawns(), - map.builders(), - map.getAtmosphere() - ); + return map.withPageFaces(merged); } } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java index 107a82a1..7cbc95a0 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java @@ -7,7 +7,7 @@ /** * The factory should be used to create references from a {@link PageEntity}. * It's not possible to create a new instance from the entity class directly because the constructor of it is only visible to the package level. - * @see PageEntity#PageEntity(Instance, Pos, int) + * @see PageEntity#PageEntity(PageResource, int) * @author theEvilReaper * @since 1.0.0 * @version 1.0.0 diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java index 249fc99d..e9308ccb 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java @@ -209,6 +209,15 @@ public Component getPageStatus() { .append(Component.text(this.maxPageAmount, NamedTextColor.RED)); } + /** + * Returns the number of pages found in this round. + * + * @return the number of found pages, never negative + */ + public int foundPageCount() { + return this.foundPages.get(); + } + /** * Returns the max page amount. * diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java index 0d409333..df38817e 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java @@ -11,8 +11,9 @@ * good together, but picking colors as variations on one hue almost always * does. HSL lets us say "same hue, just a bit darker" or "same hue, more * washed out" instead of guessing at RGB numbers by hand. That's exactly what - * {@link SeededDimensionPreset} relies on to turn a single random seed into a - * fog/sky/light color set that actually feels like it belongs together. + * {@link net.onelitefeather.cygnus.common.dimension.SeedDimensionPreset SeedDimensionPreset} + * relies on to turn a single random seed into a fog/sky/light color set that + * actually feels like it belongs together. *

    * If you're not familiar with HSL: hue is the color itself (0 = red, 120 = * green, 240 = blue, and it loops back around at 360), saturation is how diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java new file mode 100644 index 00000000..4aeee8b4 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java @@ -0,0 +1,123 @@ +package net.onelitefeather.cygnus.common.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekConfigTest { + + @Test + @DisplayName("The defaults are the values from the design") + void defaultsMatchTheDesign() { + CreekConfig config = CreekConfig.DEFAULT; + + assertTrue(config.enabled()); + assertTrue(config.activeWithLastSurvivor()); + assertEquals(48, config.sightRange()); + assertEquals(0.25D, config.stalkThreshold()); + assertEquals(0.6D, config.huntThreshold()); + assertEquals(20, config.stalkMinDistance()); + assertEquals(35, config.stalkMaxDistance()); + assertEquals(40, config.stalkMinAngle()); + assertEquals(70, config.stalkMaxAngle()); + assertEquals(1.5D, config.catchDistance()); + assertEquals(15, config.personalSpace()); + assertEquals(2, config.betrayalCatchCount()); + assertEquals(0.15D, config.betrayalChance()); + assertEquals(3.0D, config.routeLinkDistance()); + assertEquals(0.15D, config.randomStopChance()); + assertEquals(1500, config.randomStopMinMillis()); + assertEquals(4000, config.randomStopMaxMillis()); + } + + @Test + @DisplayName("The link distance has to be above 0") + void linkDistanceAboveZero() { + CreekConfig config = CreekConfig.DEFAULT; + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new CreekConfig( + config.enabled(), config.activeWithLastSurvivor(), config.sightRange(), config.sightViewAngle(), + config.wanderPauseMillis(), config.wanderSpeed(), config.huntSpeed(), config.stalkThreshold(), config.huntThreshold(), + config.stalkMinDistance(), config.stalkMaxDistance(), config.stalkMinAngle(), config.stalkMaxAngle(), + config.stalkRevealMillis(), config.stalkMinSeconds(), config.stalkMaxSeconds(), config.huntMaxSeconds(), + config.catchDistance(), config.vanishMinSeconds(), config.vanishMaxSeconds(), config.respawnMinDistance(), + config.personalSpace(), config.stuckMillis(), config.dreadPageWeight(), config.dreadTimeWeight(), + config.dreadIsolationWeight(), config.isolationRadius(), config.betrayalCatchCount(), + config.betrayalChance(), config.betrayalGlowSeconds(), config.slownessSeconds(), 0.0D, + config.randomStopChance(), config.randomStopMinMillis(), config.randomStopMaxMillis())); + assertTrue(exception.getMessage().contains("routeLinkDistance")); + } + + @Test + @DisplayName("The stalk threshold has to stay below the hunt threshold") + void stalkThresholdBelowHunt() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> with(0.6D, 0.6D, 35, 40, 15)); + assertTrue(exception.getMessage().contains("huntThreshold")); + } + + @Test + @DisplayName("A hiding place inside the cone is rejected") + void hidingPlaceOutsideTheCone() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> with(0.25D, 0.6D, 40, 40, 15)); + assertTrue(exception.getMessage().contains("stalkMinAngle")); + } + + @Test + @DisplayName("The personal space has to fit inside the stalk band") + void personalSpaceInsideTheBand() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> with(0.25D, 0.6D, 35, 40, 20)); + assertTrue(exception.getMessage().contains("stalkMinDistance")); + } + + private static CreekConfig with(double stalkThreshold, double huntThreshold, int sightViewAngle, + int stalkMinAngle, int personalSpace) { + CreekConfig d = CreekConfig.DEFAULT; + return new CreekConfig( + d.enabled(), d.activeWithLastSurvivor(), d.sightRange(), sightViewAngle, + d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), stalkThreshold, huntThreshold, + d.stalkMinDistance(), d.stalkMaxDistance(), stalkMinAngle, d.stalkMaxAngle(), + d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(), + d.catchDistance(), d.vanishMinSeconds(), d.vanishMaxSeconds(), d.respawnMinDistance(), + personalSpace, d.stuckMillis(), d.dreadPageWeight(), d.dreadTimeWeight(), + d.dreadIsolationWeight(), d.isolationRadius(), d.betrayalCatchCount(), + d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(), + d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); + } + + @Test + @DisplayName("Hunting, he is faster than a walking survivor") + void huntIsFasterThanWalking() { + // A walking player covers about 0.216 blocks per tick; the speed attribute is blocks per tick. + assertTrue(CreekConfig.DEFAULT.huntSpeed() > 0.216D); + } + + private static CreekConfig withRandomStops(double chance, int minMillis, int maxMillis) { + CreekConfig d = CreekConfig.DEFAULT; + return new CreekConfig( + d.enabled(), d.activeWithLastSurvivor(), d.sightRange(), d.sightViewAngle(), + d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), d.stalkThreshold(), d.huntThreshold(), + d.stalkMinDistance(), d.stalkMaxDistance(), d.stalkMinAngle(), d.stalkMaxAngle(), + d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(), + d.catchDistance(), d.vanishMinSeconds(), d.vanishMaxSeconds(), d.respawnMinDistance(), + d.personalSpace(), d.stuckMillis(), d.dreadPageWeight(), d.dreadTimeWeight(), + d.dreadIsolationWeight(), d.isolationRadius(), d.betrayalCatchCount(), + d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(), + chance, minMillis, maxMillis); + } + + @Test + @DisplayName("Random stops need a chance between 0 and 1 and a range that does not run backwards") + void randomStopsAreChecked() { + assertThrows(IllegalArgumentException.class, () -> withRandomStops(1.5D, 1500, 4000)); + assertThrows(IllegalArgumentException.class, () -> withRandomStops(0.15D, -1, 4000)); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> withRandomStops(0.15D, 5000, 4000)); + assertTrue(exception.getMessage().contains("randomStopMinMillis")); + withRandomStops(0.0D, 0, 0); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java index d8880952..4464da05 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java @@ -355,4 +355,59 @@ void testAnUnreadableVolumeFactorFallsBackToTheDefault(@TempDir Path tempDir) th assertEquals(GameConfig.PageProximity.DEFAULT_VOLUME_FACTOR, new GameConfigReader(tempDir).getConfig().pageProximity().volumeFactor()); } + + @Test + void testCreekDefaultsWhenNotConfigured() { + GameConfig config = new GameConfigReader(Paths.get("src", "test", "resources")).getConfig(); + + assertEquals(CreekConfig.DEFAULT, config.creek()); + } + + @Test + void testCreekValuesAreRead(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("config.properties"), """ + minPlayers=2 + creek.enabled=false + creek.huntThreshold=0.7 + creek.stalkMaxDistance=40 + creek.routeLinkDistance=4.5 + creek.randomStopChance=0.4 + creek.randomStopMinMillis=500 + creek.randomStopMaxMillis=900 + """); + + CreekConfig creek = new GameConfigReader(tempDir).getConfig().creek(); + + assertFalse(creek.enabled()); + assertEquals(0.7D, creek.huntThreshold(), 1.0E-9); + assertEquals(40, creek.stalkMaxDistance()); + assertEquals(CreekConfig.DEFAULT.stalkMinDistance(), creek.stalkMinDistance()); + assertEquals(4.5D, creek.routeLinkDistance(), 1.0E-9); + assertEquals(0.4D, creek.randomStopChance(), 1.0E-9); + assertEquals(500, creek.randomStopMinMillis()); + assertEquals(900, creek.randomStopMaxMillis()); + } + + @Test + void testCreekRejectsValuesThatDoNotFit(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("config.properties"), """ + minPlayers=2 + creek.enabled=false + creek.stalkThreshold=0.9 + """); + GameConfigReader reader = new GameConfigReader(tempDir); + + assertThrows(IllegalArgumentException.class, reader::getConfig); + } + + @Test + void testAnUnreadableCreekValueFallsBackToTheDefault(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("config.properties"), """ + minPlayers=2 + creek.huntThreshold=not-a-number + """); + + assertEquals(CreekConfig.DEFAULT.huntThreshold(), + new GameConfigReader(tempDir).getConfig().creek().huntThreshold()); + } } diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java index b44f1c8f..752fbbac 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java @@ -40,7 +40,7 @@ void testInvalidLobbyAtmosphereShare() { GameConfig defaults = GameConfig.DEFAULT; assertRejected("Lobby atmosphere share must be between 0 and 1", () -> new GameConfig( defaults.round(), defaults.teams(), null, defaults.resourcePack(), defaults.pageProximity(), - defaults.damageSound(), defaults.glitch(), defaults.slenderStatic(), 1.5F)); + defaults.damageSound(), defaults.glitch(), defaults.slenderStatic(), defaults.creek(), 1.5F)); } @Test diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java new file mode 100644 index 00000000..7f3e6203 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java @@ -0,0 +1,77 @@ +package net.onelitefeather.cygnus.common.creek; + +import net.minestom.server.coordinate.Vec; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekLinksTest { + + private static CreekRoute route(String name, Vec... points) { + return CreekRoute.ofPositions(name, List.of(points)); + } + + /** A ends at (10,0,0); B starts 2 blocks away, C exactly 3, D 3.1. */ + private static final List ROUTES = List.of( + route("A", new Vec(0, 0, 0), new Vec(10, 0, 0)), + route("B", new Vec(12, 0, 0), new Vec(20, 0, 0)), + route("C", new Vec(10, 0, 3), new Vec(10, 0, 20)), + route("D", new Vec(10, 0, -3.1), new Vec(10, 0, -20)) + ); + + @Test + @DisplayName("Ends within the link distance are linked, the one just outside is not") + void linksFollowTheDistance() { + CreekLinks links = CreekLinks.of(ROUTES, 3.0D); + + assertEquals(List.of(new CreekLinks.Node(1, 0), new CreekLinks.Node(2, 0)), + links.of(new CreekLinks.Node(0, 1))); + assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0))); + assertTrue(links.of(new CreekLinks.Node(3, 0)).isEmpty()); + } + + @Test + @DisplayName("An end on the middle of another route is linked to that point, both ways") + void endOnAMiddleIsLinked() { + CreekLinks links = CreekLinks.of(List.of( + route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)), + route("Y", new Vec(10, 0, 2), new Vec(10, 0, 20))), 3.0D); + + assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0))); + assertEquals(List.of(new CreekLinks.Node(1, 0)), links.of(new CreekLinks.Node(0, 1))); + } + + @Test + @DisplayName("Two middles close together are not linked") + void middlesNeverLink() { + CreekLinks links = CreekLinks.of(List.of( + route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)), + route("Z", new Vec(-5, 0, 1), new Vec(10, 0, 1), new Vec(25, 0, 1))), 3.0D); + + assertTrue(links.of(new CreekLinks.Node(0, 1)).isEmpty()); + assertTrue(links.of(new CreekLinks.Node(1, 1)).isEmpty()); + } + + @Test + @DisplayName("An end links to the nearest point of another route only") + void onlyTheNearestPoint() { + CreekLinks links = CreekLinks.of(List.of( + route("X", new Vec(-10, 0, 0), new Vec(1, 0, 0), new Vec(2, 0, 0), new Vec(20, 0, 0)), + route("W", new Vec(1, 0, 1), new Vec(1, 0, 20))), 3.0D); + + assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0))); + assertTrue(links.of(new CreekLinks.Node(0, 2)).isEmpty(), "the second closest point stays unlinked"); + } + + @Test + @DisplayName("The two ends of one route never link to each other") + void ownEndsNeverLink() { + CreekLinks links = CreekLinks.of(List.of(route("Short", new Vec(0, 0, 0), new Vec(1, 0, 0))), 3.0D); + + assertTrue(links.of(new CreekLinks.Node(0, 0)).isEmpty()); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java new file mode 100644 index 00000000..393f91fa --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java @@ -0,0 +1,179 @@ +package net.onelitefeather.cygnus.common.creek; + +import com.google.gson.JsonSyntaxException; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.map.GameMap; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekRoutesFileTest { + + private static final CreekRoute NORTH = CreekRoute.ofPositions("Waldweg Nord", + List.of(new Vec(12.5, 80, 4.5), new Vec(20.5, 80, 9.5), new Vec(31.5, 81, 15.5))); + private static final CreekRoute SOUTH = CreekRoute.ofPositions("Waldweg Süd", + List.of(new Vec(0.5, 80, 0.5), new Vec(0.5, 80, 10.5))); + + private static Path mapFile(Path root) { + return root.resolve("map.json"); + } + + @Test + @DisplayName("The file sits next to map.json") + void resolvesToASiblingCreekJson(@TempDir Path root) { + assertEquals(root.resolve("creek.json"), CreekRoutesFile.resolve(mapFile(root))); + } + + @Test + @DisplayName("Without a file there are no routes") + void missingFileMeansNoRoutes(@TempDir Path root) { + assertTrue(CreekRoutesFile.load(mapFile(root)).isEmpty()); + } + + @Test + @DisplayName("Broken JSON names the file") + void brokenJsonNamesTheFile(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), "[ { \"name\": ", StandardCharsets.UTF_8); + + JsonSyntaxException exception = assertThrows(JsonSyntaxException.class, () -> CreekRoutesFile.load(mapFile(root))); + assertTrue(exception.getMessage().contains("creek.json")); + } + + @Test + @DisplayName("Saved routes come back in the same order") + void savedRoutesCanBeLoadedBack(@TempDir Path root) { + CreekRoutesFile.save(mapFile(root), List.of(NORTH, SOUTH)); + + assertEquals(List.of(NORTH, SOUTH), CreekRoutesFile.load(mapFile(root))); + } + + @Test + @DisplayName("Routes without a name or with too few points are skipped") + void invalidRoutesAreSkipped(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), """ + [ + { "name": " ", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } }, { "position": { "x": 5, "y": 80, "z": 0 } } ] }, + { "name": "Stub", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } } ] }, + { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } + ] + """, StandardCharsets.UTF_8); + + assertEquals(List.of(SOUTH), CreekRoutesFile.load(mapFile(root))); + } + + @Test + @DisplayName("Only invalid routes leave nothing to walk") + void onlyInvalidRoutesMeansNoRoutes(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), """ + [ { "name": "Stub", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } } ] } ] + """, StandardCharsets.UTF_8); + + assertTrue(CreekRoutesFile.load(mapFile(root)).isEmpty()); + } + + @Test + @DisplayName("Of two routes with the same name only the first one stays") + void duplicateNamesKeepTheFirst(@TempDir Path root) { + CreekRoute copy = new CreekRoute("Waldweg Nord", SOUTH.points()); + CreekRoutesFile.save(mapFile(root), List.of(NORTH, copy)); + + assertEquals(List.of(NORTH), CreekRoutesFile.load(mapFile(root))); + } + + @Test + @DisplayName("The setup still gets routes that are not finished yet") + void uncheckedLoadKeepsIncompleteRoutes(@TempDir Path root) { + CreekRoute stub = CreekRoute.ofPositions("Stub", List.of(new Vec(0, 80, 0))); + CreekRoutesFile.save(mapFile(root), List.of(stub)); + + assertEquals(List.of(stub), CreekRoutesFile.loadUnchecked(mapFile(root))); + } + + @Test + @DisplayName("Names are trimmed") + void namesAreTrimmed() { + assertEquals("Waldweg", new CreekRoute(" Waldweg ", SOUTH.points()).name()); + } + + @Test + @DisplayName("loadInto puts the routes into the map") + void loadIntoSetsTheRoutes(@TempDir Path root) { + CreekRoutesFile.save(mapFile(root), List.of(NORTH)); + GameMap map = new GameMap("Forest", Pos.ZERO, Pos.ZERO, Set.of(), Set.of(), List.of(), null); + + assertEquals(List.of(NORTH), CreekRoutesFile.loadInto(mapFile(root), map).getCreekRoutes()); + } + + @Test + @DisplayName("A point without a pause loads with no pause") + void oldFileHasNoPauses(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), """ + [ { "name": "Alt", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } ] + """, StandardCharsets.UTF_8); + + List routes = CreekRoutesFile.load(mapFile(root)); + + assertEquals(1, routes.size()); + assertTrue(routes.getFirst().points().stream().allMatch(point -> point.pauseMillis() == 0)); + } + + @Test + @DisplayName("A route with a point from before positions were nested is skipped, not a crash") + void flatPointIsSkipped(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), """ + [ + { "name": "Flach", "points": [ { "x": 0.5, "y": 80, "z": 0.5 }, { "x": 0.5, "y": 80, "z": 10.5 } ] }, + { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } + ] + """, StandardCharsets.UTF_8); + + assertEquals(List.of(SOUTH), CreekRoutesFile.load(mapFile(root))); + } + + @Test + @DisplayName("Pauses survive saving and loading") + void pausesAreKept(@TempDir Path root) { + CreekRoute route = new CreekRoute("Pausen", List.of( + CreekWaypoint.of(new Vec(0.5, 80, 0.5)).withPause(2000), + CreekWaypoint.of(new Vec(5.5, 80, 0.5)), + CreekWaypoint.of(new Vec(9.5, 80, 0.5)).withPause(4000))); + CreekRoutesFile.save(mapFile(root), List.of(route)); + + assertEquals(List.of(route), CreekRoutesFile.load(mapFile(root))); + } + + @Test + @DisplayName("A route with a negative pause is skipped") + void negativePauseIsSkipped(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), """ + [ + { "name": "Kaputt", "points": [ { "position": { "x": 0, "y": 80, "z": 0 }, "pauseMillis": -5 }, { "position": { "x": 5, "y": 80, "z": 0 } } ] }, + { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } + ] + """, StandardCharsets.UTF_8); + + assertEquals(List.of(SOUTH), CreekRoutesFile.load(mapFile(root))); + } + + @Test + @DisplayName("A waypoint knows its position") + void waypointPosition() { + CreekWaypoint point = CreekWaypoint.of(new Vec(1.5, 80, -2.5)); + + assertEquals(new Vec(1.5, 80, -2.5), point.position()); + assertEquals(0, point.pauseMillis()); + assertEquals(3000, point.withPause(3000).pauseMillis()); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java new file mode 100644 index 00000000..d9cd309f --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java @@ -0,0 +1,200 @@ +package net.onelitefeather.cygnus.common.map; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.utils.Direction; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; +import net.onelitefeather.cygnus.common.page.PageFacesFile; +import net.onelitefeather.cygnus.common.page.PageResource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GameMapCreekRoutesTest { + + private static final CreekRoute ROUTE = CreekRoute.ofPositions("Waldweg", List.of(new Vec(0, 80, 0), new Vec(5, 80, 0))); + + private static GameMap map() { + return new GameMap("Forest", Pos.ZERO, Pos.ZERO, Set.of(), Set.of(), List.of(), null); + } + + @Test + @DisplayName("A map built the old way has no routes") + void oldConstructorHasNoRoutes() { + assertTrue(map().getCreekRoutes().isEmpty()); + } + + @Test + @DisplayName("Adding routes keeps everything else") + void withCreekRoutesKeepsTheRest() { + GameMap map = map().withCreekRoutes(List.of(ROUTE)); + + assertEquals(List.of(ROUTE), map.getCreekRoutes()); + assertEquals("Forest", map.name()); + } + + @Test + @DisplayName("The builder manages routes and their points") + void builderManagesRoutes() { + GameMapBuilder builder = new GameMapBuilder(); + + assertTrue(builder.addCreekRoute("Waldweg")); + assertFalse(builder.addCreekRoute("Waldweg"), "names are unique"); + assertFalse(builder.addCreekRoute(" "), "a name must not be empty"); + assertFalse(builder.addCreekPoint("Unknown", Vec.ZERO)); + assertTrue(builder.addCreekPoint("Waldweg", new Vec(0, 80, 0))); + assertTrue(builder.addCreekPoint("Waldweg", new Vec(5, 80, 0))); + assertTrue(builder.addCreekPoint("Waldweg", new Vec(9, 80, 0))); + assertTrue(builder.removeLastCreekPoint("Waldweg")); + + assertEquals(List.of(ROUTE), builder.build().getCreekRoutes()); + assertEquals(2, builder.getCreekRoutePoints("Waldweg").size()); + + assertTrue(builder.removeCreekRoute("Waldweg")); + assertFalse(builder.hasCreekRoute("Waldweg")); + } + + @Test + @DisplayName("A builder made from a map copies its routes") + void builderCopiesRoutes() { + GameMapBuilder builder = new GameMapBuilder(map().withCreekRoutes(List.of(ROUTE))); + + assertEquals(List.of(ROUTE), builder.getCreekRoutes()); + } + + @Test + @DisplayName("Merging page faces keeps the routes") + void pageMergeKeepsRoutes(@TempDir Path root) { + GameMap map = map().withCreekRoutes(List.of(ROUTE)); + PageFacesFile.save(root.resolve("map.json"), Set.of(new PageResource(Vec.ZERO, Direction.NORTH))); + + GameMap merged = PageFacesFile.loadInto(root.resolve("map.json"), map); + + assertEquals(List.of(ROUTE), merged.getCreekRoutes()); + } + + @Test + @DisplayName("Pauses can be set on the start and the end") + void setsEndPauses() { + GameMapBuilder builder = new GameMapBuilder(); + builder.addCreekRoute("Waldweg"); + + assertFalse(builder.setCreekEndPause("Waldweg", true, 1000), "no point yet"); + builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)); + assertTrue(builder.setCreekEndPause("Waldweg", true, 1000)); + assertFalse(builder.setCreekEndPause("Waldweg", false, 2000), "the end needs two points"); + builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)); + assertTrue(builder.setCreekEndPause("Waldweg", false, 2000)); + assertFalse(builder.setCreekEndPause("Waldweg", false, -1), "no negative pauses"); + assertFalse(builder.setCreekEndPause("Unknown", true, 1000)); + + List points = builder.getCreekRoutePoints("Waldweg"); + assertEquals(1000, points.getFirst().pauseMillis()); + assertEquals(2000, points.getLast().pauseMillis()); + } + + @Test + @DisplayName("The end pause moves with the end") + void endPauseMovesWithTheEnd() { + GameMapBuilder builder = new GameMapBuilder(); + builder.addCreekRoute("Waldweg"); + builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)); + builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)); + builder.setCreekEndPause("Waldweg", false, 2000); + + builder.addCreekPoint("Waldweg", new Vec(9, 80, 0)); + List grown = builder.getCreekRoutePoints("Waldweg"); + assertEquals(List.of(0, 0, 2000), grown.stream().map(CreekWaypoint::pauseMillis).toList()); + + builder.removeLastCreekPoint("Waldweg"); + List shrunk = builder.getCreekRoutePoints("Waldweg"); + assertEquals(List.of(0, 2000), shrunk.stream().map(CreekWaypoint::pauseMillis).toList()); + } + + @Test + @DisplayName("With one point left the start keeps its own pause") + void startKeepsItsPause() { + GameMapBuilder builder = new GameMapBuilder(); + builder.addCreekRoute("Waldweg"); + builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)); + builder.setCreekEndPause("Waldweg", true, 1000); + builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)); + builder.setCreekEndPause("Waldweg", false, 2000); + + builder.removeLastCreekPoint("Waldweg"); + + assertEquals(List.of(1000), builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::pauseMillis).toList()); + } + + /** A route from (0,80,0) over (5,80,0) to (9,80,0), paused 1000 ms at the start and 2000 ms at the end. */ + private static GameMapBuilder threePointRoute() { + GameMapBuilder builder = new GameMapBuilder(); + builder.addCreekRoute("Waldweg"); + builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)); + builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)); + builder.addCreekPoint("Waldweg", new Vec(9, 80, 0)); + builder.setCreekEndPause("Waldweg", true, 1000); + builder.setCreekEndPause("Waldweg", false, 2000); + return builder; + } + + private static List positions(GameMapBuilder builder) { + return builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::position).toList(); + } + + private static List pauses(GameMapBuilder builder) { + return builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::pauseMillis).toList(); + } + + @Test + @DisplayName("A point in the middle can be removed") + void removesAMiddlePoint() { + GameMapBuilder builder = threePointRoute(); + + assertEquals(2, builder.removeCreekPoint("Waldweg", new Vec(5, 80, 0))); + + assertEquals(List.of(new Vec(0, 80, 0), new Vec(9, 80, 0)), positions(builder)); + assertEquals(List.of(1000, 2000), pauses(builder)); + } + + @Test + @DisplayName("Removing the first point hands its pause to the new first point") + void removesTheFirstPoint() { + GameMapBuilder builder = threePointRoute(); + + assertEquals(1, builder.removeCreekPoint("Waldweg", new Vec(0, 80, 0))); + + assertEquals(List.of(new Vec(5, 80, 0), new Vec(9, 80, 0)), positions(builder)); + assertEquals(List.of(1000, 2000), pauses(builder)); + } + + @Test + @DisplayName("Removing the last point hands its pause to the new last point") + void removesTheLastPointByPosition() { + GameMapBuilder builder = threePointRoute(); + + assertEquals(3, builder.removeCreekPoint("Waldweg", new Vec(9, 80, 0))); + + assertEquals(List.of(new Vec(0, 80, 0), new Vec(5, 80, 0)), positions(builder)); + assertEquals(List.of(1000, 2000), pauses(builder)); + } + + @Test + @DisplayName("Without a point at that spot nothing is removed") + void nothingToRemove() { + GameMapBuilder builder = threePointRoute(); + + assertEquals(-1, builder.removeCreekPoint("Waldweg", new Vec(3, 80, 0))); + assertEquals(-1, builder.removeCreekPoint("Unknown", new Vec(0, 80, 0))); + assertEquals(3, positions(builder).size()); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java index b582126b..2a660fac 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java @@ -130,4 +130,18 @@ void testGameMapCopyWithEmptyCollections() { assertNotNull(builder.getPageFaces()); assertTrue(builder.getPageFaces().isEmpty()); } + + @Test + void testWithPageFacesKeepsTheRest() { + GameMap map = new GameMap("Forest", Pos.ZERO, new Pos(1, 2, 3), Set.of(), Set.of(new Pos(4, 5, 6)), List.of("Builder"), null); + PageResource page = new PageResource(Vec.ZERO, Direction.NORTH); + + GameMap copy = map.withPageFaces(Set.of(page)); + + assertEquals(Set.of(page), copy.getPageFaces()); + assertEquals("Forest", copy.name()); + assertEquals(new Pos(1, 2, 3), copy.getSlenderSpawn()); + assertEquals(Set.of(new Pos(4, 5, 6)), copy.getSurvivorSpawns()); + assertEquals(List.of("Builder"), copy.builders()); + } } diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java index 39bc60d8..7d3fcb42 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java @@ -359,4 +359,18 @@ private static void runConcurrently(List tasks) throws Exception { executor.shutdown(); } } + + @Test + void testFoundPageCount(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + PageProvider pageProvider = spawnedProvider(instance, MIN_ACTIVE_PAGE_COUNT + 1); + Player player = env.createPlayer(instance); + + assertEquals(0, pageProvider.foundPageCount()); + + pageProvider.triggerPageFound(player, pageProvider.interactablePages().getFirst().getHitBoxUUID()); + + assertEquals(1, pageProvider.foundPageCount()); + env.destroyInstance(instance, true); + } } diff --git a/config.properties.example b/config.properties.example index 7ff986a8..4344fdbf 100644 --- a/config.properties.example +++ b/config.properties.example @@ -196,3 +196,35 @@ survivorTeamSize=12 # A map that declares no atmosphere has nothing to prepare anyone for; its lobby # stays on the overworld regardless of this value. #lobbyAtmosphereShare=0.3 + +# --- The creek -------------------------------------------------------------- +# A second figure next to the slender, walked by the server. He wanders the map, +# stalks a single survivor late in the round, hunts them once the dread is high +# enough and punishes a catch - rarely by giving the survivor away to the slender. +# The creek only takes part on maps whose creek.json holds at least one usable +# route; everywhere else it stays away, even with creek.enabled=true. +# Every key below maps to a field of CreekConfig. An unreadable value falls back +# to its default; values that contradict each other are rejected. +#creek.enabled=true +#creek.activeWithLastSurvivor=true +#creek.stalkThreshold=0.25 +#creek.huntThreshold=0.6 +#creek.stalkMinDistance=20 +#creek.stalkMaxDistance=35 +#creek.catchDistance=1.5 +# Speeds are in blocks per tick. A walking survivor covers about 0.216, so the +# hunt speed has to stay above that or a hunted survivor simply walks away. +#creek.wanderSpeed=0.07 +#creek.huntSpeed=0.25 +#creek.betrayalCatchCount=2 +#creek.betrayalChance=0.15 +# How close the ends of two routes in creek.json have to be (in blocks) for the +# creek to switch from one route to the other. +#creek.routeLinkDistance=3.0 +# After reaching a waypoint the creek sometimes stops for a moment. The chance is +# between 0 and 1 (0 turns it off); the stop lasts between min and max milliseconds. +# A pause set on the route end in creek.json and a random stop do not add up: the +# longer one wins. +#creek.randomStopChance=0.15 +#creek.randomStopMinMillis=1500 +#creek.randomStopMaxMillis=4000 diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index eaddf023..69a2eef0 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -46,13 +46,17 @@ import net.onelitefeather.cygnus.damage.DamageSoundService; import net.onelitefeather.cygnus.noise.SlenderStaticService; import net.onelitefeather.cygnus.command.GlitchCommand; +import net.onelitefeather.cygnus.command.CreekCommand; import net.onelitefeather.cygnus.command.StartCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; import net.onelitefeather.cygnus.common.config.GameConfig; import net.onelitefeather.cygnus.common.config.GameConfigReader; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.common.event.GamePreLaunchEvent; import net.onelitefeather.cygnus.common.page.PageProvider; +import net.onelitefeather.cygnus.common.map.GameMap; import net.onelitefeather.cygnus.common.page.event.PageExpiredEvent; import net.onelitefeather.cygnus.disclaimer.EpilepsyDisclaimer; import net.onelitefeather.cygnus.event.GameFinishEvent; @@ -88,6 +92,14 @@ import net.onelitefeather.cygnus.player.CygnusPlayer; import net.onelitefeather.cygnus.resourcepack.ResourcePackService; import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; +import net.onelitefeather.cygnus.creek.consequence.CatchEffects; +import net.onelitefeather.cygnus.creek.body.CreakingBody; +import net.onelitefeather.cygnus.creek.consequence.GlowReveal; +import net.onelitefeather.cygnus.creek.dread.PageProgressDread; +import net.onelitefeather.cygnus.creek.RoundClock; +import net.onelitefeather.cygnus.creek.consequence.StagedCatchConsequence; +import net.onelitefeather.cygnus.creek.debug.CreekDebug; +import net.onelitefeather.cygnus.creek.CreekService; import net.onelitefeather.cygnus.stamina.StaminaService; import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer; import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; @@ -101,6 +113,8 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; +import java.util.List; +import java.util.Random; import java.util.function.Supplier; /** @@ -133,6 +147,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final TunnelVisionRenderer tunnelVisionRenderer; private final TunnelVisionService tunnelVisionService; private final SlenderStaticService slenderStaticService; + private final CreekService creekService; + private final CreekDebug creekDebug; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -192,6 +208,34 @@ public Cygnus() { () -> TeamHelper.slenderOf(this.teamService)); this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay); this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, player -> StaminaHelper.remainingShare(this.staminaService, player)); + CreekConfig creekConfig = this.gameConfig.creek(); + this.creekDebug = new CreekDebug(); + // One clock for the service and the dread: the service starts it, the dread reads it. + RoundClock roundClock = new RoundClock(System::currentTimeMillis); + // Every step and every catch runs on the scheduler thread, but Random is thread-safe + // anyway, which keeps a stray call from elsewhere harmless. + Random creekRandom = new Random(); + this.creekService = new CreekService( + creekConfig, + () -> TeamHelper.survivorsOf(this.teamService), + this.mapProvider.getActiveInstance(), + this::creekRoutes, + CreakingBody::spawn, + new PageProgressDread( + this.pageProvider::foundPageCount, + this.pageProvider::getMaxPageAmount, + roundClock::elapsedMillis, + this.gameConfig.round().gameTime(), + creekConfig), + new StagedCatchConsequence( + new CatchEffects(this.jumpscareManager::force, this.staminaService::getFoodBar, creekConfig.slownessSeconds()), + new GlowReveal(creekConfig.betrayalGlowSeconds()), + () -> TeamHelper.slenderOf(this.teamService), + creekConfig, + creekRandom), + roundClock, + creekRandom, + this.creekDebug); this.initPhases(); this.initCommands(); this.initListener(); @@ -203,6 +247,7 @@ private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); manager.register(new GlitchCommand(this.gazeSignal)); + manager.register(new CreekCommand(this.creekDebug)); } @@ -271,6 +316,11 @@ private void registerGameListener() { // Outside registerOverlayListeners for the same reason as the damage sound: the static is // heard, not drawn, so neither the resource pack nor the overlay gate has a say in it. this.slenderStaticService.registerListener(handler); + // Outside registerOverlayListeners too: the creek is an entity in the world that the + // client draws like any other, not a camera overlay. + if (this.gameConfig.creek().enabled()) { + this.creekService.registerListener(handler); + } this.registerOverlayListeners(handler); } @@ -313,6 +363,16 @@ private void initPhases() { this.linearPhaseSeries.add(new RestartPhase()); } + /** + * Returns the creek routes of the current map. + * + * @return the routes, empty without a map or without routes + */ + private List creekRoutes() { + GameMap map = this.mapProvider.getGameMap(); + return map != null ? map.getCreekRoutes() : List.of(); + } + private void finishGame() { this.pageProvider.cleanUp(); this.staminaService.cleanUp(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CreekCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/CreekCommand.java new file mode 100644 index 00000000..c25b1fe6 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/CreekCommand.java @@ -0,0 +1,59 @@ +package net.onelitefeather.cygnus.command; + +import net.kyori.adventure.permission.PermissionChecker; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.util.TriState; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.creek.debug.CreekDebug; + +/** + * Turns the creek's debug line in the action bar on or off. Meant for playtests. + * + *

    Requires {@value #PERMISSION}, like {@code GlitchCommand}: the line shows where the creek is + * and who it is after, which normal players must not know. Only players can use it, because the + * console has no action bar.

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekCommand extends Command { + + private static final String PERMISSION = "cygnus.command.creek"; + + /** + * Creates the command. + * + * @param debug the debug line to toggle for the sender + */ + public CreekCommand(CreekDebug debug) { + super("creek"); + setCondition((sender, commandString) -> sender instanceof Player); + + setDefaultExecutor((sender, context) -> { + if (!(sender instanceof Player player)) return; + + boolean watching = debug.toggle(player.getUuid()); + sender.sendMessage(Messages.withPrefix(Component.text( + watching ? "Creek debug on." : "Creek debug off.", NamedTextColor.GRAY))); + if (watching && !debug.isActive()) { + player.sendActionBar(CreekDebug.INACTIVE); + } + }); + } + + /** + * Checks whether the sender may use this command. Works like {@code GlitchCommand}. + * + * @param sender the sender to check + * @return {@code true} if the sender holds {@value #PERMISSION}, {@code false} otherwise + */ + private static boolean hasCreekPermission(CommandSender sender) { + return sender.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE)) + .test(PERMISSION); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java new file mode 100644 index 00000000..dd0bde86 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -0,0 +1,196 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.state.CreekActions; +import net.onelitefeather.cygnus.creek.state.CreekContext; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.state.VanishState; +import net.onelitefeather.cygnus.creek.world.RouteProvider; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * One creek in the round: its body, what it is doing right now, and the step that moves it on. + *

    + * The patrolling creek is one of these, and so is every variant. Each step it fills in who can see + * it in the shared snapshot of the survivors and lets its current state decide what happens next. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class Creek { + + private final CreekBody body; + private final RouteProvider route; + private final CreekRound round; + private CreekState state; + private List lastViews = List.of(); + private boolean entered; + + /** + * Sets up a creek. + * + * @param body its body in the world + * @param route its own walker along the routes + * @param round what it shares with every other creek of the round + * @param initial the state it starts in + */ + Creek(CreekBody body, RouteProvider route, CreekRound round, CreekState initial) { + this.body = body; + this.route = route; + this.round = round; + this.state = initial; + } + + /** + * Runs one step. + * + * @param survivors the survivors of this step + * @param now the current time in milliseconds + */ + void tick(SurvivorSnapshot survivors, long now) { + this.tick(survivors, Set.of(), now); + } + + /** + * Runs one step, leaving some survivors out of it. The patrolling creek uses this for everyone + * who is haunted by a variant: they do not see it, and it does not pick them out. + *

    + * The snapshots in {@link #lastViews()} still cover every survivor. + *

    + * + * @param survivors the survivors of this step + * @param ignored the survivors this creek leaves alone in this step + * @param now the current time in milliseconds + */ + void tick(SurvivorSnapshot survivors, Set ignored, long now) { + CreekContext ctx = this.context(survivors, ignored, now); + if (!this.entered) { + this.state.enter(ctx); + this.entered = true; + } + CreekState next = this.state.tick(ctx); + if (next != this.state) this.switchTo(next, ctx); + } + + /** + * Sends the creek away for the rest of the round. Calling it again changes nothing. + * + * @param survivors the survivors of this step + * @param now the current time in milliseconds + */ + void vanishForGood(SurvivorSnapshot survivors, long now) { + CreekContext ctx = this.context(survivors, Set.of(), now); + if (this.state instanceof VanishState vanish && vanish.isForever()) return; + // The state it had so far is never entered now, and never needs to be. + this.entered = true; + this.switchTo(VanishState.forever(), ctx); + } + + /** + * Fills in who sees this creek and builds the context for the states. + */ + private CreekContext context(SurvivorSnapshot survivors, Set ignored, long now) { + this.lastViews = this.views(survivors); + List noticed = ignored.isEmpty() ? this.lastViews + : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList(); + return new CreekContext(now, noticed, this.body, this.route, this.round.spots(), + new Actions(this.round, survivors, ignored, this.body.position()), + this.round.config(), this.round.random()); + } + + /** + * Tells for every survivor whether they see this creek. + *

    + * A survivor only counts as seeing the creek if it is shown to them at all. A variant is hidden + * from everyone but its target, so the others must not be able to scare it off. + *

    + * + * @param survivors the survivors of this step + * @return one view per survivor + */ + List views(SurvivorSnapshot survivors) { + List players = survivors.players(); + List base = survivors.views(); + List views = new ArrayList<>(players.size()); + for (int index = 0; index < players.size(); index++) { + Player survivor = players.get(index); + boolean sees = this.body.isVisibleTo(survivor.getUuid()) + && this.round.sight().sees(survivor, this.body.entity()); + views.add(base.get(index).withSeesCreek(sees)); + } + return views; + } + + /** + * The survivor snapshots from the last step. + * + * @return the views, empty before the first step + */ + List lastViews() { + return this.lastViews; + } + + CreekState state() { + return this.state; + } + + CreekBody body() { + return this.body; + } + + RouteProvider route() { + return this.route; + } + + void remove() { + this.body.remove(); + } + + private void switchTo(CreekState next, CreekContext ctx) { + this.state = next; + next.enter(ctx); + } + + /** + * Applies the consequences to the survivors of one step. Anyone who is no longer among them is + * left alone, and so is everyone this creek ignores in that step. + * + * @param round what the creek shares with the others of the round + * @param survivors the survivors of the step + * @param ignored the survivors the creek leaves alone in the step + * @param creek where the creek stands in the step + */ + private record Actions(CreekRound round, SurvivorSnapshot survivors, Set ignored, Pos creek) + implements CreekActions { + + @Override + public void caught(UUID survivor) { + Player player = this.survivors.player(survivor); + if (player != null) this.round.consequence().apply(player); + } + + @Override + public void selected(UUID survivor) { + Player player = this.survivors.player(survivor); + if (player != null) this.round.patrol().selected(player, this.creek, this.survivors.players()); + } + + @Override + public void vanished(Pos where) { + // Whoever this creek leaves alone does not see it, so they do not see it vanish either. + List noticing = this.survivors.players().stream() + .filter(player -> !this.ignored.contains(player.getUuid())) + .toList(); + this.round.patrol().vanished(where, noticing); + } + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java new file mode 100644 index 00000000..7f1105b5 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java @@ -0,0 +1,33 @@ +package net.onelitefeather.cygnus.creek; + +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.SpotFinder; + +import java.util.random.RandomGenerator; + +/** + * What every creek of a round shares: the patrolling one and each variant. + * + * @param sight decides whether a survivor sees a creek + * @param spots finds places where nobody can see a creek + * @param consequence what happens on a catch + * @param patrol what the patrolling creek does to the survivors around it + * @param config the settings + * @param random the random source + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +record CreekRound( + CreekSight sight, + SpotFinder spots, + CatchConsequence consequence, + PatrolHelper patrol, + CreekConfig config, + RandomGenerator random +) { +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java new file mode 100644 index 00000000..202f1770 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -0,0 +1,224 @@ +package net.onelitefeather.cygnus.creek; + +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.instance.Instance; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; +import net.onelitefeather.cygnus.creek.debug.CreekDebug; +import net.onelitefeather.cygnus.creek.dread.DreadSource; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.state.VanishState; +import net.onelitefeather.cygnus.creek.world.CreekPaths; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.Ground; +import net.onelitefeather.cygnus.creek.world.InstanceGround; +import net.onelitefeather.cygnus.creek.world.PathRoute; +import net.onelitefeather.cygnus.creek.world.SpotFinder; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.utils.RepeatingTask; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; + +/** + * Brings the creek into a round and takes it out again at the end. + *

    + * Like {@code SlenderGazeService}, it listens for the start and end of a round on its own. It does + * not need to hear about deaths or disconnects: those players are simply missing from the + * survivors on the next step, so a variant whose target is gone ends within 100 ms anyway. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekService { + + private static final Logger LOGGER = LoggerFactory.getLogger(CreekService.class); + + /** How long one step lasts, in milliseconds. */ + static final int TICK_MILLIS = 100; + + private final CreekConfig config; + private final Supplier> survivors; + private final Supplier instance; + private final BiFunction bodies; + private final DreadSource dread; + private final CatchConsequence consequence; + private final RoundClock clock; + private final RandomGenerator random; + private final CreekDebug debug; + private final CreekSight sight; + private final Ground ground; + private final SpotFinder spots; + private final Supplier> routes; + private volatile @Nullable CreekRound round; + private final RepeatingTask task = new RepeatingTask(this::tick); + private volatile @Nullable Creek creek; + private volatile @Nullable CreekVariants variants; + + /** + * Sets up the service. + * + * @param config the settings + * @param survivors supplies the survivors of the round + * @param instance supplies the instance of the round, or {@code null} while there is none + * @param routes supplies the creek routes of the current map; without any, the creek stays away + * @param bodies puts a creek body into the world + * @param dread rates how scared each survivor is + * @param consequence what happens on a catch + * @param clock the round's clock + * @param random the random source + * @param debug the debug line for playtests + */ + public CreekService(CreekConfig config, Supplier> survivors, + Supplier instance, Supplier> routes, + BiFunction bodies, DreadSource dread, + CatchConsequence consequence, RoundClock clock, RandomGenerator random, + CreekDebug debug) { + this.config = config; + this.survivors = survivors; + this.instance = instance; + this.bodies = bodies; + this.dread = dread; + this.consequence = consequence; + this.clock = clock; + this.random = random; + this.debug = debug; + this.sight = new CreekSight(config.sightRange(), config.sightViewAngle()); + this.ground = new InstanceGround(instance); + this.spots = new SpotFinder(this.sight, this.ground); + this.routes = routes; + } + + /** + * Listens for the start and end of a round. + * + * @param node the node to listen on + */ + public void registerListener(EventNode node) { + node.addListener(GameStartEvent.class, _ -> this.start()); + node.addListener(GameFinishEvent.class, _ -> this.stop()); + } + + /** + * Puts the creek into the world, hidden at first, and starts stepping it. + */ + void start() { + if (this.creek != null) return; + + Instance world = this.instance.get(); + CreekPaths paths = CreekPaths.of(this.routes.get(), this.config.routeLinkDistance()); + if (world == null || paths.isEmpty()) { + LOGGER.warn("The map has no usable creek routes, so the creek stays away this round"); + return; + } + // Every creek walks with its own cursor, so this one belongs to the patrolling creek alone. + PathRoute pathRoute = new PathRoute(paths); + CreekRound round = new CreekRound(this.sight, this.spots, this.consequence, + new PatrolHelper(this.random), this.config, this.random); + this.round = round; + List points = paths.allPoints(); + + this.clock.start(); + Pos point = points.get(this.random.nextInt(points.size())); + CreekBody body = this.bodies.apply(world, this.ground.settle(point).orElse(point)); + // Start hidden. The creek and its variants only show up once the survivors have had time to + // spread out, the creek somewhere far away from all of them. + long showsUp = this.clock.now() + this.config.vanishMinSeconds() * 1000L; + this.creek = new Creek(body, pathRoute, round, new VanishState(showsUp)); + this.variants = new CreekVariants(this.config, this.spots, this.random, showsUp, + (spot, state) -> new Creek(this.bodies.apply(world, spot), new PathRoute(paths), round, state)); + this.debug.setActive(true); + this.task.start(TICK_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Runs one step: first the patrolling creek, then the variants. Survivors with a variant do not + * see the patrolling creek, and it does not pick them out. + *

    + * Once only one survivor is left and {@code activeWithLastSurvivor} is off, the patrolling creek + * is gone for good and every variant ends. + *

    + */ + void tick() { + Creek current = this.creek; + if (current == null) return; + // One snapshot for every creek: where the survivors are and how scared they are is the same for all. + SurvivorSnapshot survivors = SurvivorSnapshot.take(this.survivors.get(), this.dread); + long now = this.clock.now(); + CreekVariants currentVariants = this.variants; + // Count every survivor here: hiding the patrol from the haunted is not being down to the last one. + if (!this.config.activeWithLastSurvivor() && survivors.size() <= 1) { + current.vanishForGood(survivors, now); + if (currentVariants != null) currentVariants.stop(); + } else { + // A haunted survivor already has a creek of their own, so the patrolling one leaves them be. + Set haunted = currentVariants == null ? Set.of() : currentVariants.running().keySet(); + current.tick(survivors, haunted, now); + if (currentVariants != null) currentVariants.tick(survivors, now); + } + if (this.debug.hasWatchers()) { + Function names = id -> nameOf(survivors, id); + List views = current.lastViews(); + Component line = CreekDebug.line(current.state(), current.body().position(), views, names, + current.route().describe(), now); + if (currentVariants != null) { + List states = currentVariants.running().values().stream().map(Creek::state).toList(); + line = line.append(CreekDebug.variants(states, CreekVariants.capacity(views.size()), names, now)); + } + this.debug.show(line); + } + } + + private static String nameOf(SurvivorSnapshot survivors, UUID id) { + Player player = survivors.player(id); + return player != null ? player.getUsername() : id.toString().substring(0, 8); + } + + /** + * Takes the creek and every variant out of the world and clears anything still running on the survivors. + */ + void stop() { + this.task.stop(); + CreekVariants currentVariants = this.variants; + this.variants = null; + if (currentVariants != null) currentVariants.stop(); + Creek current = this.creek; + this.creek = null; + this.clock.reset(); + if (current != null) current.remove(); + this.consequence.cleanUp(); + CreekRound currentRound = this.round; + this.round = null; + if (currentRound != null) currentRound.patrol().cleanUp(); + this.debug.setActive(false); + } + + @Nullable Creek creek() { + return this.creek; + } + + @Nullable CreekVariants variants() { + return this.variants; + } + +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java new file mode 100644 index 00000000..9b7c65e5 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java @@ -0,0 +1,210 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.DoneState; +import net.onelitefeather.cygnus.creek.state.StalkState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.world.SpotFinder; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.function.Predicate; +import java.util.random.RandomGenerator; + +/** + * The creek's variants: a personal creek for each haunted survivor, one that nobody else sees. + *

    + * The survivor with the highest dread gets the next one. It starts by stalking them and is gone + * once the stalk or the hunt is over. The more survivors there are, the more of them can be + * haunted at once, see {@link #capacity(int)}. After a variant, the survivor gets a breather before + * the next one. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class CreekVariants { + + /** One variant for every this many survivors. */ + static final int SURVIVORS_PER_VARIANT = 4; + + /** Never more variants than this at once, however big the round. */ + static final int MAX_VARIANTS = 3; + + private final CreekConfig config; + private final SpotFinder spots; + private final RandomGenerator random; + private final long allowedAt; + private final BiFunction spawner; + private final Map running; + private final Map cooldowns; + + /** + * Sets up the variants for a round. + * + * @param config the settings + * @param spots finds a spot next to a survivor, out of their view + * @param random the random source + * @param allowedAt the earliest time a variant may show up, in milliseconds + * @param spawner creates a variant's creek at a spot, starting in the given state + */ + CreekVariants(CreekConfig config, SpotFinder spots, RandomGenerator random, long allowedAt, + BiFunction spawner) { + this.config = config; + this.spots = spots; + this.random = random; + this.allowedAt = allowedAt; + this.spawner = spawner; + this.running = new LinkedHashMap<>(); + this.cooldowns = new HashMap<>(); + } + + /** + * How many survivors can be haunted at once: one per {@link #SURVIVORS_PER_VARIANT} + * survivors, rounded up, and never more than {@link #MAX_VARIANTS}. + * + * @param survivors the number of survivors still alive + * @return how many variants may run at once + */ + static int capacity(int survivors) { + return Math.min(MAX_VARIANTS, (survivors + SURVIVORS_PER_VARIANT - 1) / SURVIVORS_PER_VARIANT); + } + + /** + * How long a survivor's breather after a variant lasts: {@code vanishMaxSeconds} for a calm + * survivor, down to {@code vanishMinSeconds} for one who is scared to death. + * + * @param config the settings + * @param dread the survivor's dread + * @return the breather in milliseconds + */ + static long cooldownMillis(CreekConfig config, double dread) { + double span = config.vanishMaxSeconds() - config.vanishMinSeconds(); + double seconds = config.vanishMaxSeconds() - span * Math.clamp(dread, 0.0D, 1.0D); + return Math.round(seconds * 1000.0D); + } + + /** + * Picks who to haunt next: the survivor with the highest dread, as long as it reaches the + * threshold. If two are equally scared, the one who strayed farthest from the others. + * + * @param views every survivor + * @param threshold the lowest dread that counts + * @param eligible whether a survivor may get a variant right now + * @return the survivor, or empty if nobody fits + */ + static Optional pick(List views, double threshold, Predicate eligible) { + return views.stream() + .filter(view -> view.dread() >= threshold && eligible.test(view.id())) + .max(Comparator.comparingDouble(SurvivorView::dread) + .thenComparingDouble(view -> nearestOther(views, view))); + } + + /** + * Runs one step: lets every variant act, clears away the finished ones and starts new ones + * while there is room. + * + * @param survivors the survivors of this step + * @param now the current time in milliseconds + */ + void tick(SurvivorSnapshot survivors, long now) { + List views = survivors.views(); + this.tickRunning(survivors, now); + if (now < this.allowedAt) return; + // Someone with no free spot next to them should not hold up everyone behind them. + Set noSpot = new HashSet<>(); + int free = capacity(views.size()) - this.running.size(); + while (free > 0) { + Optional target = pick(views, this.config.stalkThreshold(), + id -> this.isFree(id, now) && !noSpot.contains(id)); + if (target.isEmpty()) return; + if (this.start(target.get(), now)) { + free--; + } else { + noSpot.add(target.get().id()); + } + } + } + + /** + * Tells which survivors are haunted right now, and by which creek. + * + * @return a copy, keyed by the haunted survivor + */ + Map running() { + return Map.copyOf(this.running); + } + + /** + * Removes every variant and forgets every breather. + */ + void stop() { + for (Creek variant : this.running.values()) { + variant.remove(); + } + this.running.clear(); + this.cooldowns.clear(); + } + + private void tickRunning(SurvivorSnapshot survivors, long now) { + Iterator> iterator = this.running.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + Creek variant = entry.getValue(); + variant.tick(survivors, now); + if (!(variant.state() instanceof DoneState)) continue; + variant.remove(); + iterator.remove(); + long cooldown = cooldownMillis(this.config, dreadOf(survivors.views(), entry.getKey())); + this.cooldowns.put(entry.getKey(), now + cooldown); + } + } + + private boolean isFree(UUID id, long now) { + return !this.running.containsKey(id) && this.cooldowns.getOrDefault(id, Long.MIN_VALUE) <= now; + } + + /** + * Places a new variant next to the survivor, out of their view. + * + * @return {@code false} if there was no free spot next to them in this step + */ + private boolean start(SurvivorView view, long now) { + Optional spot = this.spots.beside(view.position(), config.stalkMinDistance(), config.stalkMaxDistance(), + config.stalkMinAngle(), config.stalkMaxAngle(), List.of(view.eyes()), config.personalSpace(), + this.random); + if (spot.isEmpty()) return false; + + StalkState stalk = StalkState.starting(view.id(), now, this.config, this.random); + this.running.put(view.id(), this.spawner.apply(spot.get(), stalk)); + return true; + } + + private static double dreadOf(List views, UUID id) { + for (SurvivorView view : views) { + if (view.id().equals(id)) return view.dread(); + } + return 0.0D; + } + + private static double nearestOther(List views, SurvivorView view) { + double nearest = Double.MAX_VALUE; + for (SurvivorView other : views) { + if (other.id().equals(view.id())) continue; + nearest = Math.min(nearest, other.position().distance(view.position())); + } + return nearest; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java b/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java new file mode 100644 index 00000000..c3a5fe91 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java @@ -0,0 +1,62 @@ +package net.onelitefeather.cygnus.creek; + +import java.util.function.LongSupplier; + +/** + * Keeps track of how long the current round has been going. + *

    + * The service starts and resets it, and the dread rating reads it. Sharing one clock saves the two + * from depending on each other. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class RoundClock { + + private final LongSupplier clock; + private volatile long startedAt = -1L; + + /** + * Sets up the clock. + * + * @param clock supplies the current time in milliseconds + */ + public RoundClock(LongSupplier clock) { + this.clock = clock; + } + + /** + * The current time. + * + * @return the time in milliseconds + */ + public long now() { + return this.clock.getAsLong(); + } + + /** + * Marks the start of a round. + */ + public void start() { + this.startedAt = this.clock.getAsLong(); + } + + /** + * Resets the clock when the round is over. + */ + public void reset() { + this.startedAt = -1L; + } + + /** + * How long the round has been going. + * + * @return the time in milliseconds, {@code 0} outside a round + */ + public long elapsedMillis() { + long start = this.startedAt; + return start < 0 ? 0L : this.clock.getAsLong() - start; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java b/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java new file mode 100644 index 00000000..6a778e56 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java @@ -0,0 +1,106 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.creek.dread.DreadSource; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * The survivors of one step: the players and a snapshot of each of them. + *

    + * It is taken once per step and shared by every creek. Where a survivor stands and how scared they + * are is the same for all of them; only whether they see a creek differs, so every view here says + * {@code false} for that and each creek fills it in for itself. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class SurvivorSnapshot { + + private final List players; + private final List views; + + /** + * Pairs up the players with their views. + * + * @param players the survivors + * @param views one view per survivor, in the same order + */ + SurvivorSnapshot(List players, List views) { + if (players.size() != views.size()) { + throw new IllegalArgumentException("Every survivor needs exactly one view"); + } + this.players = List.copyOf(players); + this.views = List.copyOf(views); + } + + /** + * Takes a snapshot of every survivor. + * + * @param players the survivors of the round + * @param dread rates how scared each survivor is + * @return the snapshot + */ + static SurvivorSnapshot take(Collection players, DreadSource dread) { + List survivors = List.copyOf(players); + List views = new ArrayList<>(survivors.size()); + for (Player survivor : survivors) { + Pos position = survivor.getPosition(); + List others = new ArrayList<>(survivors.size()); + for (Player other : survivors) { + if (other != survivor) others.add(other.getPosition()); + } + views.add(new SurvivorView(survivor.getUuid(), position, + dread.dreadOf(survivor.getUuid(), position, others), false)); + } + return new SurvivorSnapshot(survivors, views); + } + + /** + * The survivors. + * + * @return the players, in the same order as {@link #views()} + */ + List players() { + return this.players; + } + + /** + * One view per survivor, none of them seeing a creek. + * + * @return the views, in the same order as {@link #players()} + */ + List views() { + return this.views; + } + + /** + * Looks up a survivor by id. + * + * @param id the id to look for + * @return the player, or {@code null} if they are not among the survivors + */ + @Nullable Player player(UUID id) { + for (Player player : this.players) { + if (player.getUuid().equals(id)) return player; + } + return null; + } + + /** + * How many survivors there are. + * + * @return the count + */ + int size() { + return this.players.size(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java new file mode 100644 index 00000000..017ef83c --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java @@ -0,0 +1,141 @@ +package net.onelitefeather.cygnus.creek.body; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.entity.metadata.monster.CreakingMeta; +import net.minestom.server.instance.Instance; +import org.jetbrains.annotations.Nullable; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The creek in the world, dressed up as a vanilla creaking. + *

    + * An enderman was out of the question: the slender player already looks like one, and nobody could + * tell the two apart. Creakings only spawn from a creaking heart, and Cygnus has none, so the + * resource pack is free to give them a new look. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreakingBody implements CreekBody { + + /** How far the goal has to move before the creek works out a new path, in blocks. */ + static final double REPATH_DISTANCE = 1.0D; + + private final EntityCreature entity; + private final Set viewers = ConcurrentHashMap.newKeySet(); + private @Nullable Pos goal; + + private CreakingBody(EntityCreature entity) { + this.entity = entity; + } + + /** + * Puts a creaking into the world. Nobody sees it until {@link #showTo(Set)} says who may. + * + * @param instance the instance of the round + * @param position where it appears + * @return the body + */ + public static CreakingBody spawn(Instance instance, Pos position) { + CreakingBody body = new CreakingBody(new EntityCreature(EntityType.CREAKING)); + body.entity.getNavigator().setNodeGenerator(FoliageGroundGenerator::new); + body.entity.getNavigator().setNodeFollower(() -> new StepFollower(body.entity)); + body.entity.updateViewableRule(player -> body.viewers.contains(player.getUuid())); + body.entity.setInstance(instance, position); + return body; + } + + @Override + public Pos position() { + return this.entity.getPosition(); + } + + @Override + public void moveTo(Pos goal, double speed) { + this.entity.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(speed); + // A survivor on the move shifts a little between almost every two steps. Working out a + // new path every time would be expensive, so small shifts are ignored. + Pos current = this.goal; + if (current != null && current.distance(goal) <= REPATH_DISTANCE) return; + // Only remember goals the navigator took on. One it turned down (too close, same block, + // unloaded chunk) or one already reached must not stand in the way of the next. + boolean started = this.entity.getNavigator().setPathTo(goal, this.arrivalDistance(), () -> this.forget(goal)); + this.goal = started ? goal : null; + } + + private void forget(Pos reached) { + if (reached.equals(this.goal)) this.goal = null; + } + + /** + * How close counts as arrived for Minestom's navigator: from the middle of the bounding box to + * one of its corners. + */ + private double arrivalDistance() { + BoundingBox box = this.entity.getBoundingBox(); + return Math.sqrt(box.width() * box.width() + box.depth() * box.depth()) / 2.0D; + } + + @Override + public void stop() { + this.goal = null; + this.entity.getNavigator().reset(); + } + + @Override + public void teleport(Pos position) { + this.stop(); + this.entity.teleport(position); + } + + @Override + public void lookAt(Pos point) { + this.entity.lookAt(point); + } + + @Override + public void showTo(Set viewers) { + this.viewers.clear(); + this.viewers.addAll(viewers); + this.entity.updateViewableRule(); + } + + @Override + public boolean isVisibleTo(UUID viewer) { + return this.viewers.contains(viewer); + } + + @Override + public void setAggressive(boolean aggressive) { + this.meta().setActive(aggressive); + } + + @Override + public void setFrozen(boolean frozen) { + this.meta().setCanMove(!frozen); + } + + @Override + public Entity entity() { + return this.entity; + } + + @Override + public void remove() { + this.entity.remove(); + } + + private CreakingMeta meta() { + return (CreakingMeta) this.entity.getEntityMeta(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java new file mode 100644 index 00000000..9669140a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java @@ -0,0 +1,96 @@ +package net.onelitefeather.cygnus.creek.body; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; + +import java.util.Set; +import java.util.UUID; + +/** + * What the creek looks like in the world: the entity that gets drawn and moved around. + *

    + * The states only ever talk to this interface. That way the vanilla creaking can later make way + * for a custom model without the behaviour changing at all. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public interface CreekBody { + + /** + * Where the creek's feet are. + * + * @return the position + */ + Pos position(); + + /** + * Walks towards a goal. + * + * @param goal where to go + * @param speed how fast, in blocks per tick + */ + void moveTo(Pos goal, double speed); + + /** + * Stops walking. + */ + void stop(); + + /** + * Moves the creek somewhere else in an instant. + * + * @param position where it ends up + */ + void teleport(Pos position); + + /** + * Turns the creek's head towards a point. + * + * @param point what to look at + */ + void lookAt(Pos point); + + /** + * Lets exactly these players see the creek. + * + * @param viewers who may see it; an empty set hides it from everyone + */ + void showTo(Set viewers); + + /** + * Tells whether a player can see the creek right now. + * + * @param viewer the player's id + * @return {@code true} if the creek is visible to them + */ + boolean isVisibleTo(UUID viewer); + + /** + * Switches the hunting look on or off. For the creaking, that means glowing eyes. + * + * @param aggressive {@code true} for the hunting look + */ + void setAggressive(boolean aggressive); + + /** + * Switches the frozen look on or off. + * + * @param frozen {@code true} while the hunted survivor is looking at the creek + */ + void setFrozen(boolean frozen); + + /** + * The entity behind the body, needed to check who can see it. + * + * @return the entity + */ + Entity entity(); + + /** + * Takes the creek out of the world. + */ + void remove(); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java new file mode 100644 index 00000000..bd6558f3 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java @@ -0,0 +1,45 @@ +package net.onelitefeather.cygnus.creek.body; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.collision.CollisionUtils; +import net.minestom.server.collision.PhysicsResult; +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.pathfinding.generators.GroundNodeGenerator; +import net.minestom.server.instance.block.Block; + +/** + * Plans paths like {@link GroundNodeGenerator}, but lets the creek walk through grass, ferns + * and leaf litter. + *

    + * Minestom only lets an entity step into a block of pure air, so a single tuft of grass made a + * spot unwalkable: the creek took detours, jumped around or did not move at all. Here only solid + * blocks and liquids get in the way. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class FoliageGroundGenerator extends GroundNodeGenerator { + + @Override + public boolean canMoveTowards(Block.Getter getter, Point start, Point end, BoundingBox boundingBox) { + Block target = getter.getBlock(end); + if (target.solid() || target.liquid()) return false; + PhysicsResult result = sweep(getter, boundingBox, start, end); + return !result.collisionX() && !result.collisionY() && !result.collisionZ(); + } + + /** + * Moves the creek's bounding box from {@code start} to {@code end} and reports what it hits. + * + * @param getter the blocks of the world + * @param boundingBox the creek's bounding box + * @param start where the move starts + * @param end where the move ends + * @return the result of the move + */ + private static PhysicsResult sweep(Block.Getter getter, BoundingBox boundingBox, Point start, Point end) { + return CollisionUtils.handlePhysics(getter, boundingBox, start.asPos(), end.sub(start).asVec(), null, false); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java new file mode 100644 index 00000000..ee4b113f --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java @@ -0,0 +1,136 @@ +package net.onelitefeather.cygnus.creek.body; + +import net.minestom.server.collision.CollisionUtils; +import net.minestom.server.collision.PhysicsResult; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.entity.pathfinding.followers.GroundNodeFollower; +import net.minestom.server.instance.Instance; +import net.minestom.server.utils.position.PositionUtils; +import org.jetbrains.annotations.Nullable; + +/** + * Moves the creek along its path like {@link GroundNodeFollower}, but steps up low blocks the + * way a vanilla mob does instead of hopping onto them. + *

    + * It differs from Minestom's follower in three ways: + *

    + *
      + *
    • Each step is checked a hair above the feet. Standing right on top of a slab, Minestom + * treats the next slab as a wall, and the creek would get stuck at the edge.
    • + *
    • Anything up to its step height attribute high it simply walks up. A creaking's own is + * 1.0625, so like in vanilla it takes whole blocks without hopping, as survivors do in a round. + * It only jumps when stepping up gets it nowhere.
    • + *
    • A path point counts as reached once the creek stands in its column and less than a block + * away in height. The path puts the floor of a slab a block too high, so on slabs the creek + * never quite arrived and kept jumping at the point.
    • + *
    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class StepFollower extends GroundNodeFollower { + + /** How far ahead it looks for a blocked step before deciding to jump, in blocks. */ + private static final double LOOK_AHEAD = 0.3D; + + private final LivingEntity entity; + + StepFollower(LivingEntity entity) { + super(entity); + this.entity = entity; + } + + /** + * The highest step the creek takes without jumping, from its step height attribute. + * + * @return the step height, in blocks + */ + double stepHeight() { + return this.entity.getAttributeValue(Attribute.STEP_HEIGHT); + } + + @Override + public void moveTowards(Point direction, double speed, Point lookAt) { + Instance instance = this.entity.getInstance(); + if (instance == null) return; + Pos position = this.entity.getPosition(); + double dx = direction.x() - position.x(); + double dz = direction.z() - position.z(); + + // Never overshoot the point. Minestom slows down using the distance including height, so + // above a point one block lower the creek kept swinging past it and never dropped into a + // narrow gap. + double step = Math.min(speed, Math.hypot(dx, dz)); + + Pos moved = this.walk(instance, position, horizontal(dx, dz, step)); + Point look = lookAt.sub(position); + float yaw = PositionUtils.getLookYaw(look.x(), look.z()); + float pitch = PositionUtils.getLookPitch(look.x(), look.y(), look.z()); + this.entity.refreshPosition(moved.withView(yaw, pitch)); + } + + @Override + public void jump(@Nullable Point point, @Nullable Point target) { + Instance instance = this.entity.getInstance(); + if (instance == null || point == null || !this.entity.isOnGround()) return; + Pos position = this.entity.getPosition(); + Vec ahead = horizontal(point.x() - position.x(), point.z() - position.z(), LOOK_AHEAD); + // Still room to walk, or a step it can walk up: no need to jump. + if (!this.blocked(instance, position.add(0, Vec.EPSILON, 0), ahead)) return; + if (!this.blocked(instance, this.lifted(instance, position), ahead)) return; + super.jump(point, target); + } + + @Override + public boolean isAtPoint(Point point) { + Pos position = this.entity.getPosition(); + return position.blockX() == point.blockX() && position.blockZ() == point.blockZ() + && Math.abs(position.y() - point.y()) < 1.0D; + } + + /** + * Moves by {@code step}, walking up a low block if the flat move runs into it. + */ + private Pos walk(Instance instance, Pos from, Vec step) { + PhysicsResult flat = this.sweep(instance, from.add(0, Vec.EPSILON, 0), step); + Pos flatEnd = flat.newPosition().withY(from.y()); + if ((!flat.collisionX() && !flat.collisionZ()) || !this.entity.isOnGround()) return flatEnd; + + PhysicsResult over = this.sweep(instance, this.lifted(instance, from), step); + if (horizontalDistance(from, over.newPosition()) <= horizontalDistance(from, flatEnd)) return flatEnd; + return this.sweep(instance, over.newPosition(), new Vec(0, -this.stepHeight(), 0)).newPosition(); + } + + /** + * Returns the position {@link #stepHeight()} higher, or lower if a ceiling is in the way. + */ + private Pos lifted(Instance instance, Pos from) { + return this.sweep(instance, from.add(0, Vec.EPSILON, 0), new Vec(0, this.stepHeight(), 0)).newPosition(); + } + + private boolean blocked(Instance instance, Pos from, Vec step) { + PhysicsResult result = this.sweep(instance, from, step); + return result.collisionX() || result.collisionZ(); + } + + /** + * Moves the creek's bounding box by {@code move} from {@code from} and reports what it hits. + */ + private PhysicsResult sweep(Instance instance, Pos from, Vec move) { + return CollisionUtils.handlePhysics(instance, this.entity.getBoundingBox(), from, move, null, false); + } + + private static Vec horizontal(double dx, double dz, double length) { + double radians = Math.atan2(dz, dx); + return new Vec(Math.cos(radians) * length, 0, Math.sin(radians) * length); + } + + private static double horizontalDistance(Pos from, Pos to) { + return Math.hypot(to.x() - from.x(), to.z() - from.z()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/package-info.java new file mode 100644 index 00000000..cdb81827 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/package-info.java @@ -0,0 +1,7 @@ +/** + * What the creek looks like in the world and how it moves. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.creek.body; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java new file mode 100644 index 00000000..ef952d87 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java @@ -0,0 +1,27 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.entity.Player; + +/** + * What happens to a survivor the creek catches. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +@FunctionalInterface +public interface CatchConsequence { + + /** + * Lets the catch hit the survivor. + * + * @param survivor the survivor who was caught + */ + void apply(Player survivor); + + /** + * Takes back anything that is still running. Called when the round ends. + */ + default void cleanUp() { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java new file mode 100644 index 00000000..002f7d8e --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java @@ -0,0 +1,81 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.entity.Player; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.onelitefeather.cygnus.stamina.FoodBar; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * The usual price for being caught: a jump scare, an empty stamina bar and slowness. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CatchEffects implements CatchConsequence { + + /** The sound of the stand-in scare, for when there is no corpse to show yet. */ + static final Key SCARE_SOUND = Key.key("entity.creaking.activate"); + + /** How long the stand-in scare's darkness lasts, in ticks. */ + static final int SCARE_DARKNESS_TICKS = 40; + + private final Predicate jumpScare; + private final Function foodBars; + private final int slownessTicks; + private final TrackedEffects effects = new TrackedEffects(); + + /** + * Sets up the effects. + * + * @param jumpScare plays the jump scare and returns {@code false} if it could not + * @param foodBars returns a survivor's stamina bar, or {@code null} if they have none + * @param slownessSeconds how long a caught survivor is slowed + */ + public CatchEffects(Predicate jumpScare, Function foodBars, int slownessSeconds) { + this.jumpScare = jumpScare; + this.foodBars = foodBars; + this.slownessTicks = slownessSeconds * 20; + } + + @Override + public void apply(Player survivor) { + // The jump scare needs a corpse, so before the first death there is nothing to show. + // The stand-in makes sure a catch never goes unnoticed. + if (!this.jumpScare.test(survivor)) { + scareWithoutCorpse(survivor); + } + FoodBar bar = this.foodBars.apply(survivor); + if (bar != null) { + bar.drain(); + } + if (this.slownessTicks > 0) { + this.effects.add(survivor, new Potion(PotionEffect.SLOWNESS, 0, this.slownessTicks)); + } + } + + @Override + public void cleanUp() { + this.effects.removeAll(); + } + + private static void scareWithoutCorpse(Player survivor) { + playScare(survivor); + survivor.addEffect(new Potion(PotionEffect.DARKNESS, 0, SCARE_DARKNESS_TICKS)); + } + + /** + * Plays the creek's scare sound to a player. + * + * @param player the player + */ + static void playScare(Player player) { + player.playSound(Sound.sound(SCARE_SOUND, Sound.Source.HOSTILE, 1.0F, 0.6F)); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/GlowReveal.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/GlowReveal.java new file mode 100644 index 00000000..4acdafc6 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/GlowReveal.java @@ -0,0 +1,128 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Metadata; +import net.minestom.server.entity.Player; +import net.minestom.server.network.packet.server.play.EntityMetaDataPacket; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Lets a survivor glow for a few seconds, but only in the slender's eyes. + *

    + * Glowing is a metadata flag, and Minestom sends metadata to everyone who can see the player. So + * the flag is never set on the survivor itself. Instead, only the slender gets a packet with it. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class GlowReveal implements SlenderReveal { + + /** How often the glow packet goes out again, in ticks. */ + static final int RESEND_TICKS = 5; + + /** The glowing bit in the entity flags. */ + static final byte GLOWING = 0x40; + + static final Key SLENDER_SOUND = Key.key("block.bell.use"); + static final Key SURVIVOR_SOUND = Key.key("entity.creaking.activate"); + static final Component WARNING = Component.text("He knows where you are...", NamedTextColor.DARK_RED, TextDecoration.ITALIC); + + private final int durationTicks; + private final Set running = ConcurrentHashMap.newKeySet(); + + /** + * Sets up the reveal. + * + * @param seconds how long the glow lasts + */ + public GlowReveal(int seconds) { + this.durationTicks = seconds * 20; + } + + @Override + public void reveal(Player survivor, Player slender) { + Pos where = survivor.getPosition(); + slender.playSound(Sound.sound(SLENDER_SOUND, Sound.Source.HOSTILE, 1.0F, 0.5F), where.x(), where.y(), where.z()); + // Let the survivor know too. Being given away without any hint would feel unfair. + survivor.playSound(Sound.sound(SURVIVOR_SOUND, Sound.Source.HOSTILE, 1.0F, 0.8F)); + survivor.sendMessage(WARNING); + + Reveal reveal = new Reveal(survivor, slender, this.durationTicks); + this.running.add(reveal); + slender.sendPacket(flagsPacket(survivor, true)); + reveal.task = MinecraftServer.getSchedulerManager() + .buildTask(() -> this.step(reveal)) + .delay(TaskSchedule.tick(RESEND_TICKS)) + .repeat(TaskSchedule.tick(RESEND_TICKS)) + .schedule(); + } + + private void step(Reveal reveal) { + reveal.ticksLeft -= RESEND_TICKS; + if (reveal.ticksLeft <= 0 || !reveal.survivor.isOnline() || !reveal.slender.isOnline()) { + this.end(reveal); + return; + } + // Whenever the survivor's metadata changes (sprinting, sneaking), the slender gets the real + // flags and the glow is gone. Sending it again keeps that gap short. + reveal.slender.sendPacket(flagsPacket(reveal.survivor, true)); + } + + @Override + public void cleanUp() { + for (Reveal reveal : List.copyOf(this.running)) { + this.end(reveal); + } + } + + private void end(Reveal reveal) { + if (!this.running.remove(reveal)) return; + if (reveal.task != null) reveal.task.cancel(); + if (reveal.slender.isOnline()) { + reveal.slender.sendPacket(flagsPacket(reveal.survivor, false)); + } + } + + /** + * Builds a packet with the survivor's current entity flags, with the glow bit set or cleared. + *

    + * The flags always go along. Otherwise the client would hold on to the last value it got, which + * after a reveal is the glow. + *

    + */ + static EntityMetaDataPacket flagsPacket(Player survivor, boolean glowing) { + Metadata.Entry entry = survivor.getMetadataPacket().entries().get(0); + byte flags = entry != null && entry.value() instanceof Byte value ? value : 0; + byte sent = glowing ? (byte) (flags | GLOWING) : (byte) (flags & ~GLOWING); + return new EntityMetaDataPacket(survivor.getEntityId(), Map.of(0, Metadata.Byte(sent))); + } + + private static final class Reveal { + + private final Player survivor; + private final Player slender; + private int ticksLeft; + private @Nullable Task task; + + private Reveal(Player survivor, Player slender, int ticksLeft) { + this.survivor = survivor; + this.slender = slender; + this.ticksLeft = ticksLeft; + } + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java new file mode 100644 index 00000000..5ac21562 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java @@ -0,0 +1,175 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; + +import java.util.Collection; +import java.util.List; +import java.util.random.RandomGenerator; + +/** + * What the patrolling creek does to the survivors around it. + *

    + * When it picks out a survivor who came too close, it is a coin toss: either the survivor freezes + * on the spot and everyone standing next to them goes blind for a moment, or the creek flings them + * away from itself. It is a scare, not a catch, so it never counts towards + * giving them away to the slender. When it vanishes at the end of its route, everyone close by + * hears it and goes blind the same way. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class PatrolHelper { + + /** How long the survivor stays frozen, in ticks (2 seconds). */ + static final int STUN_TICKS = 40; + + /** Slowness VII: strong enough that the survivor cannot move at all. */ + static final int STUN_AMPLIFIER = 6; + + /** Everyone this close to the frozen survivor goes blind too, in blocks. */ + static final double BLIND_RADIUS = 8.0D; + + /** How long they stay blind, in ticks (2 seconds). */ + static final int BLIND_TICKS = 40; + + /** A fling needs at least this much room, in blocks. With less, it tries another way. */ + static final double FLING_MIN_DISTANCE = 4.0D; + + /** A fling goes at most this far, however much room there is, in blocks. */ + static final double FLING_MAX_DISTANCE = 10.0D; + + /** + * How fast to send a survivor for every block they should fly, in blocks per second. The client + * slows them down on its own, so this is worked out by hand: tune it if flings fall short or + * overshoot. + */ + static final double FLING_SPEED_PER_BLOCK = 2.7D; + + /** How fast a fling lifts the survivor off the ground, in blocks per second. */ + static final double FLING_LIFT = 8.0D; + + /** The ways a fling tries, in degrees off straight away from the creek, in this order. */ + private static final double[] FLING_TURNS = {0.0D, 45.0D, -45.0D, 90.0D, -90.0D}; + + /** How finely the room in front of a survivor is measured, in blocks. */ + private static final double ROOM_STEP = 0.5D; + + /** The heights above the feet at which the room is measured, so the whole body fits through. */ + private static final double[] BODY_HEIGHTS = {0.5D, 1.5D}; + + private final RandomGenerator random; + private final TrackedEffects effects = new TrackedEffects(); + + /** + * Sets up the consequence for a round. + * + * @param random tosses the coin between freezing and flinging + */ + public PatrolHelper(RandomGenerator random) { + this.random = random; + } + + /** + * Freezes the survivor or flings them away. If there is no room to fling them, they are frozen + * instead, so a selection never goes unnoticed. + * + * @param selected the survivor the creek picked out + * @param creek where the creek stands, to fling them away from it + * @param survivors every survivor of the round, to find the ones standing next to a frozen one + */ + public void selected(Player selected, Pos creek, Collection survivors) { + if (this.random.nextBoolean() || !this.flingAway(selected, creek)) { + this.stun(selected, survivors); + } + } + + /** + * Freezes the survivor for a moment and blinds the other survivors standing next to them. + */ + void stun(Player selected, Collection survivors) { + CatchEffects.playScare(selected); + this.effects.add(selected, new Potion(PotionEffect.SLOWNESS, STUN_AMPLIFIER, STUN_TICKS)); + for (Player other : near(selected.getPosition(), survivors)) { + if (other != selected) this.blind(other); + } + } + + /** + * The creek vanished: every survivor close by hears it and goes blind for a moment. + * + * @param where where the creek vanished + * @param survivors the survivors it may blind + */ + public void vanished(Pos where, Collection survivors) { + for (Player survivor : near(where, survivors)) { + CatchEffects.playScare(survivor); + this.blind(survivor); + } + } + + private void blind(Player player) { + this.effects.add(player, new Potion(PotionEffect.BLINDNESS, 0, BLIND_TICKS)); + } + + /** + * The survivors within {@link #BLIND_RADIUS} of a point. + */ + private static List near(Pos center, Collection survivors) { + return survivors.stream().filter(survivor -> survivor.getPosition().distance(center) <= BLIND_RADIUS).toList(); + } + + /** + * Flings the survivor away from the creek, as far as there is room but no further than + * {@link #FLING_MAX_DISTANCE}. Straight away comes first; if something stands in the way within + * {@link #FLING_MIN_DISTANCE}, it tries to the sides. + * + * @return {@code false} if there is not enough room in any of those ways + */ + boolean flingAway(Player selected, Pos creek) { + Instance instance = selected.getInstance(); + if (instance == null) return false; + Pos from = selected.getPosition(); + double away = Math.atan2(from.z() - creek.z(), from.x() - creek.x()); + for (double turn : FLING_TURNS) { + double angle = away + Math.toRadians(turn); + Vec direction = new Vec(Math.cos(angle), 0.0D, Math.sin(angle)); + double room = room(instance, from, direction); + if (room < FLING_MIN_DISTANCE) continue; + Vec push = direction.mul(room * FLING_SPEED_PER_BLOCK); + selected.setVelocity(new Vec(push.x(), FLING_LIFT, push.z())); + CatchEffects.playScare(selected); + return true; + } + return false; + } + + /** + * How far the survivor can fly in a direction before hitting something, up to + * {@link #FLING_MAX_DISTANCE}. An unloaded chunk counts as a wall. + */ + private static double room(Instance instance, Pos from, Vec direction) { + for (double distance = ROOM_STEP; distance <= FLING_MAX_DISTANCE; distance += ROOM_STEP) { + Pos ahead = from.add(direction.mul(distance)); + if (!instance.isChunkLoaded(ahead)) return distance - ROOM_STEP; + for (double height : BODY_HEIGHTS) { + if (instance.getBlock(ahead.add(0.0D, height, 0.0D)).solid()) return distance - ROOM_STEP; + } + } + return FLING_MAX_DISTANCE; + } + + /** + * Takes back every slowness and blindness handed out that is still running, so nothing lingers + * after the round. + */ + public void cleanUp() { + this.effects.removeAll(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java new file mode 100644 index 00000000..2659546e --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java @@ -0,0 +1,28 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.entity.Player; + +/** + * Gives a survivor's position away to the slender. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +@FunctionalInterface +public interface SlenderReveal { + + /** + * Shows the slender where a survivor is. + * + * @param survivor the survivor to give away + * @param slender the slender + */ + void reveal(Player survivor, Player slender); + + /** + * Ends every reveal that is still running. + */ + default void cleanUp() { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java new file mode 100644 index 00000000..329a68f7 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java @@ -0,0 +1,85 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.entity.Player; +import net.minestom.server.tag.Tag; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; + +/** + * Punishes every catch, and sometimes gives the survivor away to the slender as well: always + * from the {@code betrayalCatchCount}-th catch on, and before that with a chance of + * {@code betrayalChance}. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class StagedCatchConsequence implements CatchConsequence { + + /** How often a survivor has been caught since they were last given away. */ + static final Tag CATCHES = Tag.Transient("creekCatches"); + + private final CatchConsequence effects; + private final SlenderReveal reveal; + private final Supplier<@Nullable Player> slender; + private final int betrayalCatchCount; + private final double betrayalChance; + private final RandomGenerator random; + private final Set counted = ConcurrentHashMap.newKeySet(); + + /** + * Sets up the consequence. + * + * @param effects the punishment for every catch + * @param reveal gives a survivor away to the slender + * @param slender supplies the slender, or {@code null} while there is none + * @param config the settings for giving survivors away + * @param random the random source + */ + public StagedCatchConsequence(CatchConsequence effects, SlenderReveal reveal, Supplier<@Nullable Player> slender, + CreekConfig config, RandomGenerator random) { + this.effects = effects; + this.reveal = reveal; + this.slender = slender; + this.betrayalCatchCount = config.betrayalCatchCount(); + this.betrayalChance = config.betrayalChance(); + this.random = random; + } + + @Override + public void apply(Player survivor) { + this.effects.apply(survivor); + + Integer before = survivor.getTag(CATCHES); + int catches = before == null ? 1 : before + 1; + Player current = this.slender.get(); + if (current != null && this.betrays(catches)) { + this.reveal.reveal(survivor, current); + survivor.removeTag(CATCHES); + this.counted.remove(survivor); + return; + } + survivor.setTag(CATCHES, catches); + this.counted.add(survivor); + } + + private boolean betrays(int catches) { + return catches >= this.betrayalCatchCount || this.random.nextDouble() < this.betrayalChance; + } + + @Override + public void cleanUp() { + this.effects.cleanUp(); + this.reveal.cleanUp(); + for (Player player : List.copyOf(this.counted)) { + player.removeTag(CATCHES); + } + this.counted.clear(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java new file mode 100644 index 00000000..32ca5ba1 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java @@ -0,0 +1,59 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.entity.Player; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.minestom.server.potion.TimedPotion; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Hands out potion effects and takes back exactly those again. + *

    + * Taking back only removes an effect that is still the one handed out here. If it has run out, or + * something else has put the same effect on the player since, it is left alone. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class TrackedEffects { + + private final Map given = new ConcurrentHashMap<>(); + + /** + * Puts an effect on a player and remembers it. + * + * @param player the player + * @param potion the effect to put on them + */ + void add(Player player, Potion potion) { + player.addEffect(potion); + TimedPotion active = player.getEffect(potion.effect()); + if (active != null) this.given.put(new Key(player, potion.effect()), active); + } + + /** + * Takes back every effect handed out that is still running, and forgets them all. + */ + void removeAll() { + for (Map.Entry entry : List.copyOf(this.given.entrySet())) { + Player player = entry.getKey().player(); + PotionEffect effect = entry.getKey().effect(); + if (Objects.equals(player.getEffect(effect), entry.getValue())) { + player.removeEffect(effect); + } + } + this.given.clear(); + } + + /** + * One effect on one player. A new effect of the same kind replaces the one remembered before. + */ + private record Key(Player player, PotionEffect effect) { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java new file mode 100644 index 00000000..aa967e90 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java @@ -0,0 +1,7 @@ +/** + * What happens to a survivor the creek catches or picks out. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.creek.consequence; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java new file mode 100644 index 00000000..4f60c13a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java @@ -0,0 +1,227 @@ +package net.onelitefeather.cygnus.creek.debug; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.DoneState; +import net.onelitefeather.cygnus.creek.state.HuntState; +import net.onelitefeather.cygnus.creek.state.PatrolState; +import net.onelitefeather.cygnus.creek.state.StalkState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.state.VanishState; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Shows what the creek is up to in the action bar of everyone who switched it on. Meant for + * playtests. + *

    + * Watchers see everything, whatever their role in the round. It is a tuning tool, not something + * players should have during a real game. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekDebug { + + /** What watchers see while no round is running. */ + public static final Component INACTIVE = Component.text("Creek inactive", NamedTextColor.GRAY); + + /** Marks a survivor who can see the creek right now. The default Minecraft font has no emoji. */ + static final String SEEN = "◉"; + + private final Set watchers = ConcurrentHashMap.newKeySet(); + private volatile boolean active; + + /** + * Switches the debug line on or off for a player. + * + * @param player the player's id + * @return {@code true} if the line is now on + */ + public boolean toggle(UUID player) { + if (this.watchers.remove(player)) return false; + this.watchers.add(player); + return true; + } + + /** + * Tells whether anyone has the debug line switched on. + * + * @return {@code true} if at least one player is watching + */ + public boolean hasWatchers() { + return !this.watchers.isEmpty(); + } + + /** + * Tells whether the creek is part of a round right now. + * + * @return {@code true} while the creek is in the world + */ + public boolean isActive() { + return this.active; + } + + /** + * Tells the debug line whether the creek is part of a round. Once it leaves, watchers see + * "Creek inactive". + * + * @param active {@code true} while the creek is in the world + */ + public void setActive(boolean active) { + this.active = active; + if (!active) this.show(INACTIVE); + } + + /** + * Sends a line to every watcher who is online. + * + * @param line the line to show + */ + public void show(Component line) { + for (UUID id : this.watchers) { + Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(id); + if (player != null) player.sendActionBar(line); + } + } + + /** + * Builds the debug line for one step. + * + * @param state the creek's state + * @param creek where the creek is + * @param views the survivors in that step + * @param names turns a player's id into a name + * @return the line + */ + public static Component line(CreekState state, Pos creek, List views, Function names) { + return line(state, creek, views, names, ""); + } + + /** + * Builds the debug line for one step, together with where the creek is on its route. + * + * @param state the creek's state + * @param creek where the creek is + * @param views the survivors in that step + * @param names turns a player's id into a name + * @param route where the creek is on its route, or an empty string without a route + * @return the line + */ + public static Component line(CreekState state, Pos creek, List views, Function names, + String route) { + return line(state, creek, views, names, route, Long.MAX_VALUE); + } + + /** + * Builds the debug line of the patrolling creek. + * + * @param state the creek's state + * @param creek where the creek is + * @param views every survivor + * @param names turns a survivor's id into a name + * @param route where the creek is on its route, or an empty string + * @param now the current time in milliseconds, to show how long until it picks someone out again + * @return the line + */ + public static Component line(CreekState state, Pos creek, List views, Function names, + String route, long now) { + TextComponent.Builder builder = Component.text(); + builder.append(Component.text(label(state), NamedTextColor.RED)); + if (!route.isEmpty()) { + builder.append(Component.text(" · " + route, NamedTextColor.AQUA)); + } + + target(state).ifPresent(id -> { + builder.append(Component.text(" → " + names.apply(id), NamedTextColor.GOLD)); + views.stream() + .filter(view -> view.id().equals(id)) + .findFirst() + .ifPresent(view -> builder.append(Component.text( + String.format(Locale.ROOT, " · %.1f m", view.position().distance(creek)), + NamedTextColor.GRAY))); + }); + + if (state instanceof PatrolState patrol && patrol.selectAllowedAt() > now) { + long seconds = (patrol.selectAllowedAt() - now + 999L) / 1000L; + builder.append(Component.text(" · select " + seconds + "s", NamedTextColor.GRAY)); + } + + for (SurvivorView view : views) { + builder.append(Component.text(" · ", NamedTextColor.DARK_GRAY)); + builder.append(Component.text( + String.format(Locale.ROOT, "%s %.2f", names.apply(view.id()), view.dread()), + NamedTextColor.GRAY)); + if (view.seesCreek()) { + builder.append(Component.text(" " + SEEN, NamedTextColor.YELLOW)); + } + } + return builder.build(); + } + + /** + * Builds the part of the debug line about the variants: how many are running, and for each + * one who it haunts, what it is doing and how many seconds it has left. + * + * @param states the states of the running variants + * @param capacity how many variants may run at once + * @param names turns a survivor's id into a name + * @param now the current time in milliseconds + * @return the segment, starting with a separator + */ + public static Component variants(List states, int capacity, Function names, long now) { + TextComponent.Builder builder = Component.text(); + builder.append(Component.text(" | ", NamedTextColor.DARK_GRAY)); + builder.append(Component.text("variants " + states.size() + "/" + capacity, NamedTextColor.LIGHT_PURPLE)); + for (CreekState state : states) { + long endsAt = switch (state) { + case StalkState stalk -> stalk.endsAt(); + case HuntState hunt -> hunt.endsAt(); + default -> now; + }; + String who = target(state).map(names).orElse("?"); + long seconds = Math.max(0L, (endsAt - now) / 1000L); + builder.append(Component.text(" · " + who + " " + label(state) + " " + seconds + "s", NamedTextColor.GRAY)); + } + return builder.build(); + } + + /** + * The name a state goes by in the debug line. + * + * @param state the state + * @return the name, for example {@code HUNT} + */ + public static String label(CreekState state) { + return switch (state) { + case PatrolState _ -> "PATROL"; + case StalkState _ -> "STALK"; + case HuntState _ -> "HUNT"; + case VanishState _ -> "VANISH"; + case DoneState _ -> "DONE"; + default -> state.getClass().getSimpleName(); + }; + } + + private static Optional target(CreekState state) { + return switch (state) { + case StalkState stalk -> Optional.of(stalk.target()); + case HuntState hunt -> Optional.of(hunt.target()); + case PatrolState patrol -> patrol.staring(); + default -> Optional.empty(); + }; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java new file mode 100644 index 00000000..4466e163 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java @@ -0,0 +1,7 @@ +/** + * A debug line in the action bar, for playtests. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.creek.debug; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java new file mode 100644 index 00000000..9fe2abd9 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java @@ -0,0 +1,31 @@ +package net.onelitefeather.cygnus.creek.dread; + +import net.minestom.server.coordinate.Pos; + +import java.util.List; +import java.util.UUID; + +/** + * Rates how likely a survivor is to be haunted. + *

    + * The creek only ever asks this interface. A sanity system can take over the rating later without + * the creek noticing. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +@FunctionalInterface +public interface DreadSource { + + /** + * Rates one survivor. + * + * @param survivor the survivor to rate + * @param position where the survivor is + * @param others where all the other survivors are + * @return the dread, between {@code 0} and {@code 1} + */ + double dreadOf(UUID survivor, Pos position, List others); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java new file mode 100644 index 00000000..e1c56be4 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java @@ -0,0 +1,72 @@ +package net.onelitefeather.cygnus.creek.dread; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; + +import java.util.List; +import java.util.UUID; +import java.util.function.IntSupplier; +import java.util.function.LongSupplier; + +/** + * Rates the dread from the pages found so far, how long the round has been going and whether a + * survivor is on their own. + *

    + * Pages and time are the same for everyone. Being alone is what sets a survivor apart, so the + * creek goes for those who wander off from the group. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class PageProgressDread implements DreadSource { + + private final IntSupplier foundPages; + private final IntSupplier maxPages; + private final LongSupplier elapsedMillis; + private final long roundMillis; + private final CreekConfig config; + + /** + * Sets up the rating. + * + * @param foundPages supplies how many pages have been found + * @param maxPages supplies how many pages there are in total + * @param elapsedMillis supplies how long the round has been going + * @param gameTimeSeconds how long a round lasts at most + * @param config the weights and how far apart counts as alone + */ + public PageProgressDread(IntSupplier foundPages, IntSupplier maxPages, LongSupplier elapsedMillis, + int gameTimeSeconds, CreekConfig config) { + this.foundPages = foundPages; + this.maxPages = maxPages; + this.elapsedMillis = elapsedMillis; + this.roundMillis = gameTimeSeconds * 1000L; + this.config = config; + } + + @Override + public double dreadOf(UUID survivor, Pos position, List others) { + double pages = share(this.foundPages.getAsInt(), this.maxPages.getAsInt()); + double time = share(this.elapsedMillis.getAsLong(), this.roundMillis); + double isolated = this.isIsolated(position, others) ? 1.0D : 0.0D; + double dread = this.config.dreadPageWeight() * pages + + this.config.dreadTimeWeight() * time + + this.config.dreadIsolationWeight() * isolated; + return Math.clamp(dread, 0.0D, 1.0D); + } + + private boolean isIsolated(Pos position, List others) { + double radius = this.config.isolationRadius(); + for (Pos other : others) { + if (other.distance(position) <= radius) return false; + } + return true; + } + + private static double share(double part, double whole) { + if (whole <= 0.0D) return 0.0D; + return Math.clamp(part / whole, 0.0D, 1.0D); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/package-info.java new file mode 100644 index 00000000..aca6d5de --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/package-info.java @@ -0,0 +1,7 @@ +/** + * Rates how likely a survivor is to be haunted. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.creek.dread; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/package-info.java new file mode 100644 index 00000000..b23cc459 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/package-info.java @@ -0,0 +1,7 @@ +/** + * The creek: a second, server-controlled figure next to the slender. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.creek; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java new file mode 100644 index 00000000..293cdebe --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java @@ -0,0 +1,40 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; + +import java.util.UUID; + +/** + * What a state can do to the survivors, beyond moving the creek's body. + *

    + * The states only know survivors by id. Whoever runs the creek looks the player up and applies the + * consequence, so the states stay free of server objects. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public interface CreekActions { + + /** + * The creek caught a survivor. + * + * @param survivor the id of the survivor + */ + void caught(UUID survivor); + + /** + * The patrolling creek picked out a survivor. + * + * @param survivor the id of the survivor + */ + void selected(UUID survivor); + + /** + * The creek vanished on purpose, in front of whoever is nearby. + * + * @param where where it vanished + */ + void vanished(Pos where); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java new file mode 100644 index 00000000..83a0aec7 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java @@ -0,0 +1,104 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.world.RouteProvider; +import net.onelitefeather.cygnus.creek.world.SpotFinder; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.random.RandomGenerator; + +/** + * Everything a state needs for one step. + *

    + * A fresh context is built for every step. It holds no server objects, so the states can be tested + * without a running server. + *

    + * + * @param now the current time in milliseconds + * @param survivors the survivors of the round + * @param body the creek's body + * @param route decides where the creek walks next + * @param spots finds places where nobody can see the creek + * @param actions what the states can do to a survivor + * @param config the settings + * @param random the random source + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public record CreekContext( + long now, + List survivors, + CreekBody body, + RouteProvider route, + SpotFinder spots, + CreekActions actions, + CreekConfig config, + RandomGenerator random +) { + + /** + * Looks up a survivor by id. + * + * @param id the id to look for + * @return the survivor, or empty if they are no longer in the round + */ + public Optional survivor(UUID id) { + for (SurvivorView view : this.survivors) { + if (view.id().equals(id)) return Optional.of(view); + } + return Optional.empty(); + } + + /** + * The ids of every survivor. + * + * @return the ids + */ + public Set survivorIds() { + Set ids = new HashSet<>(); + for (SurvivorView view : this.survivors) { + ids.add(view.id()); + } + return Set.copyOf(ids); + } + + /** + * Where every survivor's eyes are. + * + * @return the eye positions + */ + public List observerEyes() { + return this.survivors.stream().map(SurvivorView::eyes).toList(); + } + + /** + * The highest dread among the survivors. + * + * @return the dread, {@code 0} without survivors + */ + public double highestDread() { + return this.survivors.stream().mapToDouble(SurvivorView::dread).max().orElse(0.0D); + } + + /** + * Checks that no survivor is closer to a point than the given distance. + * + * @param point the point to check + * @param distance the distance to keep, in blocks + * @return {@code true} if every survivor is at least that far away + */ + public boolean farFromAll(Pos point, double distance) { + for (SurvivorView view : this.survivors) { + if (view.position().distance(point) < distance) return false; + } + return true; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java new file mode 100644 index 00000000..a873243f --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java @@ -0,0 +1,26 @@ +package net.onelitefeather.cygnus.creek.state; + +/** + * One phase of the creek's behaviour, such as patrolling, stalking or hunting. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public interface CreekState { + + /** + * Gets the body ready for this state. Called once, when the creek switches to it. + * + * @param ctx the current step + */ + void enter(CreekContext ctx); + + /** + * Runs one step. + * + * @param ctx the current step + * @return {@code this} to carry on, or the state to switch to + */ + CreekState tick(CreekContext ctx); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java new file mode 100644 index 00000000..9660644a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.onelitefeather.cygnus.creek.body.CreekBody; + +import java.util.Set; + +/** + * A variant whose stalk or hunt is over. It hides the creek and waits to be cleaned up. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class DoneState implements CreekState { + + /** There is nothing to remember in this state, so one instance serves every variant. */ + public static final DoneState INSTANCE = new DoneState(); + + private DoneState() { + } + + @Override + public void enter(CreekContext ctx) { + CreekBody body = ctx.body(); + body.stop(); + body.setAggressive(false); + body.setFrozen(false); + body.showTo(Set.of()); + } + + @Override + public CreekState tick(CreekContext ctx) { + return this; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java new file mode 100644 index 00000000..8aae06f6 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java @@ -0,0 +1,134 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreekBody; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * The creek chases one survivor, but only moves while they are not looking at it. + *

    + * Look at it and it freezes. Let it get close enough and you are caught. The hunt is over after a + * catch, when time runs out or when the survivor is gone. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class HuntState implements CreekState { + + /** How close to the survivor the creek lands after a shortcut, at the least, in blocks. */ + static final double NEAR_MIN = 6.0D; + + /** How close to the survivor the creek lands after a shortcut, at the most, in blocks. */ + static final double NEAR_MAX = 10.0D; + + /** How much closer the creek has to get to count as getting anywhere, in blocks. */ + static final double PROGRESS = 0.5D; + + private final UUID target; + private final long endsAt; + private double bestDistance = Double.MAX_VALUE; + private long progressSince; + + /** + * Sets up the hunt. + * + * @param target the survivor being hunted + * @param endsAt when the hunt is over, in milliseconds + */ + public HuntState(UUID target, long endsAt) { + this.target = target; + this.endsAt = endsAt; + } + + /** + * Starts a hunt that lasts {@code huntMaxSeconds}. + * + * @param target the survivor to hunt + * @param ctx the current step + * @return the state + */ + static HuntState starting(UUID target, CreekContext ctx) { + return new HuntState(target, ctx.now() + ctx.config().huntMaxSeconds() * 1000L); + } + + /** + * Tells who is being hunted. + * + * @return the survivor's id + */ + public UUID target() { + return this.target; + } + + /** + * Tells when the hunt is over. + * + * @return the time in milliseconds + */ + public long endsAt() { + return this.endsAt; + } + + @Override + public void enter(CreekContext ctx) { + CreekBody body = ctx.body(); + body.showTo(Set.of(this.target)); + body.setAggressive(true); + body.setFrozen(false); + this.progressSince = ctx.now(); + } + + @Override + public CreekState tick(CreekContext ctx) { + Optional found = ctx.survivor(this.target); + if (found.isEmpty() || ctx.now() >= this.endsAt) return DoneState.INSTANCE; + + SurvivorView view = found.get(); + CreekConfig config = ctx.config(); + CreekBody body = ctx.body(); + + if (view.seesCreek()) { + body.setFrozen(true); + body.stop(); + this.progressSince = ctx.now(); + return this; + } + body.setFrozen(false); + + double distance = body.position().distance(view.position()); + if (distance <= config.catchDistance()) { + ctx.actions().caught(this.target); + return DoneState.INSTANCE; + } + + if (distance < this.bestDistance - PROGRESS) { + this.bestDistance = distance; + this.progressSince = ctx.now(); + } else if (ctx.now() - this.progressSince >= config.stuckMillis()) { + this.shortcut(ctx, view); + return this; + } + + body.lookAt(view.eyes()); + body.moveTo(view.position(), config.huntSpeed()); + return this; + } + + /** + * When walking there gets the creek nowhere, it jumps close to the survivor instead, out of their view. + */ + private void shortcut(CreekContext ctx, SurvivorView view) { + ctx.spots() + .beside(view.position(), NEAR_MIN, NEAR_MAX, ctx.config().stalkMinAngle(), 180.0D, + List.of(view.eyes()), NEAR_MIN, ctx.random()) + .ifPresent(ctx.body()::teleport); + this.bestDistance = Double.MAX_VALUE; + this.progressSince = ctx.now(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java new file mode 100644 index 00000000..20f53da4 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java @@ -0,0 +1,252 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.world.RouteStep; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * The creek on its rounds: it walks its routes, and every survivor can see it. + *

    + * Come within {@link #SELECT_RADIUS} of it and it picks out whoever is closest, stares at them for a + * second and then freezes them or flings them away. After that it simply walks on and leaves + * everyone alone for {@link #SELECT_COOLDOWN_MILLIS}. Spot it from farther away and it stops and + * looks back at you for a moment. At some waypoints it rests for a while, either because the route + * says so or just by chance; whichever takes longer wins. + *

    + *

    + * At the end of a route no other route links to, it may vanish instead of resting there, which + * blinds anyone close by. It stays away for as long as it would have rested and then turns up + * somewhere else on the map, so it does not keep pacing one stretch of routes all round. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class PatrolState implements CreekState { + + /** How close a survivor has to come before the creek picks them out, in blocks. */ + static final double SELECT_RADIUS = 4.0D; + + /** How long it stares at the chosen survivor before anything happens, in milliseconds. */ + static final long STARE_MILLIS = 1000L; + + /** How long it leaves everyone alone after picking someone out, in milliseconds. */ + static final long SELECT_COOLDOWN_MILLIS = 10_000L; + + /** How likely it vanishes on reaching a dead end instead of resting there. */ + static final double DEAD_END_VANISH_CHANCE = 0.5D; + + /** How close counts as having reached a waypoint, in blocks. */ + static final double ARRIVED = 2.0D; + + /** How far it has to get to count as moving at all, in blocks. */ + static final double PROGRESS = 0.5D; + + private @Nullable RouteStep goal; + private boolean watched; + private long pausedUntil; + private long restingUntil; + private Pos progressAt = Pos.ZERO; + private long progressSince; + private Set shownTo = Set.of(); + private @Nullable UUID staring; + private long stareUntil; + private long selectAllowedAt; + + /** + * Tells who the creek is staring at right now. + * + * @return the survivor's id, or empty while it stares at nobody + */ + public Optional staring() { + return Optional.ofNullable(this.staring); + } + + /** + * Tells when the creek may pick someone out again. + * + * @return the time in milliseconds + */ + public long selectAllowedAt() { + return this.selectAllowedAt; + } + + @Override + public void enter(CreekContext ctx) { + CreekBody body = ctx.body(); + body.setAggressive(false); + body.setFrozen(false); + this.showToSurvivors(ctx); + this.markProgress(ctx.now(), body.position()); + } + + @Override + public CreekState tick(CreekContext ctx) { + CreekConfig config = ctx.config(); + CreekBody body = ctx.body(); + Pos here = body.position(); + this.showToSurvivors(ctx); + + if (this.stare(ctx)) return this; + if (ctx.now() >= this.selectAllowedAt && this.select(ctx, here)) return this; + + Optional watcher = ctx.survivors().stream().filter(SurvivorView::seesCreek).findFirst(); + if (watcher.isEmpty()) { + this.watched = false; + } else if (this.pause(ctx, watcher.get())) { + return this; + } + + if (ctx.now() < this.restingUntil) { + body.stop(); + return this; + } + RouteStep reached = this.goal; + if (reached != null && here.distance(reached.target()) < ARRIVED) { + this.goal = null; + if (reached.deadEnd() && ctx.random().nextDouble() < DEAD_END_VANISH_CHANCE) { + ctx.actions().vanished(here); + // Gone for as long as it would have rested here, then back somewhere else. + return new VanishState(ctx.now() + reached.pauseMillis()); + } + long rest = this.restMillis(ctx, reached); + if (rest > 0) { + this.restingUntil = ctx.now() + rest; + body.stop(); + return this; + } + } + + if (this.needsNewGoal(ctx, here)) { + this.goal = ctx.route().next(here, _ -> true, ctx.random()).orElse(null); + this.markProgress(ctx.now(), here); + } + RouteStep next = this.goal; + if (next == null) { + body.stop(); + return this; + } + body.moveTo(next.target(), config.wanderSpeed()); + return this; + } + + /** + * Keeps staring at the chosen survivor. Once the second is up, the consequence hits them. + * + * @return {@code true} while the stare lasts + */ + private boolean stare(CreekContext ctx) { + UUID target = this.staring; + if (target == null) return false; + Optional view = ctx.survivor(target); + if (view.isEmpty()) { + // They left before the stare was over: nothing happens, and no cooldown starts. + this.staring = null; + return false; + } + CreekBody body = ctx.body(); + if (ctx.now() < this.stareUntil) { + body.stop(); + body.lookAt(view.get().eyes()); + return true; + } + this.staring = null; + this.selectAllowedAt = ctx.now() + SELECT_COOLDOWN_MILLIS; + // They are most likely still looking at it. Walk on rather than stop again to look back. + this.watched = true; + this.markProgress(ctx.now(), body.position()); + ctx.actions().selected(target); + return false; + } + + /** + * Picks out the closest survivor within {@link #SELECT_RADIUS} and starts staring at them. + * + * @return {@code true} if someone was picked out + */ + private boolean select(CreekContext ctx, Pos here) { + SurvivorView nearest = null; + double best = SELECT_RADIUS; + for (SurvivorView view : ctx.survivors()) { + double distance = view.position().distance(here); + if (distance <= best) { + best = distance; + nearest = view; + } + } + if (nearest == null) return false; + this.staring = nearest.id(); + this.stareUntil = ctx.now() + STARE_MILLIS; + ctx.body().stop(); + ctx.body().lookAt(nearest.eyes()); + this.markProgress(ctx.now(), here); + return true; + } + + /** + * Stops and looks back for a moment when someone spots the creek from afar. + * + * @return {@code true} while it is looking back + */ + private boolean pause(CreekContext ctx, SurvivorView watcher) { + if (!this.watched) { + this.watched = true; + this.pausedUntil = ctx.now() + ctx.config().wanderPauseMillis(); + } + if (ctx.now() >= this.pausedUntil) return false; + + ctx.body().stop(); + ctx.body().lookAt(watcher.eyes()); + // Keep heading for the same point. Dropping it made the route hand out the next one, so the + // creek skipped points or turned around. Standing still on purpose is not being stuck. + this.markProgress(ctx.now(), ctx.body().position()); + return true; + } + + /** + * How long to rest at the point just reached: the route's pause, or a random stop if that + * is longer. + */ + private long restMillis(CreekContext ctx, RouteStep reached) { + CreekConfig config = ctx.config(); + int rest = reached.pauseMillis(); + if (config.randomStopChance() > 0.0D && ctx.random().nextDouble() < config.randomStopChance()) { + int spread = config.randomStopMaxMillis() - config.randomStopMinMillis(); + rest = Math.max(rest, config.randomStopMinMillis() + ctx.random().nextInt(spread + 1)); + } + return rest; + } + + private boolean needsNewGoal(CreekContext ctx, Pos here) { + RouteStep current = this.goal; + if (current == null || here.distance(current.target()) < ARRIVED) return true; + if (here.distance(this.progressAt) >= PROGRESS) { + this.markProgress(ctx.now(), here); + return false; + } + return ctx.now() - this.progressSince >= ctx.config().stuckMillis(); + } + + /** + * Shows the creek to exactly the current survivors. Anyone who stops being one (dead, or now + * the slender) loses sight of it right away. + */ + private void showToSurvivors(CreekContext ctx) { + Set survivors = ctx.survivorIds(); + if (survivors.equals(this.shownTo)) return; + this.shownTo = survivors; + ctx.body().showTo(survivors); + } + + private void markProgress(long now, Pos here) { + this.progressAt = here; + this.progressSince = now; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java new file mode 100644 index 00000000..1e88a651 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java @@ -0,0 +1,155 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreekBody; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.random.RandomGenerator; + +/** + * The creek shadows one survivor from a distance, and only that survivor can see it. + *

    + * Look at it and it holds your gaze for a moment, then it is gone and turns up somewhere else, + * just out of view. The longer the stalk lasts, the closer it creeps: it starts at + * {@code stalkMinDistance} to {@code stalkMaxDistance} away and ends at {@link #END_MIN_DISTANCE} + * to {@link #END_MAX_DISTANCE}. The stalk is over when time runs out or the survivor is gone. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class StalkState implements CreekState { + + /** How close the creek gets by the end of a stalk, in blocks. */ + static final double END_MIN_DISTANCE = 8.0D; + + /** How far away it may still be at the end of a stalk, in blocks. */ + static final double END_MAX_DISTANCE = 15.0D; + + private final UUID target; + private final long endsAt; + private long startedAt; + private long seenSince = -1L; + + /** + * Sets up the stalk. + * + * @param target the survivor being stalked + * @param endsAt when the stalk is over, in milliseconds + */ + public StalkState(UUID target, long endsAt) { + this.target = target; + this.endsAt = endsAt; + } + + /** + * Starts a stalk that lasts somewhere between {@code stalkMinSeconds} and + * {@code stalkMaxSeconds}. + * + * @param target the survivor to stalk + * @param now the current time in milliseconds + * @param config the settings + * @param random the random source + * @return the state + */ + public static StalkState starting(UUID target, long now, CreekConfig config, RandomGenerator random) { + long seconds = random.nextLong(config.stalkMinSeconds(), config.stalkMaxSeconds() + 1L); + return new StalkState(target, now + seconds * 1000L); + } + + /** + * Tells who is being stalked. + * + * @return the survivor's id + */ + public UUID target() { + return this.target; + } + + /** + * Tells when the stalk is over. + * + * @return the time in milliseconds + */ + public long endsAt() { + return this.endsAt; + } + + @Override + public void enter(CreekContext ctx) { + this.startedAt = ctx.now(); + CreekBody body = ctx.body(); + body.stop(); + body.setAggressive(false); + body.setFrozen(false); + body.showTo(Set.of(this.target)); + } + + @Override + public CreekState tick(CreekContext ctx) { + Optional found = ctx.survivor(this.target); + if (found.isEmpty() || ctx.now() >= this.endsAt) return DoneState.INSTANCE; + + SurvivorView view = found.get(); + CreekConfig config = ctx.config(); + if (view.dread() >= config.huntThreshold()) return HuntState.starting(this.target, ctx); + + CreekBody body = ctx.body(); + body.lookAt(view.eyes()); + + if (view.seesCreek()) { + if (this.seenSince < 0) this.seenSince = ctx.now(); + if (ctx.now() - this.seenSince >= config.stalkRevealMillis() && this.relocate(ctx, view)) { + this.seenSince = -1L; + } + return this; + } + this.seenSince = -1L; + + double distance = body.position().distance(view.position()); + if (distance < this.minDistance(ctx) || distance > this.maxDistance(ctx)) { + this.relocate(ctx, view); + } + return this; + } + + /** + * Moves the creek to a new spot just outside the survivor's view. + * + * @return {@code false} if there was no free spot in this step + */ + private boolean relocate(CreekContext ctx, SurvivorView view) { + CreekConfig config = ctx.config(); + double min = this.minDistance(ctx); + // The band's own minimum keeps the creek off the survivor. The fixed personal space would + // find no spot at all once the band comes closer than that. + Optional spot = ctx.spots().beside(view.position(), min, this.maxDistance(ctx), + config.stalkMinAngle(), config.stalkMaxAngle(), List.of(view.eyes()), min, ctx.random()); + spot.ifPresent(ctx.body()::teleport); + return spot.isPresent(); + } + + /** + * How far into the stalk it is: 0 at the start, 1 at the end. + */ + private double progress(long now) { + long length = this.endsAt - this.startedAt; + if (length <= 0) return 1.0D; + return Math.clamp((double) (now - this.startedAt) / length, 0.0D, 1.0D); + } + + private double minDistance(CreekContext ctx) { + double start = ctx.config().stalkMinDistance(); + return start + (END_MIN_DISTANCE - start) * this.progress(ctx.now()); + } + + private double maxDistance(CreekContext ctx) { + double start = ctx.config().stalkMaxDistance(); + return start + (END_MAX_DISTANCE - start) * this.progress(ctx.now()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java new file mode 100644 index 00000000..549d3186 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java @@ -0,0 +1,42 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; + +import java.util.UUID; + +/** + * A snapshot of one survivor, taken once per step. + * + * @param id the survivor's id + * @param position where the survivor's feet are, including where they are looking + * @param dread how likely the survivor is to be haunted, from 0 to 1 + * @param seesCreek whether the survivor can see the creek right now + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public record SurvivorView(UUID id, Pos position, double dread, boolean seesCreek) { + + /** How high a standing player's eyes are, in blocks. */ + public static final double EYE_HEIGHT = 1.62D; + + /** + * Where the survivor's eyes are. + * + * @return the eye position, looking where the survivor looks + */ + public Pos eyes() { + return this.position.add(0, EYE_HEIGHT, 0); + } + + /** + * The same snapshot, with the given answer to whether the survivor sees the creek. + * + * @param sees whether the survivor sees the creek + * @return this view if nothing changes, otherwise a copy + */ + public SurvivorView withSeesCreek(boolean sees) { + return sees == this.seesCreek ? this : new SurvivorView(this.id, this.position, this.dread, sees); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java new file mode 100644 index 00000000..6cf2a765 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java @@ -0,0 +1,90 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.creek.body.CreekBody; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * The creek is gone for a while: at the start of a round, or for good once it sits out the rest + * of it. When it comes back, it turns up somewhere nobody is looking. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class VanishState implements CreekState { + + private final long until; + + /** + * Sets up a vanish that ends at a fixed time. + * + * @param until when the creek may come back, in milliseconds + */ + public VanishState(long until) { + this.until = until; + } + + /** + * A vanish that never ends, for when the creek sits out the rest of the round. + * + * @return the state + */ + public static VanishState forever() { + return new VanishState(Long.MAX_VALUE); + } + + /** + * Tells when the creek may come back. + * + * @return the time in milliseconds, {@link Long#MAX_VALUE} for {@link #forever()} + */ + public long until() { + return this.until; + } + + /** + * Tells whether this vanish never ends. + * + * @return {@code true} for {@link #forever()} + */ + public boolean isForever() { + return this.until == Long.MAX_VALUE; + } + + @Override + public void enter(CreekContext ctx) { + CreekBody body = ctx.body(); + body.stop(); + body.setAggressive(false); + body.setFrozen(false); + body.showTo(Set.of()); + } + + @Override + public CreekState tick(CreekContext ctx) { + if (ctx.now() < this.until) return this; + + List observers = ctx.observerEyes(); + double distance = ctx.config().respawnMinDistance(); + // Any point of the route will do. Asking the route for its next point would only offer the + // neighbours of where the creek disappeared, and that is often right next to a survivor. + // Checking them in random order and taking the first hidden one picks just as fairly as + // checking them all, but usually stops after one or two. + List points = new ArrayList<>(ctx.route().points()); + Collections.shuffle(points, ctx.random()); + for (Pos point : points) { + Optional spot = ctx.spots().hiddenSpotAt(point, observers, distance); + if (spot.isPresent()) { + ctx.body().teleport(spot.get()); + return new PatrolState(); + } + } + return this; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java new file mode 100644 index 00000000..6d635664 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java @@ -0,0 +1,7 @@ +/** + * How the creek behaves, one state at a time: patrolling, stalking, hunting, vanishing and done. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.creek.state; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java new file mode 100644 index 00000000..ba50be54 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java @@ -0,0 +1,129 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.creek.CreekLinks; +import net.onelitefeather.cygnus.common.creek.CreekRoute; + +import java.util.ArrayList; +import java.util.List; + +/** + * The creek routes of a map, with the links between them worked out once up front, see + * {@link CreekLinks}. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekPaths { + + private final List routes; + private final CreekLinks links; + private final List allPoints; + + private CreekPaths(List routes, CreekLinks links) { + this.routes = routes; + this.links = links; + List points = new ArrayList<>(); + for (CreekRoute route : routes) { + route.points().forEach(point -> points.add(toPos(point.position()))); + } + this.allPoints = List.copyOf(points); + } + + /** + * Takes the routes of a map and works out the links between them. + * + * @param routes the valid routes of the map + * @param linkDistance how close an end has to be to a point of another route to link to it, in blocks + * @return the paths + */ + public static CreekPaths of(List routes, double linkDistance) { + List frozenRoutes = List.copyOf(routes); + return new CreekPaths(frozenRoutes, CreekLinks.of(frozenRoutes, linkDistance)); + } + + /** + * Tells whether the map has no routes at all. + * + * @return {@code true} without routes + */ + public boolean isEmpty() { + return this.routes.isEmpty(); + } + + /** + * How many routes there are. + * + * @return the number of routes + */ + public int size() { + return this.routes.size(); + } + + /** + * One of the routes. + * + * @param index the route's index + * @return the route + */ + public CreekRoute route(int index) { + return this.routes.get(index); + } + + /** + * How many points a route has. + * + * @param route the route's index + * @return the number of points + */ + public int pointCount(int route) { + return this.routes.get(route).points().size(); + } + + /** + * One point of a route. + * + * @param route the route's index + * @param index the point's index + * @return the point + */ + public Pos point(int route, int index) { + return toPos(this.routes.get(route).points().get(index).position()); + } + + /** + * How long the creek rests at one point of a route. + * + * @param route the route's index + * @param index the point's index + * @return the pause, in milliseconds + */ + public int pauseMillis(int route, int index) { + return this.routes.get(route).points().get(index).pauseMillis(); + } + + /** + * The points linked to a point, always in the same order. + * + * @param node the point + * @return the linked points, empty if there are none + */ + public List links(CreekLinks.Node node) { + return this.links.of(node); + } + + /** + * Every point of every route. + * + * @return the points, unmodifiable + */ + public List allPoints() { + return this.allPoints; + } + + private static Pos toPos(Vec point) { + return new Pos(point.x(), point.y(), point.z()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java new file mode 100644 index 00000000..2bdb24db --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java @@ -0,0 +1,65 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.onelitefeather.cygnus.gaze.SlenderGaze; + +/** + * Works out whether a survivor can see the creek. + *

    + * It uses the same view cone as {@link SlenderGaze}: close enough, and not too far off to the side + * of where the survivor is looking. On top of that it checks the line of sight, because the creek + * is usually far away and often behind trees. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekSight { + + private final SlenderGaze cone; + + /** + * Sets up the check with the given cone. + * + * @param range how far away the creek can still be noticed, in blocks + * @param viewAngle how far off to the side it can still be noticed, in degrees from the view centre + */ + public CreekSight(int range, int viewAngle) { + // SlenderGaze wants a close distance below its range. All that matters here is "inside + // the cone or not", and zero keeps the whole range. + this.cone = new SlenderGaze(range, 0, viewAngle); + } + + /** + * Checks whether a point is inside someone's view cone. Blocks in between do not matter here. + * + * @param observer where the observer's eyes are, including where they are looking + * @param target the point to check + * @return {@code true} if the point is inside the cone + */ + public boolean inView(Pos observer, Point target) { + return this.cone.levelOf(observer, target.asPos()) != SlenderGaze.NONE; + } + + /** + * Checks whether a player can see the creek: it is inside their view cone and no block is in the + * way. + * + * @param observer the player + * @param creek the creek's entity + * @return {@code true} if the player can see the creek + */ + public boolean sees(Player observer, Entity creek) { + Instance instance = observer.getInstance(); + if (instance == null || !instance.equals(creek.getInstance())) return false; + + Pos eyes = observer.getPosition().add(0, observer.getEyeHeight(), 0); + Pos body = creek.getPosition().add(0, creek.getEyeHeight(), 0); + return this.inView(eyes, body) && observer.hasLineOfSight(creek); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java new file mode 100644 index 00000000..6ad23409 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java @@ -0,0 +1,24 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; + +import java.util.Optional; + +/** + * Puts a candidate spot onto a floor the creek can stand on. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +@FunctionalInterface +public interface Ground { + + /** + * Finds the floor below or near a candidate spot. + * + * @param candidate the spot to check + * @return the spot on the floor, or empty if there is no room to stand + */ + Optional settle(Pos candidate); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java new file mode 100644 index 00000000..82cf457f --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java @@ -0,0 +1,60 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.Instance; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Finds a floor by looking at the blocks of the round's instance. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class InstanceGround implements Ground { + + /** How many blocks above the candidate the search starts. */ + static final int SEARCH_UP = 4; + + /** How many blocks below the candidate the search goes. */ + static final int SEARCH_DOWN = 8; + + /** How many free blocks the creek needs above its feet. A creaking is almost three blocks tall. */ + static final int HEADROOM = 3; + + private final Supplier instance; + + /** + * Sets up the floor search. + * + * @param instance supplies the round's instance, or {@code null} while there is none + */ + public InstanceGround(Supplier instance) { + this.instance = instance; + } + + @Override + public Optional settle(Pos candidate) { + Instance world = this.instance.get(); + if (world == null || !world.isChunkLoaded(candidate)) return Optional.empty(); + + int x = candidate.blockX(); + int z = candidate.blockZ(); + for (int y = candidate.blockY() + SEARCH_UP; y >= candidate.blockY() - SEARCH_DOWN; y--) { + if (world.getBlock(x, y - 1, z).solid() && hasRoom(world, x, y, z)) { + return Optional.of(new Pos(x + 0.5D, y, z + 0.5D)); + } + } + return Optional.empty(); + } + + private static boolean hasRoom(Instance world, int x, int y, int z) { + for (int offset = 0; offset < HEADROOM; offset++) { + if (world.getBlock(x, y + offset, z).solid()) return false; + } + return true; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java new file mode 100644 index 00000000..0cb34109 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java @@ -0,0 +1,182 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.creek.CreekLinks; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.random.RandomGenerator; + +/** + * Walks the creek along the routes of a map. + *

    + * It remembers the route, the point and the direction. At the end of a route it picks at random + * between turning around and any route linked to that end. Where another route begins or ends in + * the middle of this one, it picks between walking on and turning onto that route. If the creek + * ends up far from its last point, after a teleport for example, it joins back in at the nearest + * point. Each step brings the pause of its point along, except for an end the creek is just leaving + * or a point it just stepped over to from another route. + *

    + *

    + * Because it remembers where its creek is, every creek needs a walker of its own. The + * {@link CreekPaths} behind it can be shared. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class PathRoute implements RouteProvider { + + /** Farther than this from its last waypoint, the creek must have been moved and joins back in, in blocks. */ + static final double REJOIN_DISTANCE = 8.0D; + + private final CreekPaths paths; + private @Nullable Cursor cursor; + + /** + * Sets up the route walker. + * + * @param paths the routes of the map + */ + public PathRoute(CreekPaths paths) { + this.paths = paths; + } + + @Override + public List points() { + return this.paths.allPoints(); + } + + @Override + public Optional next(Pos current, Predicate allowed, RandomGenerator random) { + Cursor last = this.cursor; + if (last == null || current.distance(this.point(last)) > REJOIN_DISTANCE) { + return this.rejoin(current, allowed, random); + } + Cursor ahead = this.advance(last, random); + if (allowed.test(this.point(ahead))) return this.moveTo(ahead); + + Cursor back = this.behind(last); + if (back != null && allowed.test(this.point(back))) return this.moveTo(back); + return Optional.empty(); + } + + @Override + public String describe() { + Cursor last = this.cursor; + if (last == null) return ""; + return this.paths.route(last.route()).name() + " " + (last.index() + 1) + "/" + this.paths.pointCount(last.route()) + + " " + (last.direction() > 0 ? "→" : "←"); + } + + private Optional rejoin(Pos current, Predicate allowed, RandomGenerator random) { + CreekLinks.Node best = null; + double bestDistance = Double.MAX_VALUE; + for (int route = 0; route < this.paths.size(); route++) { + for (int index = 0; index < this.paths.pointCount(route); index++) { + Pos point = this.paths.point(route, index); + if (!allowed.test(point)) continue; + double distance = point.distance(current); + if (distance < bestDistance) { + bestDistance = distance; + best = new CreekLinks.Node(route, index); + } + } + } + if (best == null) return Optional.empty(); + return this.moveTo(this.enter(best, false, random)); + } + + /** + * Picks the next waypoint. The last choice is always to carry on the way it is going, or to + * turn around at the end of a route; every link from here is another choice. + */ + private Cursor advance(Cursor from, RandomGenerator random) { + int nextIndex = from.index() + from.direction(); + boolean atEnd = nextIndex < 0 || nextIndex >= this.paths.pointCount(from.route()); + // An end only offers its links once the creek gets there. In the middle, a crossing offers + // them on the way through, unless the creek has only just come over from the other route. + boolean crossing = !atEnd && !from.viaLink() && !this.isEnd(from); + List links = atEnd || crossing ? this.paths.links(this.node(from)) : List.of(); + int choice = atEnd || !links.isEmpty() ? random.nextInt(links.size() + 1) : 0; + if (choice < links.size()) return this.enter(links.get(choice), true, random); + if (!atEnd) return new Cursor(from.route(), nextIndex, from.direction(), false); + Cursor back = this.behind(from); + return back != null ? back : from; + } + + /** + * Puts the creek on a point. At an end there is only one way into the route, in the middle it + * picks a way at random. + */ + private Cursor enter(CreekLinks.Node node, boolean viaLink, RandomGenerator random) { + int lastIndex = this.paths.pointCount(node.route()) - 1; + int direction = node.index() == 0 ? 1 : node.index() == lastIndex ? -1 : (random.nextBoolean() ? 1 : -1); + return new Cursor(node.route(), node.index(), direction, viaLink); + } + + private @Nullable Cursor behind(Cursor from) { + int direction = -from.direction(); + int index = from.index() + direction; + if (index < 0 || index >= this.paths.pointCount(from.route())) return null; + return new Cursor(from.route(), index, direction, false); + } + + private Optional moveTo(Cursor next) { + this.cursor = next; + return Optional.of(new RouteStep(this.point(next), this.pauseAt(next), this.isDeadEnd(next))); + } + + /** + * Tells whether the creek is heading for the end of a route that no other route is linked to. + * Walking away from an end does not count. + */ + private boolean isDeadEnd(Cursor cursor) { + return this.reachesEnd(cursor) && this.paths.links(this.node(cursor)).isEmpty(); + } + + /** + * An end the creek walks away from is where it sets off, not where it rests. Neither is a point + * it just stepped over to from another route: it is where it already was. + */ + private int pauseAt(Cursor cursor) { + boolean leavingEnd = this.isEnd(cursor) && !this.reachesEnd(cursor); + if (cursor.viaLink() || leavingEnd) return 0; + return this.paths.pauseMillis(cursor.route(), cursor.index()); + } + + private Pos point(Cursor cursor) { + return this.paths.point(cursor.route(), cursor.index()); + } + + private CreekLinks.Node node(Cursor cursor) { + return new CreekLinks.Node(cursor.route(), cursor.index()); + } + + private boolean isEnd(Cursor cursor) { + return cursor.index() == 0 || cursor.index() == this.paths.pointCount(cursor.route()) - 1; + } + + /** + * Tells whether the creek is walking into an end of its route rather than away from it. + */ + private boolean reachesEnd(Cursor cursor) { + return cursor.direction() > 0 + ? cursor.index() == this.paths.pointCount(cursor.route()) - 1 + : cursor.index() == 0; + } + + /** + * Where the creek is on the routes. + * + * @param route the route's index + * @param index the index of the waypoint it was sent to last + * @param direction {@code 1} towards the end, {@code -1} towards the start + * @param viaLink whether it just stepped over to this point from another route + */ + private record Cursor(int route, int index, int direction, boolean viaLink) { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java new file mode 100644 index 00000000..c830cbd3 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java @@ -0,0 +1,50 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.random.RandomGenerator; + +/** + * Decides where the creek walks next. + *

    + * {@link PathRoute} walks the hand-placed routes of a map. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +@FunctionalInterface +public interface RouteProvider { + + /** + * Picks the next point. + * + * @param current where the creek is + * @param allowed which points the creek may go to + * @param random the random source + * @return the next point and its pause, or empty if none is allowed + */ + Optional next(Pos current, Predicate allowed, RandomGenerator random); + + /** + * Every point this route can hand out. Used to find a place to appear or come back. + * + * @return the points, possibly none + */ + default List points() { + return List.of(); + } + + /** + * Describes where the creek is on this route, for the debug line. + * + * @return a short description, or an empty string if there is nothing to show + */ + default String describe() { + return ""; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java new file mode 100644 index 00000000..9e153321 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java @@ -0,0 +1,27 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; + +/** + * The creek's next waypoint and how long it rests once it gets there. + * + * @param target where the creek walks to + * @param pauseMillis how long it rests after arriving, in milliseconds + * @param deadEnd whether the waypoint is the end of a route with no other route linked to it + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public record RouteStep(Pos target, int pauseMillis, boolean deadEnd) { + + /** + * A step to a waypoint that is no dead end. + * + * @param target where the creek walks to + * @param pauseMillis how long it rests after arriving, in milliseconds + */ + public RouteStep(Pos target, int pauseMillis) { + this(target, pauseMillis, false); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java new file mode 100644 index 00000000..e94634e1 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java @@ -0,0 +1,97 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; + +import java.util.List; +import java.util.Optional; +import java.util.random.RandomGenerator; + +/** + * Finds spots where the creek can appear without anyone seeing it happen. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class SpotFinder { + + /** How many candidate spots are tried per step. */ + static final int ATTEMPTS = 16; + + /** How high the middle of the creek's body is. That is the part a survivor would notice. */ + public static final double BODY_CENTRE = 1.4D; + + private final CreekSight sight; + private final Ground ground; + + /** + * Sets up the spot finder. + * + * @param sight the view check a spot has to pass + * @param ground puts candidates onto a floor + */ + public SpotFinder(CreekSight sight, Ground ground) { + this.sight = sight; + this.ground = ground; + } + + /** + * Checks that nobody would see the creek at a spot. + * + * @param spot where the creek's feet would be + * @param observerEyes the eyes of everyone who could see the creek + * @param personalSpace how far away from everyone the spot has to be + * @return {@code true} if the spot is far enough away and outside every view cone + */ + public boolean isHidden(Pos spot, List observerEyes, double personalSpace) { + Pos body = spot.add(0, BODY_CENTRE, 0); + for (Pos eyes : observerEyes) { + if (eyes.distance(spot) < personalSpace) return false; + if (this.sight.inView(eyes, body)) return false; + } + return true; + } + + /** + * Puts a candidate onto the floor and keeps it only if nobody would see the creek there. + * + * @param candidate the spot to try + * @param observerEyes the eyes of everyone who could see the creek + * @param personalSpace how far away from everyone the spot has to be + * @return the hidden spot, or empty + */ + public Optional hiddenSpotAt(Pos candidate, List observerEyes, double personalSpace) { + return this.ground.settle(candidate).filter(spot -> this.isHidden(spot, observerEyes, personalSpace)); + } + + /** + * Finds a hidden spot off to the side of someone's view. + * + * @param target where the target's feet are; their yaw is where they look + * @param minDistance how close to the target the spot may be at the least + * @param maxDistance how far from the target the spot may be at the most + * @param minAngle how far off to the side of their view it has to be at the least, in degrees + * @param maxAngle how far off to the side of their view it may be at the most, in degrees + * @param observerEyes the eyes of everyone who could see the creek + * @param personalSpace how far away from everyone the spot has to be + * @param random the random source + * @return a hidden spot, or empty if no candidate worked + */ + public Optional beside(Pos target, double minDistance, double maxDistance, double minAngle, + double maxAngle, List observerEyes, double personalSpace, + RandomGenerator random) { + for (int attempt = 0; attempt < ATTEMPTS; attempt++) { + double offset = random.nextDouble(minAngle, maxAngle) * (random.nextBoolean() ? 1.0D : -1.0D); + double distance = random.nextDouble(minDistance, maxDistance); + Optional spot = this.hiddenSpotAt(step(target, target.yaw() + offset, distance), observerEyes, personalSpace); + if (spot.isPresent()) return spot; + } + return Optional.empty(); + } + + private static Pos step(Pos from, double yaw, double distance) { + double radians = Math.toRadians(yaw); + // Minecraft's yaw goes clockwise from south (+Z), so the direction is (-sin, cos). + return new Pos(from.x() - Math.sin(radians) * distance, from.y(), from.z() + Math.cos(radians) * distance); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/package-info.java new file mode 100644 index 00000000..b02235fd --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/package-info.java @@ -0,0 +1,7 @@ +/** + * Everything the creek needs to find its way: view checks, floors, waypoints and hidden spots. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.creek.world; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java index 6693b14b..974f6755 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java +++ b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java @@ -8,6 +8,7 @@ import net.minestom.server.timer.TaskSchedule; import net.minestom.server.world.DimensionType; import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.common.creek.CreekRoutesFile; import net.onelitefeather.cygnus.common.dimension.BlendedAtmosphere; import net.onelitefeather.cygnus.common.dimension.DimensionAtmosphere; import net.onelitefeather.cygnus.common.dimension.DimensionFactory; @@ -161,7 +162,8 @@ private static String toKeyValue(String name) { * Reads the game map from its map file. * *

    This also merges in the page faces from the sibling {@code pages.json}, if one exists, via - * {@link PageFacesFile#loadInto(Path, GameMap)}.

    + * {@link PageFacesFile#loadInto(Path, GameMap)}. The creek routes come from the sibling + * {@code creek.json} via {@link CreekRoutesFile#loadInto(Path, GameMap)}.

    * * @return the loaded game map * @throws IllegalStateException if the file cannot be read @@ -169,7 +171,8 @@ private static String toKeyValue(String name) { private GameMap readGameMap() { GameMap map = this.fileHandler.load(this.gameEntry.getMapFile(), GameMap.class) .orElseThrow(() -> new IllegalStateException("Failed to load GameMap from file: " + this.gameEntry.getMapFile())); - return PageFacesFile.loadInto(this.gameEntry.getMapFile(), map); + Path mapFile = this.gameEntry.getMapFile(); + return CreekRoutesFile.loadInto(mapFile, PageFacesFile.loadInto(mapFile, map)); } public void loadGameMap() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java index 547e4909..230e37a0 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java @@ -24,8 +24,8 @@ * left, the way the static in Slender: The Eight Pages tightens with every page.

    * *

    Two things carry it. A carpet runs for as long as the round does and closes the gap between - * its bursts from {@link GameConfig#slenderStaticQuietInterval()} down to - * {@link GameConfig#slenderStaticFranticInterval()} as the pages disappear. On top of it, every + * its bursts from {@link GameConfig.SlenderStatic#quietInterval()} down to + * {@link GameConfig.SlenderStatic#franticInterval()} as the pages disappear. On top of it, every * single find lands as a burst of its own, so he knows a page went the moment it went instead of * on the next beat of the carpet.

    * diff --git a/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java b/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java index 2af9c028..4dbdf674 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java @@ -29,7 +29,6 @@ *
  • Phase 2 (20% < TTL ≤ 50%): Warning chime every 3 seconds (60 ticks) with standard pitch.
  • *
  • Phase 3 (TTL ≤ 20%): Critical rapid chime every 1 second (20 ticks) with lower pitch and quieter volume.
  • * - *

    * * @author TheMeinerLP * @author theEvilReaper diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java index 38cf62d5..a82ff399 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java @@ -136,4 +136,22 @@ public void switchToRegenerating() { if (state == State.REGENERATING) return; state = State.REGENERATING; } + + /** + * Empties the bar instantly and lets it regenerate. + *

    + * Used when the creek catches a survivor. Sprinting is blocked just like after running the + * bar empty, so the survivor cannot escape the slender for a while. + *

    + */ + public void drain() { + this.currentSpeedCount = 0; + player.setExp(0.0F); + if (player.isSprinting()) { + player.setSprinting(false); + EventDispatcher.call(new PlayerStopSprintingEvent(player)); + } + player.setBlockedSprinting(true); + state = State.REGENERATING; + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java new file mode 100644 index 00000000..ff291b44 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -0,0 +1,208 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.potion.PotionEffect; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreakingBody; +import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; +import net.onelitefeather.cygnus.creek.state.Contexts; +import net.onelitefeather.cygnus.creek.state.DoneState; +import net.onelitefeather.cygnus.creek.state.CreekContext; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.HuntState; +import net.onelitefeather.cygnus.creek.state.StalkState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.state.VanishState; +import net.onelitefeather.cygnus.creek.state.PatrolState; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.SpotFinder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekIntegrationTest extends CygnusPlayerTestBase { + + private static Creek creek(CreekBody body, CreekConfig config, CreekState initial) { + CreekSight sight = new CreekSight(config.sightRange(), config.sightViewAngle()); + // nextBoolean() is true for -1, so a selection always ends in the stun. + PatrolHelper patrol = new PatrolHelper(() -> -1L); + CreekRound round = new CreekRound(sight, new SpotFinder(sight, Optional::of), _ -> {}, patrol, config, + new Random(3)); + return new Creek(body, Contexts.route(), round, initial); + } + + /** A snapshot of the players, none of them scared. */ + private static SurvivorSnapshot survivors(Player... players) { + return SurvivorSnapshot.take(List.of(players), (_, _, _) -> 0.0D); + } + + @Test + @DisplayName("Wandering, he is seen by the survivors but never by the slender") + void wanderingIsPublicButNotForTheSlender(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + + creek(body, CreekConfig.DEFAULT, new PatrolState()).tick(survivors(survivor), 0L); + + assertTrue(body.entity().getViewers().contains(survivor)); + assertFalse(body.entity().getViewers().contains(slender)); + } + + @Test + @DisplayName("A survivor looking at him counts as seeing him") + void lookingCountsAsSeeing(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); + creek.tick(survivors(survivor), 0L); + + assertTrue(creek.views(survivors(survivor)).getFirst().seesCreek()); + } + + @Test + @DisplayName("Someone he is hidden from does not see him, however they look") + void hiddenFromNonTargets(Env env) { + Instance instance = env.createFlatInstance(); + Player target = env.createConnection().connect(instance, new Pos(10, 40, 0, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + Creek creek = creek(body, CreekConfig.DEFAULT, new StalkState(target.getUuid(), Long.MAX_VALUE)); + creek.tick(survivors(target, other), 0L); + + SurvivorView otherView = creek.views(survivors(target, other)).stream() + .filter(view -> view.id().equals(other.getUuid())) + .findFirst().orElseThrow(); + assertFalse(otherView.seesCreek()); + } + + @Test + @DisplayName("When the target leaves mid-hunt he is done") + void targetLeavesMidHunt(Env env) { + Instance instance = env.createFlatInstance(); + Player target = env.createConnection().connect(instance, new Pos(0, 40, 0, 180, 0)); + Player other = env.createConnection().connect(instance, new Pos(40, 40, 40)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10)); + Creek creek = creek(body, CreekConfig.DEFAULT, new HuntState(target.getUuid(), Long.MAX_VALUE)); + creek.tick(survivors(target, other), 0L); + + creek.tick(survivors(other), 100L); + + assertInstanceOf(DoneState.class, creek.state()); + assertTrue(body.entity().getViewers().isEmpty()); + } + + @Test + @DisplayName("Vanishing for good hides him from everyone for the rest of the round") + void vanishForGood(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 30)); + Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); + creek.tick(survivors(survivor), 0L); + + creek.vanishForGood(survivors(survivor), 100L); + CreekState vanished = creek.state(); + creek.vanishForGood(survivors(survivor), 200L); + + assertTrue(((VanishState) vanished).isForever()); + assertSame(vanished, creek.state(), "calling it again changes nothing"); + assertFalse(body.isVisibleTo(survivor.getUuid())); + } + + @Test + @DisplayName("A state selecting a survivor hands them to the patrol helper") + void selectionReachesTheConsequence(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + CreekState selecting = new CreekState() { + @Override + public void enter(CreekContext ctx) { + } + + @Override + public CreekState tick(CreekContext ctx) { + ctx.actions().selected(survivor.getUuid()); + return this; + } + }; + + creek(body, CreekConfig.DEFAULT, selecting).tick(survivors(survivor), 0L); + + assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("Vanishing blinds the survivors nearby, but not the ones the creek leaves alone") + void vanishSparesTheIgnored(Env env) { + Instance instance = env.createFlatInstance(); + Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player other = env.createConnection().connect(instance, new Pos(3, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 2)); + CreekState vanishing = new CreekState() { + @Override + public void enter(CreekContext ctx) { + } + + @Override + public CreekState tick(CreekContext ctx) { + ctx.actions().vanished(ctx.body().position()); + return this; + } + }; + + creek(body, CreekConfig.DEFAULT, vanishing).tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L); + + assertTrue(other.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(haunted.hasEffect(PotionEffect.BLINDNESS)); + } + + @Test + @DisplayName("A haunted survivor does not see the patrolling creek, everyone else does") + void patrolIsHiddenFromTheHaunted(Env env) { + Instance instance = env.createFlatInstance(); + Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(5, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + + creek(body, CreekConfig.DEFAULT, new PatrolState()) + .tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L); + + assertFalse(body.isVisibleTo(haunted.getUuid())); + assertTrue(body.isVisibleTo(other.getUuid())); + } + + @Test + @DisplayName("The patrolling creek never picks out a haunted survivor") + void patrolLeavesTheHauntedAlone(Env env) { + Instance instance = env.createFlatInstance(); + Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(0, 40, 60, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 2)); + Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); + + creek.tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L); + creek.tick(survivors(haunted, other), Set.of(haunted.getUuid()), 1000L); + + assertFalse(haunted.hasEffect(PotionEffect.SLOWNESS)); + assertEquals(2, creek.lastViews().size(), "the snapshots still cover every survivor"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java new file mode 100644 index 00000000..b1819484 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java @@ -0,0 +1,311 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.server.network.packet.server.play.ActionBarPacket; +import net.minestom.server.potion.PotionEffect; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.creek.body.CreakingBody; +import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; +import net.onelitefeather.cygnus.creek.debug.CreekDebug; +import net.onelitefeather.cygnus.creek.state.Contexts; +import net.onelitefeather.cygnus.creek.state.PatrolState; +import net.onelitefeather.cygnus.creek.state.VanishState; +import net.onelitefeather.cygnus.creek.world.PathRoute; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekServiceIntegrationTest extends CygnusPlayerTestBase { + + private final AtomicInteger cleanUps = new AtomicInteger(); + + private final CatchConsequence consequence = new CatchConsequence() { + @Override + public void apply(Player survivor) { + } + + @Override + public void cleanUp() { + cleanUps.incrementAndGet(); + } + }; + + private static final CreekRoute ROUTE = CreekRoute.ofPositions("Test", List.of(new Vec(10, 40, 10), new Vec(20, 40, 10))); + + private CreekService service(Instance instance, Set survivors, List routes, AtomicLong clock) { + return new CreekService(CreekConfig.DEFAULT, () -> survivors, () -> instance, () -> routes, + CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, new RoundClock(clock::get), new Random(3), new CreekDebug()); + } + + @Test + @DisplayName("At the start he is in the world, but out of sight") + void startsOutOfSight(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), new AtomicLong()); + + service.start(); + service.tick(); + + Creek creek = service.creek(); + assertNotNull(creek); + assertInstanceOf(VanishState.class, creek.state()); + assertFalse(creek.body().isVisibleTo(survivor.getUuid())); + service.stop(); + } + + @Test + @DisplayName("Without a usable route the creek stays away") + void staysAwayWithoutRoutes(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreekService service = service(instance, Set.of(survivor), List.of(), new AtomicLong()); + + service.start(); + + assertNull(service.creek()); + } + + @Test + @DisplayName("The round time counts from the start") + void roundTimeCountsFromTheStart(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + AtomicLong clock = new AtomicLong(1000L); + RoundClock roundClock = new RoundClock(clock::get); + CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor), () -> instance, + () -> List.of(ROUTE), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, roundClock, new Random(3), new CreekDebug()); + + service.start(); + clock.set(4000L); + + assertEquals(3000L, roundClock.elapsedMillis()); + service.stop(); + assertEquals(0L, roundClock.elapsedMillis()); + } + + @Test + @DisplayName("Only players watching the debug get the line") + void debugGoesToWatchersOnly(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection watcherConnection = env.createConnection(); + Player watcher = watcherConnection.connect(instance, new Pos(0, 40, 0)); + TestConnection otherConnection = env.createConnection(); + Player other = otherConnection.connect(instance, new Pos(3, 40, 0)); + CreekDebug debug = new CreekDebug(); + debug.toggle(watcher.getUuid()); + CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(watcher, other), () -> instance, + () -> List.of(ROUTE), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, + new RoundClock(new AtomicLong()::get), new Random(3), debug); + Collector watched = watcherConnection.trackIncoming(ActionBarPacket.class); + Collector unwatched = otherConnection.trackIncoming(ActionBarPacket.class); + + service.start(); + service.tick(); + + assertFalse(watched.collect().isEmpty()); + assertTrue(unwatched.collect().isEmpty()); + service.stop(); + } + + @Test + @DisplayName("With routes on the map the creek walks them") + void routesAreUsedWhenThereAreAny(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), new AtomicLong()); + + service.start(); + + Creek creek = service.creek(); + assertNotNull(creek); + assertInstanceOf(PathRoute.class, creek.route()); + service.stop(); + } + + @Test + @DisplayName("At the end he and every variant are removed and every consequence is cleaned up") + void stopRemovesEverythingAndCleansUp(Env env) { + Round round = this.roundWithVariant(env, CreekConfig.DEFAULT); + Creek creek = round.service().creek(); + CreekVariants variants = round.service().variants(); + assertNotNull(creek); + assertNotNull(variants); + Creek variant = variants.running().get(round.scared().getUuid()); + assertNotNull(variant); + + round.service().stop(); + + assertTrue(creek.body().entity().isRemoved()); + assertTrue(variant.body().entity().isRemoved()); + assertNull(round.service().creek()); + assertNull(round.service().variants()); + assertEquals(1, cleanUps.get()); + } + + @Test + @DisplayName("At the end no stun from the patrol is left on a survivor") + void stopClearsTheSelectionEffects(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); + // Walled in closely on every side: there is no room to fling them, so a selection always stuns. + for (int i = -3; i <= 3; i++) { + for (int y = 40; y <= 41; y++) { + instance.setBlock(i, y, -63, Block.STONE); + instance.setBlock(i, y, -56, Block.STONE); + } + } + for (int z = -63; z <= -56; z++) { + for (int y = 40; y <= 41; y++) { + instance.setBlock(-3, y, z, Block.STONE); + instance.setBlock(3, y, z, Block.STONE); + } + } + AtomicLong clock = new AtomicLong(); + CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), clock); + service.start(); + clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); + service.tick(); + Creek creek = service.creek(); + assertNotNull(creek); + assertInstanceOf(PatrolState.class, creek.state()); + + creek.body().teleport(new Pos(0, 40, -58)); + service.tick(); + clock.addAndGet(1000L); + service.tick(); + assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS), "the patrol stunned the survivor"); + + service.stop(); + + assertFalse(survivor.hasEffect(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("After the start delay he patrols, a scared survivor gets a variant and no longer sees the patrol") + void patrolsAndHidesFromTheHaunted(Env env) { + Round round = this.roundWithVariant(env, CreekConfig.DEFAULT); + Creek creek = round.service().creek(); + CreekVariants variants = round.service().variants(); + assertNotNull(creek); + assertNotNull(variants); + assertInstanceOf(PatrolState.class, creek.state()); + assertTrue(variants.running().containsKey(round.scared().getUuid())); + assertTrue(creek.body().isVisibleTo(round.scared().getUuid()), "the variant only just started"); + + round.service().tick(); + + assertFalse(creek.body().isVisibleTo(round.scared().getUuid())); + assertTrue(creek.body().isVisibleTo(round.other().getUuid())); + round.service().stop(); + } + + @Test + @DisplayName("Every creek walks the routes with a walker of its own") + void everyCreekHasItsOwnWalker(Env env) { + Round round = this.roundWithVariant(env, CreekConfig.DEFAULT); + Creek creek = round.service().creek(); + CreekVariants variants = round.service().variants(); + assertNotNull(creek); + assertNotNull(variants); + Creek variant = variants.running().get(round.scared().getUuid()); + assertNotNull(variant); + + assertInstanceOf(PathRoute.class, variant.route()); + assertNotSame(creek.route(), variant.route()); + assertEquals(creek.route().points(), variant.route().points()); + round.service().stop(); + } + + @Test + @DisplayName("Each survivor's dread is worked out once per step, however many creeks there are") + void dreadOncePerStep(Env env) { + Round round = this.roundWithVariant(env, CreekConfig.DEFAULT); + CreekVariants variants = round.service().variants(); + assertNotNull(variants); + assertEquals(1, variants.running().size()); + round.dreadCalls().set(0); + + round.service().tick(); + + assertEquals(2, round.dreadCalls().get()); + round.service().stop(); + } + + @Test + @DisplayName("With the last survivor and the setting off, he is gone for good and every variant ends") + void goneForGoodWithTheLastSurvivor(Env env) { + Round round = this.roundWithVariant(env, Contexts.withoutLastSurvivor()); + Creek creek = round.service().creek(); + CreekVariants variants = round.service().variants(); + assertNotNull(creek); + assertNotNull(variants); + Creek variant = variants.running().get(round.scared().getUuid()); + assertNotNull(variant); + round.service().tick(); + assertInstanceOf(PatrolState.class, creek.state(), + "hiding the patrol from the haunted survivor is not being down to the last one"); + + round.survivors().set(Set.of(round.scared())); + round.service().tick(); + + assertInstanceOf(VanishState.class, creek.state()); + assertTrue(((VanishState) creek.state()).isForever()); + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + round.service().stop(); + } + + /** + * A started round with two survivors far from the route, of whom only the first is scared + * enough for a variant. It runs one step past the start delay: the patrol is out, and the + * variant has just started. + */ + private Round roundWithVariant(Env env, CreekConfig config) { + Instance instance = env.createFlatInstance(); + // Far from the route points, so the vanish finds a hidden spot to come back at. + Player scared = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0)); + AtomicReference> survivors = new AtomicReference<>(Set.of(scared, other)); + AtomicInteger dreadCalls = new AtomicInteger(); + AtomicLong clock = new AtomicLong(); + CreekService service = new CreekService(config, survivors::get, () -> instance, () -> List.of(ROUTE), + CreakingBody::spawn, (id, _, _) -> { + dreadCalls.incrementAndGet(); + return id.equals(scared.getUuid()) ? 0.5D : 0.0D; + }, consequence, + new RoundClock(clock::get), new Random(3), new CreekDebug()); + service.start(); + clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); + service.tick(); + return new Round(service, scared, other, survivors, dreadCalls); + } + + private record Round(CreekService service, Player scared, Player other, AtomicReference> survivors, + AtomicInteger dreadCalls) { + } + +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java new file mode 100644 index 00000000..267db4bd --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java @@ -0,0 +1,216 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreakingBody; +import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; +import net.onelitefeather.cygnus.creek.state.Contexts; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.Ground; +import net.onelitefeather.cygnus.creek.world.SpotFinder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekVariantsIntegrationTest extends CygnusPlayerTestBase { + + private static final CreekConfig CONFIG = CreekConfig.DEFAULT; + private static final CreekSight SIGHT = new CreekSight(CONFIG.sightRange(), CONFIG.sightViewAngle()); + private static final CatchConsequence NO_CATCH = new CatchConsequence() { + @Override + public void apply(Player survivor) { + } + + @Override + public void cleanUp() { + } + }; + private static final PatrolHelper PATROL = new PatrolHelper(new Random(1)); + + private static BiFunction spawner(Instance instance, CreekConfig config, SpotFinder spots) { + CreekRound round = new CreekRound(SIGHT, spots, NO_CATCH, PATROL, config, new Random(3)); + return (spot, initial) -> new Creek(CreakingBody.spawn(instance, spot), Contexts.route(), round, initial); + } + + private static CreekVariants variants(Instance instance, CreekConfig config, Ground ground, long allowedAt) { + SpotFinder spots = new SpotFinder(SIGHT, ground); + return new CreekVariants(config, spots, new Random(5), allowedAt, spawner(instance, config, spots)); + } + + private static SurvivorView view(Player player, double dread) { + return new SurvivorView(player.getUuid(), player.getPosition(), dread, false); + } + + private static Player join(Env env, Instance instance, double x) { + return env.createConnection().connect(instance, new Pos(x, 40, 0, 0, 0)); + } + + @Test + @DisplayName("A variant starts for the survivor with the highest dread and only they see it") + void startsForTheHighestDread(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 10); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + List survivors = List.of(first, second); + List views = List.of(view(first, 0.5D), view(second, 0.3D)); + + variants.tick(new SurvivorSnapshot(survivors, views), 0L); + variants.tick(new SurvivorSnapshot(survivors, views), 100L); + + assertEquals(1, variants.running().size(), "two survivors allow one variant"); + Creek variant = variants.running().get(first.getUuid()); + assertTrue(variant.body().isVisibleTo(first.getUuid())); + assertFalse(variant.body().isVisibleTo(second.getUuid())); + } + + @Test + @DisplayName("Five survivors allow two variants, one per survivor") + void twoVariantsForFiveSurvivors(Env env) { + Instance instance = env.createFlatInstance(); + List survivors = List.of(join(env, instance, 0), join(env, instance, 100), join(env, instance, 200), + join(env, instance, 300), join(env, instance, 400)); + List views = List.of(view(survivors.get(0), 0.5D), view(survivors.get(1), 0.4D), + view(survivors.get(2), 0.3D), view(survivors.get(3), 0.0D), view(survivors.get(4), 0.0D)); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + + variants.tick(new SurvivorSnapshot(survivors, views), 0L); + variants.tick(new SurvivorSnapshot(survivors, views), 100L); + + assertEquals(2, variants.running().size()); + assertTrue(variants.running().containsKey(survivors.get(0).getUuid())); + assertTrue(variants.running().containsKey(survivors.get(1).getUuid())); + } + + @Test + @DisplayName("Nothing starts before the allowed time") + void waitsForTheAllowedTime(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 1000L); + + variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.9D))), 999L); + + assertTrue(variants.running().isEmpty()); + } + + @Test + @DisplayName("After a variant ends, its target has a cooldown") + void cooldownAfterTheEnd(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 10); + List survivors = List.of(first, second); + List views = List.of(view(first, 0.5D), view(second, 0.0D)); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + variants.tick(new SurvivorSnapshot(survivors, views), 0L); + Creek variant = variants.running().get(first.getUuid()); + + // the stalk lasts at most stalkMaxSeconds (90 s) + variants.tick(new SurvivorSnapshot(survivors, views), 100_000L); + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + + // dread 0.5 gives a 30 s cooldown with the default vanish times + variants.tick(new SurvivorSnapshot(survivors, views), 129_999L); + assertTrue(variants.running().isEmpty()); + + variants.tick(new SurvivorSnapshot(survivors, views), 130_000L); + assertEquals(1, variants.running().size()); + } + + @Test + @DisplayName("A variant ends when its target leaves") + void endsWhenTheTargetLeaves(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 10); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + variants.tick(new SurvivorSnapshot(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.0D))), 0L); + Creek variant = variants.running().get(first.getUuid()); + + variants.tick(new SurvivorSnapshot(List.of(second), List.of(view(second, 0.0D))), 100L); + + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + } + + @Test + @DisplayName("A dropping capacity does not cut a running variant short") + void droppingCapacityKeepsRunningVariants(Env env) { + Instance instance = env.createFlatInstance(); + List survivors = List.of(join(env, instance, 0), join(env, instance, 100), join(env, instance, 200), + join(env, instance, 300), join(env, instance, 400)); + List views = List.of(view(survivors.get(0), 0.5D), view(survivors.get(1), 0.4D), + view(survivors.get(2), 0.3D), view(survivors.get(3), 0.0D), view(survivors.get(4), 0.0D)); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + variants.tick(new SurvivorSnapshot(survivors, views), 0L); + + variants.tick(new SurvivorSnapshot(survivors.subList(0, 4), views.subList(0, 4)), 100L); + + assertEquals(2, variants.running().size()); + } + + @Test + @DisplayName("Without a spot nothing starts, the next step tries again") + void retriesWithoutASpot(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + AtomicBoolean floor = new AtomicBoolean(false); + CreekVariants variants = variants(instance, CONFIG, + candidate -> floor.get() ? Optional.of(candidate) : Optional.empty(), 0L); + + variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 0L); + assertTrue(variants.running().isEmpty()); + + floor.set(true); + variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 100L); + assertEquals(1, variants.running().size()); + } + + @Test + @DisplayName("A survivor without a free spot does not block the next one in line") + void skipsASurvivorWithoutASpot(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 200); + // no floor near the first survivor, floor everywhere near the second + CreekVariants variants = variants(instance, CONFIG, + candidate -> candidate.x() > 100 ? Optional.of(candidate) : Optional.empty(), 0L); + + variants.tick(new SurvivorSnapshot(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.3D))), 0L); + + assertEquals(List.of(second.getUuid()), List.copyOf(variants.running().keySet())); + } + + @Test + @DisplayName("Stopping removes every variant") + void stopRemovesEveryVariant(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 0L); + Creek variant = variants.running().get(first.getUuid()); + + variants.stop(); + + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java new file mode 100644 index 00000000..b22a9699 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java @@ -0,0 +1,81 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CreekVariantsTest { + + private static final UUID FIRST = UUID.randomUUID(); + private static final UUID SECOND = UUID.randomUUID(); + private static final UUID THIRD = UUID.randomUUID(); + + @Test + @DisplayName("One variant per four survivors, at most three") + void capacity() { + assertEquals(0, CreekVariants.capacity(0)); + assertEquals(1, CreekVariants.capacity(1)); + assertEquals(1, CreekVariants.capacity(4)); + assertEquals(2, CreekVariants.capacity(5)); + assertEquals(2, CreekVariants.capacity(8)); + assertEquals(3, CreekVariants.capacity(9)); + assertEquals(3, CreekVariants.capacity(12)); + assertEquals(3, CreekVariants.capacity(40)); + } + + @Test + @DisplayName("The breather after a variant shrinks as the dread grows") + void cooldownFollowsDread() { + assertEquals(40_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 0.0D)); + assertEquals(30_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 0.5D)); + assertEquals(20_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 1.0D)); + } + + @Test + @DisplayName("The survivor with the highest dread above the threshold is picked") + void picksTheHighestDread() { + List views = List.of( + new SurvivorView(FIRST, new Pos(0, 40, 0), 0.3D, false), + new SurvivorView(SECOND, new Pos(10, 40, 0), 0.5D, false), + new SurvivorView(THIRD, new Pos(20, 40, 0), 0.1D, false)); + + assertEquals(Optional.of(views.get(1)), CreekVariants.pick(views, 0.25D, _ -> true)); + } + + @Test + @DisplayName("Nobody above the threshold, nobody is picked") + void picksNobodyBelowTheThreshold() { + List views = List.of(new SurvivorView(FIRST, new Pos(0, 40, 0), 0.2D, false)); + + assertEquals(Optional.empty(), CreekVariants.pick(views, 0.25D, _ -> true)); + } + + @Test + @DisplayName("Survivors that are not eligible are skipped") + void skipsIneligibleSurvivors() { + List views = List.of( + new SurvivorView(FIRST, new Pos(0, 40, 0), 0.9D, false), + new SurvivorView(SECOND, new Pos(10, 40, 0), 0.5D, false)); + + assertEquals(Optional.of(views.get(1)), CreekVariants.pick(views, 0.25D, id -> !id.equals(FIRST))); + } + + @Test + @DisplayName("On a tie the one who strayed from the group is picked") + void prefersTheLonelyOne() { + List views = List.of( + new SurvivorView(FIRST, new Pos(0, 40, -60), 0.3D, false), + new SurvivorView(SECOND, new Pos(5, 40, -60), 0.3D, false), + new SurvivorView(THIRD, new Pos(0, 40, -160), 0.3D, false)); + + assertEquals(THIRD, CreekVariants.pick(views, 0.25D, _ -> true).orElseThrow().id()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java new file mode 100644 index 00000000..003ff7be --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java @@ -0,0 +1,66 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +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; + +class SurvivorSnapshotIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("The snapshot rates each survivor against the others, and nobody sees a creek yet") + void takesOneViewPerSurvivor(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player second = env.createConnection().connect(instance, new Pos(10, 40, 0)); + List> others = new ArrayList<>(); + + SurvivorSnapshot survivors = SurvivorSnapshot.take(List.of(first, second), (id, _, positions) -> { + others.add(positions); + return id.equals(first.getUuid()) ? 0.7D : 0.2D; + }); + + assertEquals(List.of(first, second), survivors.players()); + SurvivorView view = survivors.views().getFirst(); + assertEquals(first.getUuid(), view.id()); + assertEquals(first.getPosition(), view.position()); + assertEquals(0.7D, view.dread()); + assertFalse(survivors.views().stream().anyMatch(SurvivorView::seesCreek)); + assertEquals(List.of(List.of(second.getPosition()), List.of(first.getPosition())), others, + "each survivor is rated against everyone but themselves"); + } + + @Test + @DisplayName("A survivor is found by id, anyone else is not") + void findsPlayers(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + SurvivorSnapshot survivors = SurvivorSnapshot.take(List.of(first), (_, _, _) -> 0.0D); + + assertSame(first, survivors.player(first.getUuid())); + assertNull(survivors.player(UUID.randomUUID())); + } + + @Test + @DisplayName("Players and views have to match up") + void rejectsMismatchedViews(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + + assertThrows(IllegalArgumentException.class, () -> new SurvivorSnapshot(List.of(first), List.of())); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java new file mode 100644 index 00000000..401086e7 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java @@ -0,0 +1,195 @@ +package net.onelitefeather.cygnus.creek.body; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.metadata.monster.CreakingMeta; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreakingBodyIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("He is shown to the chosen viewers and nobody else") + void showsHimOnlyToTheChosen(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player second = env.createConnection().connect(instance, new Pos(2, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10)); + + assertTrue(body.entity().getViewers().isEmpty(), "nobody sees him before he is shown"); + + body.showTo(Set.of(first.getUuid())); + + assertTrue(body.entity().getViewers().contains(first)); + assertFalse(body.entity().getViewers().contains(second)); + assertTrue(body.isVisibleTo(first.getUuid())); + + body.showTo(Set.of()); + + assertTrue(body.entity().getViewers().isEmpty()); + } + + @Test + @DisplayName("The hunting look lights his eyes and freezing stops him") + void looksFollowTheState(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10)); + CreakingMeta meta = (CreakingMeta) body.entity().getEntityMeta(); + + body.setAggressive(true); + assertTrue(meta.isActive()); + + body.setFrozen(true); + assertFalse(meta.canMove()); + + body.setFrozen(false); + assertTrue(meta.canMove()); + } + + @Test + @DisplayName("Removing takes him out of the world") + void removeTakesHimOut(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10)); + + body.remove(); + + assertTrue(body.entity().isRemoved()); + } + + @Test + @DisplayName("A goal the navigator turned down does not block the next one") + void rejectedGoalDoesNotBlockTheNext(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 10.5)); + + // Same block as he stands in: the navigator has nothing to do and turns it down. + body.moveTo(new Pos(0.7, 40, 10.7), 0.25D); + Pos next = new Pos(1.4, 40, 10.9); + body.moveTo(next, 0.25D); + + assertEquals(next, ((EntityCreature) body.entity()).getNavigator().getGoalPosition()); + } + + @ParameterizedTest + @ValueSource(strings = {"minecraft:leaf_litter", "minecraft:short_grass", "minecraft:fern"}) + @DisplayName("He walks through plants on the ground") + void walksThroughPlants(String plant, Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + Block cover = Block.fromKey(plant); + for (int x = -2; x <= 14; x++) { + for (int z = 5; z <= 15; z++) instance.setBlock(x, 40, z, cover); + } + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 10.5)); + Pos goal = new Pos(10.5, 40, 10.5); + + walk(env, body, goal); + + assertTrue(body.position().distance(goal) <= 1.0D, "he gets through the " + plant + ", stands at " + body.position()); + } + + @Test + @DisplayName("He walks along a row of slabs without hopping") + void walksOverSlabsWithoutHopping(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + for (int x = 0; x <= 12; x++) { + instance.setBlock(x, 40, 10, Block.STONE_SLAB); + } + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40.5, 10.5)); + Pos goal = new Pos(10.5, 40.5, 10.5); + + double highest = walk(env, body, goal); + + assertTrue(body.position().distance(goal) <= 1.0D, "he reaches the end of the slabs, stands at " + body.position()); + assertTrue(highest < 41.0D, "he does not jump on flat slabs, highest y " + highest); + } + + @Test + @DisplayName("He climbs steps made of slabs") + void climbsSlabSteps(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + // floor 40 -> slab 40.5 -> block 41 -> slab 41.5 -> block 42 + // across the whole width, so he cannot walk around them + for (int z = 0; z <= 20; z++) { + instance.setBlock(3, 40, z, Block.STONE_SLAB); + for (int x = 4; x <= 20; x++) instance.setBlock(x, 40, z, Block.STONE); + instance.setBlock(5, 41, z, Block.STONE_SLAB); + for (int x = 6; x <= 20; x++) instance.setBlock(x, 41, z, Block.STONE); + } + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 10.5)); + Pos goal = new Pos(10.5, 42, 10.5); + + walk(env, body, goal); + + assertTrue(body.position().distance(goal) <= 1.0D, "he gets to the top, stands at " + body.position()); + } + + @Test + @DisplayName("The step he walks up comes from his step height attribute") + void stepHeightFollowsTheAttribute(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 0.5)); + EntityCreature creature = (EntityCreature) body.entity(); + StepFollower follower = new StepFollower(creature); + assertEquals(1.0625D, follower.stepHeight(), 1.0E-9, "a creaking's own step, as in vanilla"); + + creature.getAttribute(Attribute.STEP_HEIGHT).setBaseValue(0.6D); + + assertEquals(0.6D, follower.stepHeight(), 1.0E-9); + } + + @Test + @DisplayName("He drops into a gap only one block wide") + void dropsIntoANarrowGap(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + for (int x = -2; x <= 12; x++) { + for (int z = 5; z <= 15; z++) instance.setBlock(x, 40, z, Block.STONE); + } + instance.setBlock(8, 40, 10, Block.AIR); + // off the block grid, so full steps never land him exactly in the middle of the gap + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.6, 41, 10.37)); + Pos goal = new Pos(8.5, 40, 10.5); + + walk(env, body, goal); + + assertTrue(body.position().distance(goal) <= 1.0D, "he gets down into the gap, stands at " + body.position()); + } + + /** + * Lets him settle, sends him to the goal and ticks until he is there or 10 seconds are over. + * + * @return the highest y he reached on the way + */ + private static double walk(Env env, CreakingBody body, Pos goal) { + for (int i = 0; i < 20; i++) env.tick(); + body.moveTo(goal, 0.25D); + double highest = body.position().y(); + for (int i = 0; i < 200 && body.position().distance(goal) > 1.0D; i++) { + env.tick(); + highest = Math.max(highest, body.position().y()); + } + return highest; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/CatchEffectsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/CatchEffectsIntegrationTest.java new file mode 100644 index 00000000..bc5d7a9a --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/CatchEffectsIntegrationTest.java @@ -0,0 +1,59 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.potion.PotionEffect; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.stamina.FoodBar; +import net.onelitefeather.cygnus.stamina.StaminaFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CatchEffectsIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A catch drains the stamina and slows the survivor") + void drainsAndSlows(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) survivor); + CatchEffects effects = new CatchEffects(_ -> true, _ -> bar, 4); + + effects.apply(survivor); + + assertEquals(0.0f, bar.remainingShare(), 1.0E-6f); + assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("Without a corpse to borrow, the scare still happens") + void scaresWithoutACorpse(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + CatchEffects effects = new CatchEffects(_ -> false, _ -> null, 4); + + effects.apply(survivor); + + assertTrue(survivor.hasEffect(PotionEffect.DARKNESS)); + } + + @Test + @DisplayName("Cleaning up lifts the slowness") + void cleanUpLiftsTheSlowness(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + CatchEffects effects = new CatchEffects(_ -> true, _ -> null, 4); + effects.apply(survivor); + + effects.cleanUp(); + + assertFalse(survivor.hasEffect(PotionEffect.SLOWNESS)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/GlowRevealIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/GlowRevealIntegrationTest.java new file mode 100644 index 00000000..db9e660c --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/GlowRevealIntegrationTest.java @@ -0,0 +1,104 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Metadata; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.EntityMetaDataPacket; +import net.minestom.server.network.packet.server.play.SystemChatPacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GlowRevealIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("The slender sees the survivor glow at once") + void slenderSeesTheGlow(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TestConnection slenderConnection = env.createConnection(); + Player slender = slenderConnection.connect(instance, new Pos(5, 40, 0)); + Collector packets = slenderConnection.trackIncoming(EntityMetaDataPacket.class); + GlowReveal reveal = new GlowReveal(6); + + reveal.reveal(survivor, slender); + + assertTrue(packets.collect().stream().anyMatch(packet -> glows(packet, survivor))); + reveal.cleanUp(); + } + + @Test + @DisplayName("Sprinting wipes the glow only until the next resend") + void glowComesBackAfterSprinting(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TestConnection slenderConnection = env.createConnection(); + Player slender = slenderConnection.connect(instance, new Pos(5, 40, 0)); + GlowReveal reveal = new GlowReveal(6); + reveal.reveal(survivor, slender); + Collector packets = slenderConnection.trackIncoming(EntityMetaDataPacket.class); + + survivor.setSprinting(true); + for (int tick = 0; tick < GlowReveal.RESEND_TICKS + 1; tick++) { + env.tick(); + } + + List sent = packets.collect().stream() + .filter(packet -> packet.entityId() == survivor.getEntityId() && packet.entries().containsKey(0)) + .toList(); + assertFalse(sent.isEmpty()); + assertTrue(glows(sent.getLast(), survivor), "the resend has to bring the glow back"); + reveal.cleanUp(); + } + + @Test + @DisplayName("Cleaning up takes the glow back") + void cleanUpTakesTheGlowBack(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TestConnection slenderConnection = env.createConnection(); + Player slender = slenderConnection.connect(instance, new Pos(5, 40, 0)); + GlowReveal reveal = new GlowReveal(6); + reveal.reveal(survivor, slender); + Collector packets = slenderConnection.trackIncoming(EntityMetaDataPacket.class); + + reveal.cleanUp(); + + List sent = packets.collect(); + assertFalse(sent.isEmpty()); + assertNotNull(sent.getLast().entries().get(0), "the flags have to be sent explicitly"); + assertFalse(glows(sent.getLast(), survivor)); + } + + @Test + @DisplayName("The survivor is warned") + void survivorIsWarned(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection survivorConnection = env.createConnection(); + Player survivor = survivorConnection.connect(instance, new Pos(0, 40, 0)); + Player slender = env.createConnection().connect(instance, new Pos(5, 40, 0)); + Collector messages = survivorConnection.trackIncoming(SystemChatPacket.class); + GlowReveal reveal = new GlowReveal(6); + + reveal.reveal(survivor, slender); + + assertFalse(messages.collect().isEmpty()); + reveal.cleanUp(); + } + + private static boolean glows(EntityMetaDataPacket packet, Player survivor) { + if (packet.entityId() != survivor.getEntityId()) return false; + Metadata.Entry flags = packet.entries().get(0); + return flags != null && flags.value() instanceof Byte value && (value & GlowReveal.GLOWING) != 0; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java new file mode 100644 index 00000000..e7c73433 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java @@ -0,0 +1,167 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.server.potion.PotionEffect; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PatrolHelperIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A stun slows the selected survivor and does not count as a catch") + void stunSlowsTheSelected(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); + + patrol.stun(selected, List.of(selected)); + + assertTrue(selected.hasEffect(PotionEffect.SLOWNESS)); + assertFalse(selected.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(selected.hasTag(StagedCatchConsequence.CATCHES)); + } + + @Test + @DisplayName("A stun blinds other survivors nearby, nobody else") + void stunBlindsOtherSurvivorsNearby(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); + Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); + Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); + + patrol.stun(selected, List.of(selected, near, far)); + + assertTrue(near.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(far.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(slender.hasEffect(PotionEffect.BLINDNESS), "only survivors go blind"); + assertFalse(selected.hasEffect(PotionEffect.BLINDNESS)); + } + + @Test + @DisplayName("Vanishing blinds the survivors nearby, nobody else") + void vanishBlindsSurvivorsNearby(Env env) { + Instance instance = env.createFlatInstance(); + Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); + Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); + Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); + + patrol.vanished(new Pos(0, 40, 0), List.of(near, far)); + + assertTrue(near.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(far.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(slender.hasEffect(PotionEffect.BLINDNESS), "only survivors go blind"); + } + + /** Builds a two blocks high wall of stone, on the floor of a flat instance. */ + private static void wall(Instance instance, int fromX, int toX, int fromZ, int toZ) { + for (int x = fromX; x <= toX; x++) { + for (int z = fromZ; z <= toZ; z++) { + instance.setBlock(x, 40, z, Block.STONE); + instance.setBlock(x, 41, z, Block.STONE); + } + } + } + + private static Instance flat(Env env) { + Instance instance = env.createFlatInstance(); + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + instance.loadChunk(x, z).join(); + } + } + return instance; + } + + @Test + @DisplayName("On open ground a fling sends the survivor the full distance away from the creek") + void flingsAwayFromTheCreek(Env env) { + Instance instance = flat(env); + Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); + + assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); + + Vec velocity = selected.getVelocity(); + assertEquals(PatrolHelper.FLING_MAX_DISTANCE * PatrolHelper.FLING_SPEED_PER_BLOCK, velocity.z(), 1.0E-6); + assertEquals(0.0D, velocity.x(), 1.0E-6); + assertEquals(PatrolHelper.FLING_LIFT, velocity.y(), 1.0E-6); + } + + @Test + @DisplayName("With a tree in the way the fling only goes as far as there is room") + void flingsOnlyAsFarAsThereIsRoom(Env env) { + Instance instance = flat(env); + Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); + wall(instance, 8, 8, 14, 14); + PatrolHelper patrol = new PatrolHelper(new Random(1)); + + assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); + + double distance = selected.getVelocity().z() / PatrolHelper.FLING_SPEED_PER_BLOCK; + assertTrue(distance >= PatrolHelper.FLING_MIN_DISTANCE && distance < 6.0D, "flung " + distance + " blocks"); + } + + @Test + @DisplayName("With the way ahead blocked the fling goes to a free side") + void flingsToAFreeSide(Env env) { + Instance instance = flat(env); + Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); + wall(instance, 0, 15, 10, 10); + PatrolHelper patrol = new PatrolHelper(new Random(1)); + + assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); + + Vec velocity = selected.getVelocity(); + assertEquals(0.0D, velocity.z(), 1.0E-6, "straight and diagonal are blocked"); + assertTrue(Math.abs(velocity.x()) >= PatrolHelper.FLING_MIN_DISTANCE * PatrolHelper.FLING_SPEED_PER_BLOCK); + } + + @Test + @DisplayName("Boxed in on every side, the survivor is stunned instead") + void stunsWhenBoxedIn(Env env) { + Instance instance = flat(env); + Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); + wall(instance, 6, 11, 6, 6); + wall(instance, 6, 11, 11, 11); + wall(instance, 6, 6, 6, 11); + wall(instance, 11, 11, 6, 11); + // nextBoolean() is false for 0, so selected() tries the fling first. + PatrolHelper patrol = new PatrolHelper(() -> 0L); + + patrol.selected(selected, new Pos(8.5, 40, 7.5), List.of(selected)); + + assertEquals(Vec.ZERO, selected.getVelocity()); + assertTrue(selected.hasEffect(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("Cleaning up removes every slowness and blindness it applied") + void cleanUpRemovesTheEffects(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); + patrol.stun(selected, List.of(selected, near)); + + patrol.cleanUp(); + + assertFalse(selected.hasEffect(PotionEffect.SLOWNESS)); + assertFalse(near.hasEffect(PotionEffect.BLINDNESS)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java new file mode 100644 index 00000000..217819f3 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java @@ -0,0 +1,126 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import org.jetbrains.annotations.Nullable; +import net.onelitefeather.cygnus.creek.state.Contexts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StagedCatchConsequenceIntegrationTest extends CygnusPlayerTestBase { + + private final List punished = new ArrayList<>(); + private final List revealed = new ArrayList<>(); + private int cleanUps; + + private final CatchConsequence effects = new CatchConsequence() { + @Override + public void apply(Player survivor) { + punished.add(survivor); + } + + @Override + public void cleanUp() { + cleanUps++; + } + }; + + private final SlenderReveal reveal = new SlenderReveal() { + @Override + public void reveal(Player survivor, Player slender) { + revealed.add(new Player[]{survivor, slender}); + } + + @Override + public void cleanUp() { + cleanUps++; + } + }; + + private StagedCatchConsequence consequence(@Nullable Player slender, double draw) { + return new StagedCatchConsequence(effects, reveal, () -> slender, CreekConfig.DEFAULT, Contexts.rolling(draw)); + } + + @Test + @DisplayName("The first catch only punishes") + void firstCatchPunishes(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player slender = env.createConnection().connect(instance, new Pos(5, 40, 0)); + + consequence(slender, 0.99D).apply(survivor); + + assertEquals(List.of(survivor), punished); + assertTrue(revealed.isEmpty()); + assertEquals(1, survivor.getTag(StagedCatchConsequence.CATCHES)); + } + + @Test + @DisplayName("The second catch gives the survivor away") + void secondCatchBetrays(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player slender = env.createConnection().connect(instance, new Pos(5, 40, 0)); + StagedCatchConsequence consequence = consequence(slender, 0.99D); + + consequence.apply(survivor); + consequence.apply(survivor); + + assertEquals(1, revealed.size()); + assertEquals(survivor, revealed.getFirst()[0]); + assertEquals(slender, revealed.getFirst()[1]); + assertNull(survivor.getTag(StagedCatchConsequence.CATCHES), "the count starts over"); + } + + @Test + @DisplayName("A lucky draw gives the survivor away early") + void luckyDrawBetraysEarly(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player slender = env.createConnection().connect(instance, new Pos(5, 40, 0)); + + consequence(slender, 0.0D).apply(survivor); + + assertEquals(1, revealed.size()); + } + + @Test + @DisplayName("Without a slender nobody is given away") + void withoutASlender(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + StagedCatchConsequence consequence = consequence(null, 0.0D); + + consequence.apply(survivor); + consequence.apply(survivor); + + assertTrue(revealed.isEmpty()); + assertEquals(2, survivor.getTag(StagedCatchConsequence.CATCHES)); + } + + @Test + @DisplayName("Cleaning up forgets the count and cleans the parts") + void cleanUpForgets(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + StagedCatchConsequence consequence = consequence(null, 0.99D); + consequence.apply(survivor); + + consequence.cleanUp(); + + assertNull(survivor.getTag(StagedCatchConsequence.CATCHES)); + assertEquals(2, cleanUps); + } + +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java new file mode 100644 index 00000000..4ef16944 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java @@ -0,0 +1,62 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TrackedEffectsIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("Removing takes back every effect it handed out") + void removesItsOwnEffects(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TrackedEffects effects = new TrackedEffects(); + effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100)); + effects.add(player, new Potion(PotionEffect.BLINDNESS, 0, 100)); + + effects.removeAll(); + + assertFalse(player.hasEffect(PotionEffect.SLOWNESS)); + assertFalse(player.hasEffect(PotionEffect.BLINDNESS)); + } + + @Test + @DisplayName("An effect something else put on the player since is left alone") + void leavesForeignEffects(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TrackedEffects effects = new TrackedEffects(); + effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100)); + player.addEffect(new Potion(PotionEffect.SLOWNESS, 2, 400)); + + effects.removeAll(); + + assertTrue(player.hasEffect(PotionEffect.SLOWNESS)); + assertEquals(2, player.getEffectLevel(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("Handing out the same effect again replaces the one it remembers") + void remembersTheLatest(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TrackedEffects effects = new TrackedEffects(); + effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100)); + effects.add(player, new Potion(PotionEffect.SLOWNESS, 6, 40)); + + effects.removeAll(); + + assertFalse(player.hasEffect(PotionEffect.SLOWNESS)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java new file mode 100644 index 00000000..62824215 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java @@ -0,0 +1,150 @@ +package net.onelitefeather.cygnus.creek.debug; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.state.Contexts; +import net.onelitefeather.cygnus.creek.state.CreekContext; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.DoneState; +import net.onelitefeather.cygnus.creek.state.HuntState; +import net.onelitefeather.cygnus.creek.state.StalkState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.state.VanishState; +import net.onelitefeather.cygnus.creek.state.PatrolState; +import net.onelitefeather.cygnus.creek.state.RecordingBody; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.SpotFinder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekDebugTest { + + private static final UUID STEVE = UUID.randomUUID(); + private static final UUID ALEX = UUID.randomUUID(); + private static final Map NAMES = Map.of(STEVE, "Steve", ALEX, "Alex"); + + private static String plain(Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + @Test + @DisplayName("A hunt names its target, the distance, every dread and who sees him") + void huntLine() { + List views = List.of( + new SurvivorView(STEVE, new Pos(0, 40, 12.4), 0.62D, true), + new SurvivorView(ALEX, new Pos(50, 40, 0), 0.18D, false)); + + Component line = CreekDebug.line(new HuntState(STEVE, Long.MAX_VALUE), new Pos(0, 40, 0), views, NAMES::get); + + assertEquals("HUNT → Steve · 12.4 m · Steve 0.62 ◉ · Alex 0.18", plain(line)); + } + + @Test + @DisplayName("Patrolling there is no target, only the dread of everyone") + void patrolLine() { + List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 30), 0.1D, false)); + + Component line = CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get); + + assertEquals("PATROL · Steve 0.10", plain(line)); + } + + @Test + @DisplayName("Every state has its own label") + void labels() { + assertEquals("STALK", CreekDebug.label(new StalkState(STEVE, 0L))); + assertEquals("VANISH", CreekDebug.label(VanishState.forever())); + assertEquals("PATROL", CreekDebug.label(new PatrolState())); + assertEquals("DONE", CreekDebug.label(DoneState.INSTANCE)); + } + + @Test + @DisplayName("Toggling switches a watcher on and off again") + void toggle() { + CreekDebug debug = new CreekDebug(); + + assertTrue(debug.toggle(STEVE)); + assertTrue(debug.hasWatchers()); + assertFalse(debug.toggle(STEVE)); + assertFalse(debug.hasWatchers()); + } + + @Test + @DisplayName("The route description follows the label") + void routeIsShown() { + List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 30), 0.1D, false)); + + Component line = CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get, "Waldweg Nord 3/7 →"); + + assertEquals("PATROL · Waldweg Nord 3/7 → · Steve 0.10", plain(line)); + } + + @Test + @DisplayName("Without a route description the line is unchanged") + void emptyRouteAddsNothing() { + List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 30), 0.1D, false)); + + assertEquals("PATROL · Steve 0.10", plain(CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get, ""))); + } + + private static final Pos HERE = new Pos(0, 40, 0); + private static final SpotFinder SPOTS = new SpotFinder(new CreekSight(48, 35), Optional::of); + + @Test + @DisplayName("While staring the patrol names the selected survivor") + void patrolStareIsShown() { + PatrolState patrol = new PatrolState(); + List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 2), 0.1D, false)); + CreekContext ctx = new CreekContext(0L, views, new RecordingBody(HERE), Contexts.route(new Pos(30, 40, 0)), + SPOTS, Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1)); + patrol.enter(ctx); + patrol.tick(ctx); + + assertEquals("PATROL → Steve · 2.0 m · Steve 0.10", + plain(CreekDebug.line(patrol, HERE, views, NAMES::get, "", 0L))); + } + + @Test + @DisplayName("The variants show how many run and who they haunt") + void variantsSegment() { + List states = List.of(new StalkState(STEVE, 34_000L), new HuntState(ALEX, 12_000L)); + + assertEquals(" | variants 2/2 · Steve STALK 34s · Alex HUNT 12s", + plain(CreekDebug.variants(states, 2, NAMES::get, 0L))); + } + + @Test + @DisplayName("Without variants only the count is shown") + void noVariants() { + assertEquals(" | variants 0/1", plain(CreekDebug.variants(List.of(), 1, NAMES::get, 0L))); + } + + @Test + @DisplayName("During the selection cooldown the patrol shows the seconds left") + void selectCooldownIsShown() { + PatrolState patrol = new PatrolState(); + List near = List.of(new SurvivorView(STEVE, new Pos(0, 40, 2), 0.1D, false)); + RecordingBody body = new RecordingBody(HERE); + CreekContext start = new CreekContext(0L, near, body, Contexts.route(new Pos(30, 40, 0)), SPOTS, + Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1)); + patrol.enter(start); + patrol.tick(start); + patrol.tick(new CreekContext(1000L, near, body, Contexts.route(new Pos(30, 40, 0)), SPOTS, + Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1))); + + assertEquals("PATROL · select 7s · Steve 0.10", + plain(CreekDebug.line(patrol, HERE, near, NAMES::get, "", 4000L))); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/dread/PageProgressDreadTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/dread/PageProgressDreadTest.java new file mode 100644 index 00000000..4a34d479 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/dread/PageProgressDreadTest.java @@ -0,0 +1,70 @@ +package net.onelitefeather.cygnus.creek.dread; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PageProgressDreadTest { + + private static final UUID ID = UUID.randomUUID(); + private static final Pos HERE = new Pos(0, 40, 0); + private static final List NEIGHBOUR = List.of(new Pos(5, 40, 0)); + + private static PageProgressDread dread(int found, int max, long elapsedMillis, int gameTimeSeconds) { + return new PageProgressDread(() -> found, () -> max, () -> elapsedMillis, gameTimeSeconds, CreekConfig.DEFAULT); + } + + @Test + @DisplayName("Nothing found, no time gone, not alone: no dread") + void nothingYet() { + assertEquals(0.0D, dread(0, 8, 0, 900).dreadOf(ID, HERE, NEIGHBOUR), 1.0E-9); + } + + @Test + @DisplayName("Every page found weighs 0.6") + void allPages() { + assertEquals(0.6D, dread(8, 8, 0, 900).dreadOf(ID, HERE, NEIGHBOUR), 1.0E-9); + } + + @Test + @DisplayName("Half the round gone weighs 0.15") + void halfTheRound() { + assertEquals(0.15D, dread(0, 8, 450_000, 900).dreadOf(ID, HERE, NEIGHBOUR), 1.0E-9); + } + + @Test + @DisplayName("Nobody within the radius adds 0.1") + void aloneAddsIsolation() { + assertEquals(0.1D, dread(0, 8, 0, 900).dreadOf(ID, HERE, List.of(new Pos(30, 40, 0))), 1.0E-9); + } + + @Test + @DisplayName("The last survivor is always alone") + void lastSurvivorIsAlone() { + assertEquals(0.1D, dread(0, 8, 0, 900).dreadOf(ID, HERE, List.of()), 1.0E-9); + } + + @Test + @DisplayName("Everything at once is full dread") + void everything() { + assertEquals(1.0D, dread(8, 8, 900_000, 900).dreadOf(ID, HERE, List.of()), 1.0E-9); + } + + @Test + @DisplayName("Values past their maximum are clamped") + void clamped() { + assertEquals(1.0D, dread(12, 8, 2_000_000, 900).dreadOf(ID, HERE, List.of()), 1.0E-9); + } + + @Test + @DisplayName("Without pages or round time nothing divides by zero") + void noDivisionByZero() { + assertEquals(0.0D, dread(0, 0, 0, 0).dreadOf(ID, HERE, NEIGHBOUR), 1.0E-9); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java new file mode 100644 index 00000000..4dd9c0c7 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java @@ -0,0 +1,144 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.RouteProvider; +import net.onelitefeather.cygnus.creek.world.RouteStep; +import net.onelitefeather.cygnus.creek.world.SpotFinder; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.random.RandomGenerator; + +/** + * Builds contexts for the state tests: the design's settings, ground everywhere, fixed chance. + */ +public final class Contexts { + + /** The defaults, without random stops, so wandering tests do not depend on chance. */ + static final CreekConfig CONFIG = randomStops(0.0D, 0, 0); + static final CreekSight SIGHT = new CreekSight(CONFIG.sightRange(), CONFIG.sightViewAngle()); + static final SpotFinder SPOTS = new SpotFinder(SIGHT, Optional::of); + + /** Actions that do nothing. */ + public static final CreekActions NO_ACTIONS = actions(_ -> {}, _ -> {}); + + /** Actions that hand every catch and selection to the given consumers. */ + public static CreekActions actions(Consumer caught, Consumer selected) { + return actions(caught, selected, _ -> {}); + } + + /** Actions that hand every catch, selection and vanish to the given consumers. */ + public static CreekActions actions(Consumer caught, Consumer selected, Consumer vanished) { + return new CreekActions() { + @Override + public void caught(UUID survivor) { + caught.accept(survivor); + } + + @Override + public void selected(UUID survivor) { + selected.accept(survivor); + } + + @Override + public void vanished(Pos where) { + vanished.accept(where); + } + }; + } + + /** A random source that answers every roll with the given value. */ + public static RandomGenerator rolling(double value) { + return new RandomGenerator() { + @Override + public long nextLong() { + return 0L; + } + + @Override + public double nextDouble() { + return value; + } + }; + } + + static CreekContext context(long now, CreekBody body, RouteProvider route, List caught, + SurvivorView... survivors) { + return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions(caught::add, _ -> {}), CONFIG, + new Random(7)); + } + + static CreekContext context(long now, CreekBody body, RouteProvider route, CreekConfig config, + SurvivorView... survivors) { + return new CreekContext(now, List.of(survivors), body, route, SPOTS, NO_ACTIONS, config, new Random(7)); + } + + /** A context with the given actions and random source. */ + static CreekContext context(long now, CreekBody body, RouteProvider route, CreekActions actions, + RandomGenerator random, SurvivorView... survivors) { + return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions, CONFIG, random); + } + + /** A context that records every survivor the state selects. */ + static CreekContext selecting(long now, CreekBody body, RouteProvider route, List selected, + SurvivorView... survivors) { + return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions(_ -> {}, selected::add), CONFIG, + new Random(7)); + } + + /** The defaults with other random stops. */ + static CreekConfig randomStops(double chance, int minMillis, int maxMillis) { + return copy(CreekConfig.DEFAULT.activeWithLastSurvivor(), chance, minMillis, maxMillis); + } + + /** The defaults, but the creek sits out the round once only one survivor is left. */ + public static CreekConfig withoutLastSurvivor() { + CreekConfig d = CreekConfig.DEFAULT; + return copy(false, d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); + } + + private static CreekConfig copy(boolean activeWithLastSurvivor, double chance, int minMillis, int maxMillis) { + CreekConfig d = CreekConfig.DEFAULT; + return new CreekConfig( + d.enabled(), activeWithLastSurvivor, d.sightRange(), d.sightViewAngle(), + d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), d.stalkThreshold(), d.huntThreshold(), + d.stalkMinDistance(), d.stalkMaxDistance(), d.stalkMinAngle(), d.stalkMaxAngle(), + d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(), + d.catchDistance(), d.vanishMinSeconds(), d.vanishMaxSeconds(), d.respawnMinDistance(), + d.personalSpace(), d.stuckMillis(), d.dreadPageWeight(), d.dreadTimeWeight(), + d.dreadIsolationWeight(), d.isolationRadius(), d.betrayalCatchCount(), + d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(), + chance, minMillis, maxMillis); + } + + /** A route that offers the first allowed point at least a block away. */ + public static RouteProvider route(Pos... points) { + return new RouteProvider() { + @Override + public Optional next(Pos current, Predicate allowed, + RandomGenerator random) { + return Arrays.stream(points) + .filter(point -> point.distance(current) >= 1.0D) + .filter(allowed) + .findFirst() + .map(point -> new RouteStep(point, 0)); + } + + @Override + public List points() { + return List.of(points); + } + }; + } + + private Contexts() { + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/CreekContextTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/CreekContextTest.java new file mode 100644 index 00000000..679bf7fa --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/CreekContextTest.java @@ -0,0 +1,59 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekContextTest { + + private static final UUID FIRST = UUID.randomUUID(); + private static final UUID SECOND = UUID.randomUUID(); + + private static CreekContext context() { + return Contexts.context(0L, new RecordingBody(Pos.ZERO), Contexts.route(), new ArrayList<>(), + new SurvivorView(FIRST, new Pos(0, 40, 0), 0.2D, false), + new SurvivorView(SECOND, new Pos(30, 40, 0), 0.5D, true)); + } + + @Test + @DisplayName("A survivor is found by id") + void findsSurvivor() { + assertEquals(SECOND, context().survivor(SECOND).orElseThrow().id()); + assertTrue(context().survivor(UUID.randomUUID()).isEmpty()); + } + + @Test + @DisplayName("The ids of every survivor") + void survivorIds() { + assertEquals(Set.of(FIRST, SECOND), context().survivorIds()); + } + + @Test + @DisplayName("The highest dread wins, and none means zero") + void highestDread() { + assertEquals(0.5D, context().highestDread()); + assertEquals(0.0D, Contexts.context(0L, new RecordingBody(Pos.ZERO), Contexts.route(), new ArrayList<>()).highestDread()); + } + + @Test + @DisplayName("Far from all means far from every survivor") + void farFromAll() { + assertTrue(context().farFromAll(new Pos(15, 40, 20), 15)); + assertFalse(context().farFromAll(new Pos(5, 40, 0), 15)); + } + + @Test + @DisplayName("Observer eyes sit above the feet") + void observerEyes() { + assertEquals(List.of(new Pos(0, 41.62, 0), new Pos(30, 41.62, 0)), context().observerEyes()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java new file mode 100644 index 00000000..9c7ba153 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java @@ -0,0 +1,108 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +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.assertTrue; + +class HuntStateTest { + + private static final UUID TARGET = UUID.randomUUID(); + /** At the origin, looking towards negative Z - away from where he comes from. */ + private static final Pos TARGET_POS = new Pos(0, 40, 0, 180, 0); + + private static SurvivorView target(boolean sees) { + return new SurvivorView(TARGET, TARGET_POS, 0.7D, sees); + } + + private static CreekContext at(long now, RecordingBody body, List caught, boolean sees) { + return Contexts.context(now, body, Contexts.route(), caught, target(sees)); + } + + @Test + @DisplayName("Entering shows him to the target and lights his eyes") + void enterLightsTheEyes() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 10)); + new HuntState(TARGET, 30_000L).enter(at(0L, body, new ArrayList<>(), false)); + + assertEquals(Set.of(TARGET), body.viewers); + assertTrue(body.aggressive); + } + + @Test + @DisplayName("Seen, he freezes") + void freezesWhenSeen() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 10)); + HuntState state = new HuntState(TARGET, 30_000L); + + assertSame(state, state.tick(at(0L, body, new ArrayList<>(), true))); + assertTrue(body.frozen); + assertNull(body.goal); + } + + @Test + @DisplayName("Unseen, he closes in") + void closesInWhenUnseen() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 10)); + new HuntState(TARGET, 30_000L).tick(at(0L, body, new ArrayList<>(), false)); + + assertFalse(body.frozen); + assertEquals(TARGET_POS, body.goal); + assertEquals(Contexts.CONFIG.huntSpeed(), body.speed); + } + + @Test + @DisplayName("Close enough and unseen, he catches the target") + void catchesTheTarget() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 1)); + List caught = new ArrayList<>(); + + CreekState next = new HuntState(TARGET, 30_000L).tick(at(0L, body, caught, false)); + + assertEquals(List.of(TARGET), caught); + assertSame(DoneState.INSTANCE, next); + } + + @Test + @DisplayName("Close enough but seen, he does not") + void noCatchWhileSeen() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 1)); + List caught = new ArrayList<>(); + + new HuntState(TARGET, 30_000L).tick(at(0L, body, caught, true)); + + assertTrue(caught.isEmpty()); + } + + @Test + @DisplayName("When the time is up he gives up") + void givesUp() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 10)); + assertSame(DoneState.INSTANCE, new HuntState(TARGET, 30_000L).tick(at(30_000L, body, new ArrayList<>(), false))); + } + + @Test + @DisplayName("Stuck, he takes a shortcut close to the target") + void takesAShortcut() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 10)); + HuntState state = new HuntState(TARGET, 30_000L); + state.enter(at(0L, body, new ArrayList<>(), false)); + + state.tick(at(0L, body, new ArrayList<>(), false)); + state.tick(at(3000L, body, new ArrayList<>(), false)); + + assertEquals(1, body.teleports.size()); + double distance = body.position.distance(TARGET_POS); + assertTrue(distance >= HuntState.NEAR_MIN - 1.0E-6 && distance <= HuntState.NEAR_MAX + 1.0E-6, "distance was " + distance); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java new file mode 100644 index 00000000..2a684bef --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java @@ -0,0 +1,400 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.creek.world.CreekPaths; +import net.onelitefeather.cygnus.creek.world.PathRoute; +import net.onelitefeather.cygnus.creek.world.RouteProvider; +import net.onelitefeather.cygnus.creek.world.RouteStep; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PatrolStateTest { + + private static final UUID FIRST = UUID.randomUUID(); + private static final UUID SECOND = UUID.randomUUID(); + private static final Pos A = new Pos(30, 40, 0); + private static final Pos B = new Pos(-30, 40, 0); + + private static SurvivorView far(UUID id, double dread, boolean sees) { + return new SurvivorView(id, new Pos(0, 40, -60, 0, 0), dread, sees); + } + + private static SurvivorView at(UUID id, double z) { + return new SurvivorView(id, new Pos(0, 40, z), 0.0D, false); + } + + // ---- walking, carried over from the wander behaviour ---- + + @Test + @DisplayName("Entering shows him to every survivor") + void enterShowsHimToEveryone() { + RecordingBody body = new RecordingBody(Pos.ZERO); + new PatrolState().enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), + far(FIRST, 0.0D, false), new SurvivorView(SECOND, new Pos(40, 40, 40), 0.0D, false))); + + assertEquals(Set.of(FIRST, SECOND), body.viewers); + } + + @Test + @DisplayName("He walks to the next point") + void walksToTheNextPoint() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false)); + state.enter(ctx); + + assertSame(state, state.tick(ctx)); + assertEquals(A, body.goal); + assertEquals(Contexts.CONFIG.wanderSpeed(), body.speed); + } + + @Test + @DisplayName("Spotted from afar, he stops and looks back for a moment") + void pausesWhenSpotted() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + SurvivorView watcher = far(FIRST, 0.0D, true); + state.enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), watcher)); + + state.tick(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), watcher)); + assertNull(body.goal); + assertEquals(watcher.eyes(), body.lookedAt); + + state.tick(Contexts.context(1499L, body, Contexts.route(A), new ArrayList<>(), watcher)); + assertNull(body.goal); + + state.tick(Contexts.context(1500L, body, Contexts.route(A), new ArrayList<>(), watcher)); + assertEquals(A, body.goal); + } + + @Test + @DisplayName("Making no progress, he picks another point") + void picksAnotherPointWhenStuck() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + AtomicInteger calls = new AtomicInteger(); + RouteProvider cycling = (_, _, _) -> Optional.of(new RouteStep(calls.getAndIncrement() % 2 == 0 ? A : B, 0)); + PatrolState state = new PatrolState(); + state.enter(Contexts.context(0L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false))); + + state.tick(Contexts.context(0L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false))); + assertEquals(A, body.goal); + + state.tick(Contexts.context(2999L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false))); + assertEquals(A, body.goal); + + state.tick(Contexts.context(3000L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false))); + assertEquals(B, body.goal); + } + + @Test + @DisplayName("With nowhere to go he stands still") + void standsStillWithoutAPoint() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + CreekContext ctx = Contexts.context(0L, body, Contexts.route(), new ArrayList<>(), far(FIRST, 0.0D, false)); + state.enter(ctx); + + assertSame(state, state.tick(ctx)); + assertNull(body.goal); + assertTrue(body.stops > 0); + } + + @Test + @DisplayName("Someone who leaves the survivors stops seeing him") + void dropsViewersWhoLeaveTheSurvivors() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + SurvivorView second = new SurvivorView(SECOND, new Pos(40, 40, 40), 0.0D, false); + state.enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false), second)); + + state.tick(Contexts.context(100L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false))); + + assertEquals(Set.of(FIRST), body.viewers); + } + + /** Hands out A, then B, then A again, each with the given pause. */ + private static RouteProvider alternating(int pauseMillis) { + AtomicInteger calls = new AtomicInteger(); + return (_, _, _) -> Optional.of(new RouteStep(calls.getAndIncrement() % 2 == 0 ? A : B, pauseMillis)); + } + + private static CreekContext at(long now, RecordingBody body, RouteProvider route, CreekConfig config) { + return Contexts.context(now, body, route, config, far(FIRST, 0.0D, false)); + } + + @Test + @DisplayName("Arriving at a point with a pause, it rests there") + void restsAtAPointWithAPause() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(2000); + PatrolState state = new PatrolState(); + state.enter(at(0L, body, route, Contexts.CONFIG)); + state.tick(at(0L, body, route, Contexts.CONFIG)); + assertEquals(A, body.goal); + + body.position = A; + state.tick(at(100L, body, route, Contexts.CONFIG)); + assertNull(body.goal); + state.tick(at(2099L, body, route, Contexts.CONFIG)); + assertNull(body.goal); + + state.tick(at(2100L, body, route, Contexts.CONFIG)); + assertEquals(B, body.goal); + } + + @Test + @DisplayName("Getting stuck is no reason to rest") + void noRestWhenStuck() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(5000); + PatrolState state = new PatrolState(); + state.enter(at(0L, body, route, Contexts.CONFIG)); + state.tick(at(0L, body, route, Contexts.CONFIG)); + + state.tick(at(3000L, body, route, Contexts.CONFIG)); + + assertEquals(B, body.goal); + } + + @Test + @DisplayName("A random stop holds it at a point without a pause") + void randomStop() { + CreekConfig config = Contexts.randomStops(1.0D, 1000, 1000); + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(0); + PatrolState state = new PatrolState(); + state.enter(at(0L, body, route, config)); + state.tick(at(0L, body, route, config)); + + body.position = A; + state.tick(at(100L, body, route, config)); + state.tick(at(1099L, body, route, config)); + assertNull(body.goal); + + state.tick(at(1100L, body, route, config)); + assertEquals(B, body.goal); + } + + @Test + @DisplayName("Pause and random stop do not add up, the longer one wins") + void longerHaltWins() { + CreekConfig config = Contexts.randomStops(1.0D, 1000, 1000); + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(3000); + PatrolState state = new PatrolState(); + state.enter(at(0L, body, route, config)); + state.tick(at(0L, body, route, config)); + + body.position = A; + state.tick(at(100L, body, route, config)); + state.tick(at(3099L, body, route, config)); + assertNull(body.goal); + + state.tick(at(3100L, body, route, config)); + assertEquals(B, body.goal); + } + + @Test + @DisplayName("Without a chance and without a pause it walks on right away") + void noHaltWithoutChanceOrPause() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(0); + PatrolState state = new PatrolState(); + state.enter(at(0L, body, route, Contexts.CONFIG)); + state.tick(at(0L, body, route, Contexts.CONFIG)); + + body.position = A; + state.tick(at(100L, body, route, Contexts.CONFIG)); + + assertEquals(B, body.goal); + } + + // ---- selecting ---- + + @Test + @DisplayName("Within the radius he selects the nearest survivor and stares at them") + void selectsTheNearestWithinTheRadius() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + SurvivorView first = at(FIRST, 3); + SurvivorView second = at(SECOND, 2); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, first, second)); + + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, first, second)); + + assertEquals(Optional.of(SECOND), state.staring()); + assertEquals(second.eyes(), body.lookedAt); + assertNull(body.goal); + assertTrue(selected.isEmpty(), "the consequence waits for the end of the stare"); + } + + @Test + @DisplayName("Nobody within the radius, nobody is selected") + void selectsNobodyOutsideTheRadius() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(FIRST, 5))); + + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(FIRST, 5))); + + assertEquals(Optional.empty(), state.staring()); + assertEquals(A, body.goal); + } + + @Test + @DisplayName("After staring for a second he applies the consequence and walks on") + void staresThenAppliesAndWalksOn() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2))); + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2))); + + state.tick(Contexts.selecting(999L, body, Contexts.route(A), selected, at(SECOND, 2))); + assertTrue(selected.isEmpty()); + assertNull(body.goal); + + state.tick(Contexts.selecting(1000L, body, Contexts.route(A), selected, at(SECOND, 2))); + assertEquals(List.of(SECOND), selected); + assertEquals(A, body.goal); + assertEquals(Optional.empty(), state.staring()); + assertEquals(11_000L, state.selectAllowedAt()); + } + + @Test + @DisplayName("During the cooldown he selects nobody") + void selectsNobodyDuringTheCooldown() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2))); + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2))); + state.tick(Contexts.selecting(1000L, body, Contexts.route(A), selected, at(SECOND, 2))); + + state.tick(Contexts.selecting(10_999L, body, Contexts.route(A), selected, at(SECOND, 2))); + assertEquals(Optional.empty(), state.staring()); + assertEquals(A, body.goal); + + state.tick(Contexts.selecting(11_000L, body, Contexts.route(A), selected, at(SECOND, 2))); + assertEquals(Optional.of(SECOND), state.staring()); + } + + @Test + @DisplayName("If the selected survivor leaves during the stare, nothing happens and no cooldown starts") + void dropsTheSelectionWhenTheSurvivorLeaves() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false))); + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false))); + + state.tick(Contexts.selecting(500L, body, Contexts.route(A), selected, far(FIRST, 0.0D, false))); + assertTrue(selected.isEmpty()); + assertEquals(Optional.empty(), state.staring()); + + state.tick(Contexts.selecting(600L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false))); + assertEquals(Optional.of(SECOND), state.staring()); + } + + @Test + @DisplayName("After looking back he walks on to the point he was heading for") + void keepsHisPointAfterAPause() { + PathRoute route = new PathRoute(CreekPaths.of(List.of(CreekRoute.ofPositions("R", + List.of(new Vec(0, 40, 0), new Vec(20, 40, 0), new Vec(40, 40, 0)))), 3.0D)); + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + SurvivorView calm = far(FIRST, 0.0D, false); + state.enter(Contexts.context(0L, body, route, new ArrayList<>(), calm)); + state.tick(Contexts.context(0L, body, route, new ArrayList<>(), calm)); + state.tick(Contexts.context(100L, body, route, new ArrayList<>(), calm)); + assertEquals(new Pos(20, 40, 0), body.goal); + + body.position = new Pos(10, 40, 0); + SurvivorView watcher = far(FIRST, 0.0D, true); + state.tick(Contexts.context(200L, body, route, new ArrayList<>(), watcher)); + assertNull(body.goal, "he stops to look back"); + + state.tick(Contexts.context(1700L, body, route, new ArrayList<>(), watcher)); + assertEquals(new Pos(20, 40, 0), body.goal, "he goes on to the point he was heading for"); + } + + // ---- vanishing at a dead end ---- + + /** A route that always heads for A with the given pause, as a dead end or not. */ + private static RouteProvider towardsA(int pauseMillis, boolean deadEnd) { + return (_, _, _) -> Optional.of(new RouteStep(A, pauseMillis, deadEnd)); + } + + private static CreekContext deadEndContext(long now, RecordingBody body, RouteProvider route, List vanished, + double roll) { + return Contexts.context(now, body, route, Contexts.actions(_ -> {}, _ -> {}, vanished::add), Contexts.rolling(roll), far(FIRST, 0.0D, false)); + } + + @Test + @DisplayName("At a dead end he may vanish, gone for as long as he would have rested there") + void vanishesAtADeadEnd() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = towardsA(3000, true); + List vanished = new ArrayList<>(); + PatrolState state = new PatrolState(); + state.enter(deadEndContext(0L, body, route, vanished, 0.0D)); + state.tick(deadEndContext(0L, body, route, vanished, 0.0D)); + assertEquals(A, body.goal); + + body.position = A; + CreekState next = state.tick(deadEndContext(100L, body, route, vanished, 0.0D)); + + VanishState vanish = assertInstanceOf(VanishState.class, next); + assertEquals(3100L, vanish.until()); + assertEquals(List.of(A), vanished); + } + + @Test + @DisplayName("Missing the roll at a dead end, he rests and walks on as anywhere else") + void restsWhenTheRollMisses() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = towardsA(3000, true); + List vanished = new ArrayList<>(); + PatrolState state = new PatrolState(); + state.enter(deadEndContext(0L, body, route, vanished, 0.99D)); + state.tick(deadEndContext(0L, body, route, vanished, 0.99D)); + + body.position = A; + assertSame(state, state.tick(deadEndContext(100L, body, route, vanished, 0.99D))); + assertNull(body.goal, "he rests at the end"); + assertTrue(vanished.isEmpty()); + } + + @Test + @DisplayName("A point that is no dead end never makes him vanish") + void noVanishWithoutADeadEnd() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = towardsA(3000, false); + List vanished = new ArrayList<>(); + PatrolState state = new PatrolState(); + state.enter(deadEndContext(0L, body, route, vanished, 0.0D)); + state.tick(deadEndContext(0L, body, route, vanished, 0.0D)); + + body.position = A; + assertSame(state, state.tick(deadEndContext(100L, body, route, vanished, 0.0D))); + assertTrue(vanished.isEmpty()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java new file mode 100644 index 00000000..750308de --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java @@ -0,0 +1,91 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.onelitefeather.cygnus.creek.body.CreekBody; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * A body that only records what the states ask of it. + */ +public final class RecordingBody implements CreekBody { + + Pos position; + @Nullable Pos goal; + double speed; + int stops; + final List teleports = new ArrayList<>(); + Set viewers = Set.of(); + boolean aggressive; + boolean frozen; + @Nullable Pos lookedAt; + boolean removed; + + public RecordingBody(Pos position) { + this.position = position; + } + + @Override + public Pos position() { + return this.position; + } + + @Override + public void moveTo(Pos goal, double speed) { + this.goal = goal; + this.speed = speed; + } + + @Override + public void stop() { + this.goal = null; + this.stops++; + } + + @Override + public void teleport(Pos position) { + this.goal = null; + this.position = position; + this.teleports.add(position); + } + + @Override + public void lookAt(Pos point) { + this.lookedAt = point; + } + + @Override + public void showTo(Set viewers) { + this.viewers = Set.copyOf(viewers); + } + + @Override + public boolean isVisibleTo(UUID viewer) { + return this.viewers.contains(viewer); + } + + @Override + public void setAggressive(boolean aggressive) { + this.aggressive = aggressive; + } + + @Override + public void setFrozen(boolean frozen) { + this.frozen = frozen; + } + + @Override + public Entity entity() { + throw new UnsupportedOperationException("The recording body has no entity"); + } + + @Override + public void remove() { + this.removed = true; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java new file mode 100644 index 00000000..b8a41356 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java @@ -0,0 +1,148 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.creek.world.SpotFinder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StalkStateTest { + + private static final UUID TARGET = UUID.randomUUID(); + private static final UUID OTHER = UUID.randomUUID(); + /** 25 blocks away, behind and to the side of a target looking towards positive Z. */ + private static final Pos IN_THE_BAND = new Pos(15, 40, -20); + + private static SurvivorView target(double dread, boolean sees) { + return new SurvivorView(TARGET, new Pos(0, 40, 0, 0, 0), dread, sees); + } + + private static CreekContext at(long now, RecordingBody body, SurvivorView... survivors) { + return Contexts.context(now, body, Contexts.route(), new ArrayList<>(), survivors); + } + + @Test + @DisplayName("Entering shows him to the target alone") + void enterShowsOnlyTheTarget() { + RecordingBody body = new RecordingBody(IN_THE_BAND); + new StalkState(TARGET, 60_000L).enter(at(0L, body, target(0.3D, false), + new SurvivorView(OTHER, new Pos(40, 40, 40), 0.0D, false))); + + assertEquals(Set.of(TARGET), body.viewers); + } + + @Test + @DisplayName("Without the target he is done") + void vanishesWithoutTheTarget() { + RecordingBody body = new RecordingBody(IN_THE_BAND); + assertSame(DoneState.INSTANCE, new StalkState(TARGET, 60_000L).tick(at(0L, body))); + } + + @Test + @DisplayName("When the time is up he is done") + void vanishesWhenTheTimeIsUp() { + RecordingBody body = new RecordingBody(IN_THE_BAND); + assertSame(DoneState.INSTANCE, new StalkState(TARGET, 1000L).tick(at(1000L, body, target(0.3D, false)))); + } + + @Test + @DisplayName("At the hunt threshold the stalk turns into a hunt") + void turnsIntoAHunt() { + RecordingBody body = new RecordingBody(IN_THE_BAND); + assertInstanceOf(HuntState.class, new StalkState(TARGET, 60_000L).tick(at(0L, body, target(0.6D, false)))); + } + + @Test + @DisplayName("Seen, he lingers for a moment and then moves out of sight") + void lingersThenMoves() { + RecordingBody body = new RecordingBody(IN_THE_BAND); + StalkState state = new StalkState(TARGET, 60_000L); + + state.tick(at(0L, body, target(0.3D, true))); + state.tick(at(699L, body, target(0.3D, true))); + assertTrue(body.teleports.isEmpty(), "he has to be seen for a moment first"); + + state.tick(at(700L, body, target(0.3D, true))); + + assertEquals(1, body.teleports.size()); + Pos spot = body.position; + double distance = spot.distance(target(0.3D, true).position()); + assertTrue(distance >= 20 - 1.0E-6 && distance <= 35 + 1.0E-6, "distance was " + distance); + assertFalse(Contexts.SIGHT.inView(target(0.3D, true).eyes(), spot.add(0, SpotFinder.BODY_CENTRE, 0))); + } + + @Test + @DisplayName("When the target comes too close he moves back into the band") + void relocatesWhenTooClose() { + RecordingBody body = new RecordingBody(new Pos(0, 40, -10)); + new StalkState(TARGET, 60_000L).tick(at(0L, body, target(0.3D, false))); + + assertEquals(1, body.teleports.size()); + } + + @Test + @DisplayName("Inside the band and unseen he just stares") + void staresInsideTheBand() { + RecordingBody body = new RecordingBody(IN_THE_BAND); + StalkState state = new StalkState(TARGET, 60_000L); + + assertSame(state, state.tick(at(0L, body, target(0.3D, false)))); + assertTrue(body.teleports.isEmpty()); + assertEquals(target(0.3D, false).eyes(), body.lookedAt); + } + + private static double distanceToTarget(RecordingBody body) { + return body.position.distance(target(0.3D, false).position()); + } + + @Test + @DisplayName("Halfway through the stalk the band has shrunk to about 14 to 25 blocks") + void bandShrinksByHalfway() { + RecordingBody body = new RecordingBody(new Pos(0, 40, -17)); + StalkState state = new StalkState(TARGET, 60_000L); + state.enter(at(0L, body, target(0.3D, false))); + + state.tick(at(30_000L, body, target(0.3D, false))); + + assertTrue(body.teleports.isEmpty(), "17 blocks is inside the band halfway through"); + } + + @Test + @DisplayName("Late in the stalk he closes in when he is too far away") + void closesInLate() { + RecordingBody body = new RecordingBody(new Pos(0, 40, -18)); + StalkState state = new StalkState(TARGET, 60_000L); + state.enter(at(0L, body, target(0.3D, false))); + + state.tick(at(59_000L, body, target(0.3D, false))); + + assertEquals(1, body.teleports.size()); + double distance = distanceToTarget(body); + assertTrue(distance >= 8.0D - 1.0E-6 && distance <= 15.5D, "distance was " + distance); + } + + @Test + @DisplayName("Seen late in the stalk he comes back closer") + void comesBackCloserWhenSeenLate() { + RecordingBody body = new RecordingBody(new Pos(0, 40, -12)); + StalkState state = new StalkState(TARGET, 60_000L); + state.enter(at(0L, body, target(0.3D, false))); + + state.tick(at(58_000L, body, target(0.3D, true))); + assertTrue(body.teleports.isEmpty(), "12 blocks is inside the late band"); + state.tick(at(58_700L, body, target(0.3D, true))); + + assertEquals(1, body.teleports.size()); + double distance = distanceToTarget(body); + assertTrue(distance >= 8.0D - 1.0E-6 && distance <= 15.5D, "distance was " + distance); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java new file mode 100644 index 00000000..cb1800fa --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java @@ -0,0 +1,124 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.creek.world.CreekPaths; +import net.onelitefeather.cygnus.creek.world.PathRoute; +import net.onelitefeather.cygnus.creek.world.RouteProvider; +import net.onelitefeather.cygnus.creek.world.SpotFinder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VanishStateTest { + + private static final UUID SURVIVOR = UUID.randomUUID(); + /** At the origin, looking towards positive Z. */ + private static final SurvivorView WATCHER = new SurvivorView(SURVIVOR, new Pos(0, 40, 0, 0, 0), 0.0D, false); + private static final Pos AHEAD = new Pos(0, 40, 20); + private static final Pos BEHIND = new Pos(0, 40, -40); + + @Test + @DisplayName("Entering hides and calms him") + void enterHidesHim() { + RecordingBody body = new RecordingBody(new Pos(50, 40, 50)); + body.viewers = Set.of(SURVIVOR); + body.aggressive = true; + body.frozen = true; + + new VanishState(1000L).enter(Contexts.context(0L, body, Contexts.route(), new ArrayList<>(), WATCHER)); + + assertTrue(body.viewers.isEmpty()); + assertFalse(body.aggressive); + assertFalse(body.frozen); + assertEquals(1, body.stops); + } + + @Test + @DisplayName("He stays away until the cooldown is over") + void staysAwayUntilTheCooldown() { + RecordingBody body = new RecordingBody(new Pos(50, 40, 50)); + VanishState state = new VanishState(1000L); + + assertSame(state, state.tick(Contexts.context(999L, body, Contexts.route(BEHIND), new ArrayList<>(), WATCHER))); + assertTrue(body.teleports.isEmpty()); + } + + @Test + @DisplayName("He comes back at a hidden point far from everyone") + void comesBackHidden() { + RecordingBody body = new RecordingBody(new Pos(50, 40, 50)); + VanishState state = new VanishState(1000L); + + CreekState next = state.tick(Contexts.context(1000L, body, Contexts.route(AHEAD, BEHIND), new ArrayList<>(), WATCHER)); + + assertInstanceOf(PatrolState.class, next); + assertEquals(List.of(BEHIND), body.teleports); + } + + @Test + @DisplayName("Without a fitting point he stays away") + void staysAwayWithoutAPoint() { + RecordingBody body = new RecordingBody(new Pos(50, 40, 50)); + VanishState state = new VanishState(1000L); + + assertSame(state, state.tick(Contexts.context(1000L, body, Contexts.route(AHEAD), new ArrayList<>(), WATCHER))); + } + + @Test + @DisplayName("Coming back, he stops looking once he has found a hidden point") + void stopsAtTheFirstHiddenPoint() { + RecordingBody body = new RecordingBody(new Pos(50, 40, 50)); + AtomicInteger checked = new AtomicInteger(); + SpotFinder counting = new SpotFinder(Contexts.SIGHT, candidate -> { + checked.incrementAndGet(); + return Optional.of(candidate); + }); + Pos[] behind = new Pos[20]; + for (int i = 0; i < behind.length; i++) behind[i] = new Pos(i, 40, -40 - i); + CreekContext ctx = new CreekContext(1000L, List.of(WATCHER), body, Contexts.route(behind), counting, + Contexts.NO_ACTIONS, Contexts.CONFIG, new Random(7)); + + assertInstanceOf(PatrolState.class, new VanishState(1000L).tick(ctx)); + assertEquals(1, checked.get(), "every point behind the survivor is hidden, so the first one will do"); + } + + @Test + @DisplayName("Forever means forever") + void foreverNeverEnds() { + VanishState state = VanishState.forever(); + RecordingBody body = new RecordingBody(new Pos(50, 40, 50)); + + assertTrue(state.isForever()); + assertSame(state, state.tick(Contexts.context(Long.MAX_VALUE - 1, body, Contexts.route(BEHIND), new ArrayList<>(), WATCHER))); + } + + @Test + @DisplayName("On a route it comes back far away, not next to where it vanished") + void comesBackAnywhereOnTheRoute() { + // the route runs right past the survivor at the origin and ends far behind them + RouteProvider route = new PathRoute(CreekPaths.of(List.of(CreekRoute.ofPositions("Weg", List.of( + new Vec(0, 40, 10), new Vec(0, 40, 5), new Vec(0, 40, -5), new Vec(0, 40, -40)))), 3.0D)); + route.next(new Pos(0, 40, 5), _ -> true, new java.util.Random(1)); + RecordingBody body = new RecordingBody(new Pos(0, 40, 5)); + + CreekState next = new VanishState(0L).tick(Contexts.context(0L, body, route, new ArrayList<>(), WATCHER)); + + assertInstanceOf(PatrolState.class, next); + assertEquals(List.of(new Pos(0, 40, -40)), body.teleports); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java new file mode 100644 index 00000000..dc349e7a --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java @@ -0,0 +1,62 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.creek.CreekLinks; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekPathsTest { + + private static CreekRoute route(String name, Vec... points) { + return CreekRoute.ofPositions(name, List.of(points)); + } + + /** A ends at (10,0,0); B starts 2 blocks away, C exactly 3, D 3.1. */ + private static final List ROUTES = List.of( + route("A", new Vec(0, 0, 0), new Vec(10, 0, 0)), + route("B", new Vec(12, 0, 0), new Vec(20, 0, 0)), + route("C", new Vec(10, 0, 3), new Vec(10, 0, 20)), + route("D", new Vec(10, 0, -3.1), new Vec(10, 0, -20)) + ); + + @Test + @DisplayName("Links come from CreekLinks, by route and point") + void linksByNode() { + CreekPaths paths = CreekPaths.of(ROUTES, 3.0D); + + assertEquals(List.of(new CreekLinks.Node(1, 0), new CreekLinks.Node(2, 0)), + paths.links(new CreekLinks.Node(0, 1))); + } + + @Test + @DisplayName("Points come out as Pos") + void pointsAsPos() { + CreekPaths paths = CreekPaths.of(ROUTES, 3.0D); + + assertEquals(4, paths.size()); + assertEquals(new Pos(12, 0, 0), paths.point(1, 0)); + assertEquals(new Pos(20, 0, 0), paths.point(1, 1)); + assertEquals(8, paths.allPoints().size()); + assertTrue(CreekPaths.of(List.of(), 3.0D).isEmpty()); + } + + @Test + @DisplayName("Each point reports its pause") + void pausesPerPoint() { + CreekRoute route = new CreekRoute("P", List.of( + CreekWaypoint.of(new Vec(0, 0, 0)).withPause(1000), + CreekWaypoint.of(new Vec(10, 0, 0)))); + CreekPaths paths = CreekPaths.of(List.of(route), 3.0D); + + assertEquals(1000, paths.pauseMillis(0, 0)); + assertEquals(0, paths.pauseMillis(0, 1)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightIntegrationTest.java new file mode 100644 index 00000000..3ed7f82a --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightIntegrationTest.java @@ -0,0 +1,38 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekSightIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A wall between them hides him") + void wallHidesHim(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5, 0, 0)); + EntityCreature creek = new EntityCreature(EntityType.CREAKING); + creek.setInstance(instance, new Pos(0.5, 40, 10.5)).join(); + CreekSight sight = new CreekSight(48, 35); + + assertTrue(sight.sees(survivor, creek), "nothing stands between them yet"); + + for (int y = 40; y < 44; y++) { + for (int x = -2; x <= 2; x++) { + instance.setBlock(x, y, 5, Block.STONE); + } + } + + assertFalse(sight.sees(survivor, creek), "a wall hides him"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightTest.java new file mode 100644 index 00000000..857870ea --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightTest.java @@ -0,0 +1,41 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekSightTest { + + /** Eyes of a survivor standing at the origin and looking towards positive Z. */ + private static final Pos EYES = new Pos(0, 41.62, 0, 0, 0); + private static final CreekSight SIGHT = new CreekSight(48, 35); + + @Test + @DisplayName("Straight ahead he is seen") + void straightAheadIsSeen() { + assertTrue(SIGHT.inView(EYES, new Vec(0, 41.62, 20))); + } + + @Test + @DisplayName("Behind the survivor he is not") + void behindIsNotSeen() { + assertFalse(SIGHT.inView(EYES, new Vec(0, 41.62, -20))); + } + + @Test + @DisplayName("Beyond the range he is not") + void beyondRangeIsNotSeen() { + assertFalse(SIGHT.inView(EYES, new Vec(0, 41.62, 60))); + } + + @Test + @DisplayName("Beside the cone he is not") + void besideTheConeIsNotSeen() { + double radians = Math.toRadians(45); + assertFalse(SIGHT.inView(EYES, new Vec(-Math.sin(radians) * 10, 41.62, Math.cos(radians) * 10))); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/InstanceGroundIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/InstanceGroundIntegrationTest.java new file mode 100644 index 00000000..15dd75bc --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/InstanceGroundIntegrationTest.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MicrotusExtension.class) +class InstanceGroundIntegrationTest { + + @Test + @DisplayName("A spot in the air settles onto the floor, centred on its block") + void settlesOntoTheFloor(Env env) { + Instance instance = env.createFlatInstance(); + instance.loadChunk(0, 0).join(); + InstanceGround ground = new InstanceGround(() -> instance); + + assertEquals(new Pos(3.5, 40, 3.5), ground.settle(new Pos(3.2, 43, 3.7)).orElseThrow()); + } + + @Test + @DisplayName("An unloaded chunk offers no spot") + void unloadedChunkHasNoSpot(Env env) { + Instance instance = env.createFlatInstance(); + InstanceGround ground = new InstanceGround(() -> instance); + + assertTrue(ground.settle(new Pos(10_000, 40, 10_000)).isEmpty()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java new file mode 100644 index 00000000..55291b7c --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java @@ -0,0 +1,291 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.random.RandomGenerator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PathRouteTest { + + private static final Predicate ANYWHERE = _ -> true; + + private static final CreekRoute A = CreekRoute.ofPositions("A", + List.of(new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0))); + /** Starts 2 blocks after the end of A, so the two are linked. */ + private static final CreekRoute B = CreekRoute.ofPositions("B", + List.of(new Vec(22, 40, 0), new Vec(22, 40, 10))); + + private static PathRoute route(CreekRoute... routes) { + return new PathRoute(CreekPaths.of(List.of(routes), 3.0D)); + } + + /** Always answers with the given index (capped to the bound) and forwards for booleans. */ + private static RandomGenerator fixed(int index) { + return new RandomGenerator() { + @Override + public long nextLong() { + return 0L; + } + + @Override + public int nextInt(int bound) { + return Math.min(index, bound - 1); + } + + @Override + public boolean nextBoolean() { + return true; + } + }; + } + + private static RouteStep step(PathRoute route, Pos current, RandomGenerator random) { + return route.next(current, ANYWHERE, random).orElseThrow(); + } + + private static Pos next(PathRoute route, Pos current, RandomGenerator random) { + return step(route, current, random).target(); + } + + private static CreekRoute paused(String name, int startPause, int endPause, Vec... positions) { + List points = new ArrayList<>(CreekRoute.ofPositions(name, List.of(positions)).points()); + points.set(0, points.getFirst().withPause(startPause)); + points.set(points.size() - 1, points.getLast().withPause(endPause)); + return new CreekRoute(name, points); + } + + @Test + @DisplayName("Without a position it joins at the nearest point") + void joinsAtTheNearestPoint() { + PathRoute route = route(A); + + assertEquals(new Pos(10, 40, 0), next(route, new Pos(9, 41, 1), fixed(0))); + assertEquals("A 2/3 →", route.describe()); + } + + @Test + @DisplayName("It walks the points in order") + void walksInOrder() { + PathRoute route = route(A); + Pos at = next(route, new Pos(0, 40, 0), fixed(0)); + + at = next(route, at, fixed(0)); + assertEquals(new Pos(10, 40, 0), at); + at = next(route, at, fixed(0)); + assertEquals(new Pos(20, 40, 0), at); + } + + @Test + @DisplayName("At an end without links it turns around") + void turnsAroundWithoutLinks() { + PathRoute route = route(A); + Pos at = next(route, new Pos(0, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + at = next(route, at, fixed(0)); + + assertEquals(new Pos(10, 40, 0), next(route, at, fixed(0))); + assertEquals("A 2/3 ←", route.describe()); + } + + @Test + @DisplayName("At a linked end it can switch to the other route") + void takesALink() { + PathRoute route = route(A, B); + Pos at = next(route, new Pos(10, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + + at = next(route, at, fixed(0)); + assertEquals(new Pos(22, 40, 0), at, "the linked end is the stepping stone"); + assertEquals(new Pos(22, 40, 10), next(route, at, fixed(0))); + } + + @Test + @DisplayName("At a linked end it can also turn around") + void canStillTurnAround() { + PathRoute route = route(A, B); + Pos at = next(route, new Pos(10, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + + assertEquals(new Pos(10, 40, 0), next(route, at, fixed(1))); + } + + @Test + @DisplayName("After a teleport it rejoins where it is now") + void rejoinsAfterATeleport() { + PathRoute route = route(A, B); + next(route, new Pos(0, 40, 0), fixed(0)); + + assertEquals(new Pos(22, 40, 10), next(route, new Pos(22, 41, 12), fixed(0))); + } + + @Test + @DisplayName("A blocked point makes it turn around") + void turnsAroundWhenBlocked() { + PathRoute route = route(A); + Pos at = next(route, new Pos(10, 40, 0), fixed(0)); + + Optional next = route.next(at, point -> point.x() < 15, fixed(0)).map(RouteStep::target); + + assertEquals(Optional.of(new Pos(0, 40, 0)), next); + } + + @Test + @DisplayName("With both ways blocked it stays put, and moves on once a way is free") + void waitsUntilAWayIsFree() { + PathRoute route = route(A); + Pos at = next(route, new Pos(10, 40, 0), fixed(0)); + + assertTrue(route.next(at, point -> point.x() == 10, fixed(0)).isEmpty()); + assertEquals(new Pos(20, 40, 0), next(route, at, fixed(0))); + } + + @Test + @DisplayName("With no point allowed at all it waits, then joins") + void joinsOnceAPointIsAllowed() { + PathRoute route = route(A); + + assertTrue(route.next(new Pos(0, 40, 0), _ -> false, fixed(0)).isEmpty()); + assertEquals(new Pos(0, 40, 0), next(route, new Pos(0, 40, 0), fixed(0))); + } + + @Test + @DisplayName("Before it joined there is nothing to describe") + void emptyDescriptionBeforeJoining() { + assertEquals("", route(A).describe()); + assertEquals(5, route(A, B).points().size()); + } + + @Test + @DisplayName("Reaching an end in walking direction carries its pause") + void endsInWalkingDirectionPause() { + PathRoute route = route(paused("A", 1000, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0))); + + assertEquals(0, step(route, new Pos(0, 40, 0), fixed(0)).pauseMillis(), "it walks away from the start"); + assertEquals(0, step(route, new Pos(0, 40, 0), fixed(0)).pauseMillis()); + RouteStep end = step(route, new Pos(10, 40, 0), fixed(0)); + assertEquals(new RouteStep(new Pos(20, 40, 0), 2000, true), end); + assertEquals(0, step(route, end.target(), fixed(0)).pauseMillis()); + assertEquals(new RouteStep(new Pos(0, 40, 0), 1000, true), step(route, new Pos(10, 40, 0), fixed(0))); + } + + @Test + @DisplayName("Heading for an end without links is a dead end, heading anywhere else is not") + void marksDeadEnds() { + PathRoute route = route(A); + + assertFalse(step(route, new Pos(0, 40, 0), fixed(0)).deadEnd(), "joining at the start walks away from it"); + assertFalse(step(route, new Pos(0, 40, 0), fixed(0)).deadEnd(), "the middle is no end"); + assertTrue(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd()); + assertFalse(step(route, new Pos(20, 40, 0), fixed(0)).deadEnd(), "turning around leaves the end"); + assertTrue(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd(), "the start is a dead end too"); + } + + @Test + @DisplayName("A linked end is no dead end") + void linkedEndIsNoDeadEnd() { + PathRoute route = route(A, B); + next(route, new Pos(10, 40, 0), fixed(0)); + + assertFalse(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd()); + } + + /** X runs along the x axis; Y starts right next to X's middle point and runs off along z. */ + private static final CreekRoute X = CreekRoute.ofPositions("X", + List.of(new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0))); + private static final CreekRoute Y = CreekRoute.ofPositions("Y", + List.of(new Vec(10, 40, 2), new Vec(10, 40, 12), new Vec(10, 40, 22))); + + @Test + @DisplayName("At a crossing in the middle it can turn onto the other route") + void turnsAtACrossing() { + PathRoute route = route(X, Y); + Pos at = next(route, new Pos(0, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + assertEquals(new Pos(10, 40, 0), at); + + RouteStep turn = step(route, at, fixed(0)); + assertEquals(new RouteStep(new Pos(10, 40, 2), 0), turn, "the other route's end is the stepping stone"); + assertEquals(new Pos(10, 40, 12), next(route, turn.target(), fixed(0))); + } + + @Test + @DisplayName("At a crossing in the middle it can also walk on") + void walksOnAtACrossing() { + PathRoute route = route(X, Y); + Pos at = next(route, new Pos(0, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + + assertEquals(new Pos(20, 40, 0), next(route, at, fixed(1))); + } + + @Test + @DisplayName("Right after taking a link it does not jump straight back") + void noJumpBackAfterALink() { + PathRoute route = route(X, Y); + Pos at = next(route, new Pos(0, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + at = next(route, at, fixed(0)); + assertEquals(new Pos(10, 40, 2), at); + + assertEquals(new Pos(10, 40, 12), next(route, at, fixed(0))); + } + + @Test + @DisplayName("From an end it can join the middle of another route and walk on from there") + void joinsAMiddleFromAnEnd() { + PathRoute route = route(X, Y); + next(route, new Pos(10, 40, 22), fixed(0)); + Pos at = next(route, new Pos(10, 40, 22), fixed(0)); + RouteStep start = step(route, at, fixed(0)); + assertEquals(new Pos(10, 40, 2), start.target()); + assertFalse(start.deadEnd(), "an end on another route's middle is no dead end"); + + RouteStep join = step(route, start.target(), fixed(0)); + assertEquals(new RouteStep(new Pos(10, 40, 0), 0), join, "no second stop where the routes meet"); + assertEquals(new Pos(20, 40, 0), next(route, join.target(), fixed(0)), "it walks on, not back"); + } + + @Test + @DisplayName("The start of a linked route is no second stop") + void noPauseAtTheSteppingStone() { + PathRoute route = route( + paused("A", 0, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0)), + paused("B", 3000, 0, new Vec(22, 40, 0), new Vec(22, 40, 10))); + next(route, new Pos(10, 40, 0), fixed(0)); + + assertEquals(2000, step(route, new Pos(10, 40, 0), fixed(0)).pauseMillis()); + assertEquals(new RouteStep(new Pos(22, 40, 0), 0), step(route, new Pos(20, 40, 0), fixed(0))); + } + + @Test + @DisplayName("Rejoining at an end does not stop there") + void noPauseWhenRejoiningAtAnEnd() { + PathRoute route = route(paused("A", 1000, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0))); + + assertEquals(new RouteStep(new Pos(20, 40, 0), 0), step(route, new Pos(21, 40, 0), fixed(0))); + } + + @Test + @DisplayName("A point in the middle keeps its pause") + void middlePointKeepsItsPause() { + PathRoute route = route(new CreekRoute("M", List.of( + CreekWaypoint.of(new Vec(0, 40, 0)), + CreekWaypoint.of(new Vec(10, 40, 0)).withPause(1500), + CreekWaypoint.of(new Vec(20, 40, 0))))); + next(route, new Pos(0, 40, 0), fixed(0)); + + assertEquals(new RouteStep(new Pos(10, 40, 0), 1500), step(route, new Pos(0, 40, 0), fixed(0))); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/SpotFinderTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/SpotFinderTest.java new file mode 100644 index 00000000..3a10537f --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/SpotFinderTest.java @@ -0,0 +1,58 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.random.RandomGenerator; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SpotFinderTest { + + private static final CreekSight SIGHT = new CreekSight(48, 35); + private static final SpotFinder SPOTS = new SpotFinder(SIGHT, Optional::of); + private static final Pos TARGET = new Pos(0, 40, 0, 0, 0); + private static final Pos TARGET_EYES = TARGET.add(0, 1.62, 0); + + @Test + @DisplayName("A spot beside the view stays in the band and out of the cone") + void besideStaysOutOfTheCone() { + RandomGenerator random = new Random(1); + for (int i = 0; i < 50; i++) { + Pos spot = SPOTS.beside(TARGET, 20, 35, 40, 70, List.of(TARGET_EYES), 15, random).orElseThrow(); + double distance = spot.distance(TARGET); + assertTrue(distance >= 20 - 1.0E-6 && distance <= 35 + 1.0E-6, "distance was " + distance); + assertFalse(SIGHT.inView(TARGET_EYES, spot.add(0, SpotFinder.BODY_CENTRE, 0))); + } + } + + @Test + @DisplayName("A spot straight ahead is not hidden") + void aheadIsNotHidden() { + assertFalse(SPOTS.isHidden(new Pos(0, 40, 20), List.of(TARGET_EYES), 15)); + } + + @Test + @DisplayName("A spot too close is not hidden, even behind") + void tooCloseIsNotHidden() { + assertFalse(SPOTS.isHidden(new Pos(0, 40, -5), List.of(TARGET_EYES), 15)); + } + + @Test + @DisplayName("A spot far behind is hidden") + void farBehindIsHidden() { + assertTrue(SPOTS.isHidden(new Pos(0, 40, -20), List.of(TARGET_EYES), 15)); + } + + @Test + @DisplayName("Without ground there is no spot") + void noGroundNoSpot() { + SpotFinder nowhere = new SpotFinder(SIGHT, _ -> Optional.empty()); + assertTrue(nowhere.beside(TARGET, 20, 35, 40, 70, List.of(TARGET_EYES), 15, new Random(1)).isEmpty()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameFinishListenerTest.java b/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameFinishListenerTest.java new file mode 100644 index 00000000..41c45650 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameFinishListenerTest.java @@ -0,0 +1,74 @@ +package net.onelitefeather.cygnus.listener.game; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the round-end cleanup that used to be missing: a player who survived to the round's + * natural end (as opposed to dying or quitting, which already clear this via + * {@link net.onelitefeather.cygnus.listener.PlayerDeathListener} and + * {@link net.onelitefeather.cygnus.listener.PlayerQuitListener}) kept carrying + * {@link Tags#TEAM_KEY} and the leftover SlenderEye item into the {@code RestartPhase} countdown, + * which let the item and sprinting keep behaving as if the round were still live. + */ +@Disabled +class GameFinishListenerTest extends CygnusPlayerTestBase { + + @Test + void testSlenderLosesTeamTagAndItemsOnRoundFinish(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer slender = (CygnusPlayer) env.createPlayer(instance); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + slender.getInventory().addItemStack(ItemStack.of(Material.ENDER_EYE)); + + GameFinishListener listener = new GameFinishListener(); + listener.accept(new GameFinishEvent(GameFinishEvent.Reason.ALL_PAGES_FOUND)); + + assertFalse(slender.hasTag(Tags.TEAM_KEY), "the slender role must not survive round end"); + assertTrue(slender.getInventory().getItemStack(0).isAir(), "the leftover SlenderEye must be gone"); + + env.destroyInstance(instance, true); + } + + @Test + void testSurvivorLosesTeamTagOnRoundFinish(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer survivor = (CygnusPlayer) env.createPlayer(instance); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + + GameFinishListener listener = new GameFinishListener(); + listener.accept(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertFalse(survivor.hasTag(Tags.TEAM_KEY), "the survivor role must not survive round end"); + + env.destroyInstance(instance, true); + } + + @Test + void testSpectatorKeepsItsTagOnRoundFinish(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player spectator = env.createPlayer(instance); + spectator.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + + GameFinishListener listener = new GameFinishListener(); + listener.accept(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertTrue(spectator.hasTag(Tags.TEAM_KEY), "spectators are not part of the round-end reset"); + + env.destroyInstance(instance, true); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java index 4a674a43..245a5a88 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java @@ -1,11 +1,14 @@ package net.onelitefeather.cygnus.map; import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; import net.minestom.server.instance.InstanceContainer; import net.minestom.server.world.DimensionType; import net.minestom.server.world.attribute.EnvironmentAttribute; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekRoutesFile; import net.onelitefeather.cygnus.common.dimension.MapAtmosphere; import net.onelitefeather.cygnus.common.dimension.StaticDimensionPreset; import net.onelitefeather.cygnus.common.map.GameMap; @@ -211,6 +214,21 @@ void loadsPageFacesFromTheSiblingPagesJson(Env env, @TempDir Path root) throws I env.destroyInstance(gameInstance, true); } + @Test + void testGameMapCarriesTheCreekRoutes(Env env, @TempDir Path root) throws IOException { + GameMapProvider provider = createProvider(root); + Path mapFile = root.resolve("game").resolve("maps").resolve(ARENA_NAME).resolve("map.json"); + CreekRoute route = CreekRoute.ofPositions("Waldweg", List.of(new Vec(0, 80, 0), new Vec(5, 80, 0))); + CreekRoutesFile.save(mapFile, List.of(route)); + + provider.loadGameMap(); + + assertEquals(List.of(route), provider.getGameMap().getCreekRoutes()); + InstanceContainer gameInstance = (InstanceContainer) provider.getActiveInstance().get(); + provider.close(); + env.destroyInstance(gameInstance, true); + } + /** * Creates a map directory layout the provider accepts and returns a provider reading it. * diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java index 1fe45c52..b764e5b8 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; /** * Verifies the stamina share other systems read off the survivor's bar. @@ -29,4 +30,21 @@ void freshBarIsFull(Env env) { assertEquals(1.0f, bar.remainingShare(), 1.0E-6f); } + + @Test + @DisplayName("Draining empties the bar and leaves it to regenerate") + void drainEmptiesTheBar(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) player); + + bar.drain(); + + assertEquals(0.0f, bar.remainingShare(), 1.0E-6f); + assertFalse(bar.canConsume(), "an empty bar must not allow sprinting again straight away"); + + bar.consume(); + + assertEquals(1.0f / 20, bar.remainingShare(), 1.0E-6f); + } } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupExtension.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupExtension.java index 619f1cd8..bad5131d 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupExtension.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupExtension.java @@ -29,6 +29,7 @@ import net.onelitefeather.cygnus.setup.listener.PageCreationListener; import net.onelitefeather.cygnus.setup.listener.PlayerSpawnListener; import net.onelitefeather.cygnus.setup.listener.SetupItemListener; +import net.onelitefeather.cygnus.setup.listener.CreekRouteListener; import net.onelitefeather.cygnus.setup.listener.SpawnCreationListener; import net.onelitefeather.cygnus.setup.listener.data.PlayerRemoveDataListener; import net.onelitefeather.cygnus.setup.listener.dialog.DialogPayloadListener; @@ -99,6 +100,10 @@ private void registerSetupComponents() { .ignoreCancelled(false) .handler(new SpawnCreationListener(this.dataService)) .build()); + manager.addListener(EventListener.builder(PlayerBlockBreakEvent.class) + .ignoreCancelled(false) + .handler(new CreekRouteListener(this.dataService)) + .build()); manager.addListener(AddEntityToInstanceEvent.class, new InstanceAddListener(instanceUUID)); manager.addListener(RemoveEntityFromInstanceEvent.class, new InstanceRemoveListener(instanceUUID)); registerCancelListener(manager); diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java new file mode 100644 index 00000000..ad6e2514 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java @@ -0,0 +1,194 @@ +package net.onelitefeather.cygnus.setup.creek; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.metadata.display.AbstractDisplayMeta; +import net.minestom.server.entity.metadata.display.TextDisplayMeta; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.ParticlePacket; +import net.minestom.server.particle.Particle; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import net.onelitefeather.cygnus.common.creek.CreekLinks; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Shows the creek route being edited to the player editing it: particles at every point and along + * the route, the route's name above its start, and a marker on every point that links to another route. + * Without a route being edited it shows nothing. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekRoutePreview { + + /** How often the particles are drawn, in ticks. */ + static final int INTERVAL_TICKS = 10; + + /** Distance between two particles on a line, in blocks. */ + static final double LINE_STEP = 0.5D; + + /** How far above the start point the name floats, in blocks. */ + static final double LABEL_HEIGHT = 2.5D; + + private static final Particle POINT_PARTICLE = Particle.FLAME; + private static final Particle LINE_PARTICLE = Particle.CRIT; + private static final Particle LINK_PARTICLE = Particle.HAPPY_VILLAGER; + + private final Player player; + private final Supplier> routes; + private final Supplier<@Nullable String> active; + private final double linkDistance; + private final List labels = new ArrayList<>(); + private @Nullable Task task; + + /** + * Creates the preview. + * + * @param player the player who sees it + * @param routes supplies the current routes + * @param active supplies the name of the route being edited, or {@code null} + * @param linkDistance how close an end has to be to a point of another route to link to it, in blocks + */ + public CreekRoutePreview(Player player, Supplier> routes, Supplier<@Nullable String> active, + double linkDistance) { + this.player = player; + this.routes = routes; + this.active = active; + this.linkDistance = linkDistance; + } + + /** + * Starts drawing. Does nothing if it is already running. + */ + public void show() { + if (this.task != null) return; + this.refreshLabels(); + this.task = MinecraftServer.getSchedulerManager() + .buildTask(this::draw) + .repeat(TaskSchedule.tick(INTERVAL_TICKS)) + .schedule(); + } + + /** + * Stops drawing and removes the names. + */ + public void hide() { + if (this.task != null) { + this.task.cancel(); + this.task = null; + } + this.labels.forEach(Entity::remove); + this.labels.clear(); + } + + /** + * Rebuilds the floating name after the edited route changed. Without a route being edited the + * name goes away. + */ + public void refreshLabels() { + this.labels.forEach(Entity::remove); + this.labels.clear(); + Instance instance = this.player.getInstance(); + CreekRoute route = this.activeRoute(this.routes.get()); + if (instance == null || route == null || route.points().isEmpty()) return; + + Vec start = route.points().getFirst().position(); + Entity label = new Entity(EntityType.TEXT_DISPLAY); + TextDisplayMeta meta = (TextDisplayMeta) label.getEntityMeta(); + meta.setText(Component.text(route.name(), NamedTextColor.GREEN)); + meta.setBillboardRenderConstraints(AbstractDisplayMeta.BillboardConstraints.CENTER); + label.setAutoViewable(false); + label.setInstance(instance, new Pos(start.x(), start.y() + LABEL_HEIGHT, start.z())) + .thenRun(() -> label.addViewer(this.player)); + this.labels.add(label); + } + + private void draw() { + if (!this.player.isOnline()) { + this.hide(); + return; + } + List routes = this.routes.get(); + CreekRoute route = this.activeRoute(routes); + if (route == null) return; + + List points = route.points().stream().map(CreekWaypoint::position).toList(); + for (int i = 0; i < points.size(); i++) { + this.particle(POINT_PARTICLE, points.get(i)); + if (i + 1 < points.size()) { + line(points.get(i), points.get(i + 1), LINE_STEP).forEach(spot -> this.particle(LINE_PARTICLE, spot)); + } + } + linkedPoints(route, routes, this.linkDistance).forEach(point -> this.particle(LINK_PARTICLE, point.add(0, 0.5, 0))); + } + + private @Nullable CreekRoute activeRoute(List routes) { + String activeName = this.active.get(); + if (activeName == null) return null; + for (CreekRoute route : routes) { + if (route.name().equals(activeName)) return route; + } + return null; + } + + private void particle(Particle particle, Vec spot) { + this.player.sendPacket(new ParticlePacket(particle, spot.x(), spot.y() + 0.2D, spot.z(), 0f, 0f, 0f, 0f, 1)); + } + + /** + * Returns the spots between two points, one every {@code step} blocks, without the points + * themselves. + * + * @param from the first point + * @param to the second point + * @param step the distance between two spots + * @return the spots + */ + static List line(Vec from, Vec to, double step) { + double length = from.distance(to); + List spots = new ArrayList<>(); + for (double travelled = step; travelled < length - 1.0E-9; travelled += step) { + double share = travelled / length; + spots.add(from.add(to.sub(from).mul(share))); + } + return spots; + } + + /** + * Returns the points of a route that link to another route, as {@link CreekLinks} links them + * for the game. + * + * @param route the route whose points are checked + * @param routes all routes of the map, the checked one included + * @param distance the link distance, in blocks + * @return the linked points of {@code route} + */ + static Set linkedPoints(CreekRoute route, List routes, double distance) { + int index = routes.indexOf(route); + if (index < 0) return Set.of(); + CreekLinks links = CreekLinks.of(routes, distance); + Set linked = new HashSet<>(); + for (int point = 0; point < route.points().size(); point++) { + if (!links.of(new CreekLinks.Node(index, point)).isEmpty()) { + linked.add(route.points().get(point).position()); + } + } + return linked; + } +} diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/package-info.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/package-info.java new file mode 100644 index 00000000..f7aeaf6e --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/package-info.java @@ -0,0 +1,7 @@ +/** + * Setup tools for the creek routes of a map. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.setup.creek; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java index 768bb70b..65241b10 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java @@ -7,12 +7,22 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; import net.minestom.server.inventory.InventoryType; import net.minestom.server.utils.Direction; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekRoutesFile; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import net.onelitefeather.cygnus.common.map.GameMap; import net.onelitefeather.cygnus.common.map.GameMapBuilder; import net.onelitefeather.cygnus.common.util.GsonHelper; +import net.onelitefeather.cygnus.setup.creek.CreekRoutePreview; +import net.onelitefeather.cygnus.setup.event.dialog.DialogContext; +import net.onelitefeather.cygnus.setup.event.dialog.DialogRequestEvent; +import net.onelitefeather.cygnus.setup.event.dialog.DialogTarget; import net.onelitefeather.cygnus.setup.inventory.page.PageHeaderFormatter; +import net.onelitefeather.cygnus.setup.inventory.slot.CreekRouteSlot; import net.onelitefeather.cygnus.setup.inventory.slot.PageSlot; import net.onelitefeather.cygnus.setup.inventory.view.InventoryMode; import net.onelitefeather.cygnus.setup.inventory.view.MapDataOverviewInventory; @@ -31,6 +41,7 @@ import net.theevilreaper.aves.inventory.util.LayoutCalculator; import net.theevilreaper.aves.map.BaseMapBuilder; import net.theevilreaper.aves.map.MapEntry; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; @@ -41,9 +52,14 @@ public class GameData extends InstanceSetupData { private final MapDataOverviewInventory inventory; private final SurvivorViewInventory survivorInventory; private final PageableInventory pageInventory; + private final PageableInventory creekRouteInventory; + private final List creekRouteSlots; + private final CreekRoutePreview creekRoutePreview; private GameMapBuilder gameMapBuilder; private boolean pageMode; private boolean survivorMode; + private boolean creekRouteMode; + private @Nullable String activeCreekRoute; /** * Constructs a new GameData instance. @@ -52,12 +68,70 @@ public class GameData extends InstanceSetupData { * @param mapEntry the map entry associated with this game data */ public GameData(Player player, MapEntry mapEntry) { - super(player.getUuid(), mapEntry, BossBar.Color.RED); + super(player, mapEntry, BossBar.Color.RED); this.loadData(); - + this.creekRouteSlots = new ArrayList<>(); this.inventory = new MapDataOverviewInventory(player, this.gameMapBuilder, InventoryMode.GAME); this.survivorInventory = new SurvivorViewInventory(player, this.gameMapBuilder); this.pageInventory = createPageInventory(player); + this.creekRouteInventory = createCreekRouteInventory(player); + this.creekRoutePreview = new CreekRoutePreview(player, () -> this.gameMapBuilder.getCreekRoutes(), + () -> this.activeCreekRoute, CreekConfig.DEFAULT.routeLinkDistance()); + this.refreshCreekRoutes(); + } + + /** + * Creates the {@link PageableInventory} to display the creek routes of the map. + * + * @param player who owns the inventory + * @return the created inventory + */ + private PageableInventory createCreekRouteInventory(Player player) { + InventoryLayout layout = InventoryLayout.fromType(InventoryType.CHEST_6_ROW); + layout.setItems(LayoutCalculator.fillRow(InventoryType.CHEST_1_ROW), SetupItems.DECORATION_PANE); + layout.setItems(LayoutCalculator.fillRow(InventoryType.CHEST_6_ROW), SetupItems.DECORATION_PANE); + return PageableInventory.builder() + .titleData(TitleData.builder() + .title(Component.text("Creek routes - ")) + .pageMapper(PageHeaderFormatter::format) + .showPageNumbers(true) + .build()) + .player(player) + .slotRange(LayoutCalculator.quad(InventoryType.CHEST_1_ROW.getSize(), InventoryType.CHEST_5_ROW.getSize() - 1)) + .layout(layout) + .values(new ArrayList<>()) + .build(); + } + + /** + * Rebuilds the route overview after routes or points changed. + */ + private void refreshCreekRoutes() { + this.creekRouteSlots.forEach(this.creekRouteInventory::remove); + this.creekRouteSlots.clear(); + for (CreekRoute route : this.gameMapBuilder.getCreekRoutes()) { + CreekRouteSlot slot = new CreekRouteSlot(route, route.name().equals(this.activeCreekRoute), + this::selectFromOverview, this::deleteFromOverview); + this.creekRouteSlots.add(slot); + this.creekRouteInventory.add(slot); + } + if (this.hasCreekRouteMode()) { + this.creekRoutePreview.refreshLabels(); + } + } + + private void selectFromOverview(Player player, String name) { + if (this.selectCreekRoute(name)) { + player.sendMessage(SetupMessages.getCreekRouteSelected(name)); + player.sendMessage(SetupMessages.CREEK_POINT_HINT); + SetupItems.setCreekRouteEditItems(player); + } + } + + private void deleteFromOverview(Player player, String name) { + if (this.deleteCreekRoute(name)) { + player.sendMessage(SetupMessages.getCreekRouteDeleted(name)); + } } /** @@ -130,6 +204,160 @@ public void swapSurvivorMode() { this.survivorMode = !this.survivorMode; } + /** + * Returns whether the creek route mode is on. + * + * @return {@code true} while routes are being edited + */ + public boolean hasCreekRouteMode() { + return this.creekRouteMode; + } + + /** + * Turns the creek route mode on or off. + */ + public void swapCreekRouteMode() { + this.creekRouteMode = !this.creekRouteMode; + } + + /** + * Creates a creek route and makes it the active one. + * + * @param name the route's name + * @return {@code false} if the name is empty or taken + */ + public boolean createCreekRoute(String name) { + String trimmed = name.trim(); + if (!this.gameMapBuilder.addCreekRoute(trimmed)) return false; + this.activeCreekRoute = trimmed; + this.refreshCreekRoutes(); + return true; + } + + /** + * Makes a creek route the active one. + * + * @param name the route's name + * @return {@code false} if there is no such route + */ + public boolean selectCreekRoute(String name) { + if (!this.gameMapBuilder.hasCreekRoute(name)) return false; + this.activeCreekRoute = name; + this.refreshCreekRoutes(); + return true; + } + + /** + * Deletes a creek route. + * + * @param name the route's name + * @return {@code false} if there was no such route + */ + public boolean deleteCreekRoute(String name) { + if (!this.gameMapBuilder.removeCreekRoute(name)) return false; + if (name.equals(this.activeCreekRoute)) this.activeCreekRoute = null; + this.refreshCreekRoutes(); + return true; + } + + /** + * Appends a point to the active creek route. + * + * @param point where the creek's feet stand + * @return {@code false} without an active route + */ + public boolean addCreekPoint(Vec point) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null || !this.gameMapBuilder.addCreekPoint(activeRoute, point)) return false; + this.refreshCreekRoutes(); + return true; + } + + /** + * Removes the point of the active creek route that sits at the given position. + * + * @param point the position of the point to remove + * @return the number of the removed point, counting from 1, or {@code -1} without an active + * route or without a point there + */ + public int removeCreekPointAt(Vec point) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null) return -1; + int removed = this.gameMapBuilder.removeCreekPoint(activeRoute, point); + if (removed > 0) this.refreshCreekRoutes(); + return removed; + } + + /** + * Removes the last point of the active creek route. + * + * @return {@code false} without an active route or without points + */ + public boolean removeLastCreekPoint() { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null || !this.gameMapBuilder.removeLastCreekPoint(activeRoute)) return false; + this.refreshCreekRoutes(); + return true; + } + + /** + * Sets how long the creek waits at the start of the active route. + * + * @param millis the pause, in milliseconds + * @return {@code false} without an active route or without a point + */ + public boolean setCreekStartPause(int millis) { + return this.setCreekPause(true, millis); + } + + /** + * Sets how long the creek waits at the end of the active route. + * + * @param millis the pause, in milliseconds + * @return {@code false} without an active route or with fewer than two points + */ + public boolean setCreekEndPause(int millis) { + return this.setCreekPause(false, millis); + } + + private boolean setCreekPause(boolean atStart, int millis) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null || !this.gameMapBuilder.setCreekEndPause(activeRoute, atStart, millis)) return false; + this.refreshCreekRoutes(); + return true; + } + + /** + * Stops editing the active creek route. The route itself stays. + * + * @return {@code false} if no route was being edited + */ + public boolean finishCreekRoute() { + if (this.activeCreekRoute == null) return false; + this.activeCreekRoute = null; + this.refreshCreekRoutes(); + return true; + } + + /** + * Returns the name of the creek route being edited. + * + * @return the name, or {@code null} if none is active + */ + public @Nullable String activeCreekRoute() { + return this.activeCreekRoute; + } + + /** + * Returns how many points the active creek route has. + * + * @return the number of points, {@code 0} without an active route + */ + public int activeCreekPointCount() { + String activeRoute = this.activeCreekRoute; + return activeRoute == null ? 0 : this.gameMapBuilder.getCreekRoutePoints(activeRoute).size(); + } + /** * {@inheritDoc} */ @@ -258,6 +486,80 @@ public void handleItemInteraction(Player player, byte tagValue) { return; } + if (SetupItemId.CREEK_ROUTES == tagValue) { + if (!hasCreekRouteMode() && (hasPageMode() || hasSurvivorMode())) { + player.sendMessage(SetupMessages.getModeInform(hasPageMode() ? "page" : "survivor")); + return; + } + this.swapCreekRouteMode(); + if (hasCreekRouteMode()) { + player.sendMessage(SetupMessages.CREEK_MODE_ENABLED); + player.sendMessage(SetupMessages.getModeInform("creek route")); + SetupItems.setCreekRouteOverviewItems(player); + this.creekRoutePreview.show(); + } else { + this.finishCreekRoute(); + this.creekRoutePreview.hide(); + player.sendMessage(SetupMessages.CREEK_MODE_DISABLED); + SetupItems.setGameLayout(player); + } + return; + } + if (SetupItemId.CREEK_LEAVE == tagValue) { + if (hasCreekRouteMode()) { + this.swapCreekRouteMode(); + } + this.finishCreekRoute(); + this.creekRoutePreview.hide(); + player.sendMessage(SetupMessages.CREEK_MODE_DISABLED); + SetupItems.setGameLayout(player); + return; + } + if (SetupItemId.CREEK_NEW == tagValue) { + EventDispatcher.call(new DialogRequestEvent(player, DialogTarget.CREEK_ROUTE_NAME)); + return; + } + if (SetupItemId.CREEK_UNDO == tagValue) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null) { + player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE); + } else if (!this.removeLastCreekPoint()) { + player.sendMessage(SetupMessages.NO_CREEK_POINT_TO_REMOVE); + } else { + player.sendMessage(SetupMessages.getCreekPointRemoved(activeRoute, this.activeCreekPointCount())); + } + return; + } + if (SetupItemId.CREEK_LIST == tagValue) { + this.creekRouteInventory.open(); + return; + } + if (SetupItemId.CREEK_FINISH == tagValue) { + String activeRoute = this.activeCreekRoute; + int pointCount = this.activeCreekPointCount(); + if (activeRoute != null && this.finishCreekRoute()) { + player.sendMessage(SetupMessages.getCreekRouteFinished(activeRoute, pointCount)); + if (pointCount < CreekRoute.MIN_POINTS) { + player.sendMessage(SetupMessages.CREEK_ROUTE_NOT_USABLE); + } + } + SetupItems.setCreekRouteOverviewItems(player); + return; + } + if (SetupItemId.CREEK_PAUSE == tagValue) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null) { + player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE); + return; + } + List points = this.gameMapBuilder.getCreekRoutePoints(activeRoute); + int startMillis = points.isEmpty() ? 0 : points.getFirst().pauseMillis(); + int endMillis = points.size() < CreekRoute.MIN_POINTS ? 0 : points.getLast().pauseMillis(); + EventDispatcher.call(new DialogRequestEvent(player, DialogTarget.CREEK_ROUTE_PAUSE, + new DialogContext.CreekPauseContext(activeRoute, startMillis, endMillis))); + return; + } + super.handleItemInteraction(player, tagValue); } @@ -290,7 +592,6 @@ public void handleDataContextDelete(MapDataCategory category, Point point) { this.gameMapBuilder.removeSurvivorSpawn(pos); this.triggerUpdate(InventoryTarget.SURVIVOR); } else if (category == MapDataCategory.PAGE) { - Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(this.uuid); PageResource pageResource = null; if (player instanceof SetupPlayer setupPlayer) { pageResource = setupPlayer.getPageResource(); @@ -312,6 +613,7 @@ public void save() { } GameMap map = this.gameMapBuilder.build(); PageFacesFile.save(mapEntry.getMapFile(), map.getPageFaces()); + CreekRoutesFile.save(mapEntry.getMapFile(), map.getCreekRoutes()); GsonHelper.FILE_HANDLER.save(mapEntry.getMapFile(), map); } @@ -330,9 +632,12 @@ public void teleport(Player player) { @Override public void reset() { super.reset(); + this.bossBar.removeViewer(player); this.survivorInventory.unregister(); this.inventory.unregister(); this.pageInventory.unregister(); + this.creekRouteInventory.unregister(); + this.creekRoutePreview.hide(); } /** @@ -344,6 +649,7 @@ public void loadData() { this.mapEntry.hasMapFile() ? GsonHelper.FILE_HANDLER.load(mapEntry.getMapFile(), GameMap.class) .map(map -> PageFacesFile.loadInto(mapEntry.getMapFile(), map)) + .map(map -> map.withCreekRoutes(CreekRoutesFile.loadUnchecked(mapEntry.getMapFile()))) : Optional.empty(); this.gameMapBuilder = mapData diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java index 6127b7a5..cfc94269 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java @@ -34,7 +34,7 @@ public abstract class InstanceSetupData implements SetupData { protected static final Pos SPAWN_POINT = new Pos(0, 100, 0); - protected UUID uuid; + protected Player player; protected MapEntry mapEntry; protected @Nullable InstanceContainer instance; protected @Nullable FalcoAnvilLoader chunkLoader; @@ -44,12 +44,12 @@ public abstract class InstanceSetupData implements SetupData { /** * Creates a new setup data container. * - * @param uuid unique identifier of this setup session + * @param player who owns the setup * @param mapEntry map entry associated with the setup * @param color boss bar color used for setup feedback */ - protected InstanceSetupData(UUID uuid, MapEntry mapEntry, BossBar.Color color) { - this.uuid = uuid; + protected InstanceSetupData(Player player, MapEntry mapEntry, BossBar.Color color) { + this.player = player; this.mapEntry = mapEntry; this.bossBar = BossBar.bossBar(Component.empty(), 1, color, BossBar.Overlay.PROGRESS); } @@ -170,7 +170,7 @@ public MapEntry getMapEntry() { */ @Override public UUID getId() { - return this.uuid; + return this.player.getUuid(); } /** diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java index 6bb9e6d0..595036b7 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java @@ -28,7 +28,7 @@ public final class LobbyData extends InstanceSetupData { * @param mapEntry that contains the map */ public LobbyData(Player player, MapEntry mapEntry) { - super(player.getUuid(), mapEntry, BossBar.Color.GREEN); + super(player, mapEntry, BossBar.Color.GREEN); this.loadData(); this.viewInventory = new MapDataOverviewInventory(player, this.mapBuilder, InventoryMode.LOBBY); } @@ -120,6 +120,7 @@ public void teleport(Player player) { @Override public void reset() { super.reset(); + this.bossBar.removeViewer(player); this.viewInventory.unregister(); } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java index f4b6283e..f299b012 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java @@ -133,7 +133,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) { range.end(128f); range.step(1f); range.initial(atmosphere.fogStartDistance()); - range.labelFormat("%s: %.0f blocks"); + range.labelFormat("%s: %s blocks"); }); meta.range(FOG_END_INPUT, range -> { range.label(Component.text("Fully fogged at")); @@ -142,7 +142,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) { range.end(384f); range.step(1f); range.initial(atmosphere.fogEndDistance()); - range.labelFormat("%s: %.0f blocks"); + range.labelFormat("%s: %s blocks"); }); meta.range(SKY_FOG_END_INPUT, range -> { range.label(Component.text("Horizon haze at")); @@ -151,7 +151,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) { range.end(256f); range.step(1f); range.initial(atmosphere.skyFogEndDistance()); - range.labelFormat("%s: %.0f blocks"); + range.labelFormat("%s: %s blocks"); }); meta.range(SKY_LIGHT_INPUT, range -> { range.label(Component.text("Sky light")); @@ -160,7 +160,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) { range.end(100f); range.step(1f); range.initial(atmosphere.skyLightFactor() * SKY_LIGHT_SCALE); - range.labelFormat("%s: %.0f"); + range.labelFormat("%s: %s"); }); meta.text(FOG_COLOR_INPUT, text -> text.label(Component.text("Fog color")) diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java new file mode 100644 index 00000000..d485ffcf --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java @@ -0,0 +1,102 @@ +package net.onelitefeather.cygnus.setup.dialog; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.minestom.server.dialog.DialogAction; +import net.minestom.server.dialog.DialogAfterAction; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.setup.event.dialog.DialogContext; +import net.onelitefeather.cygnus.setup.util.DialogBase; +import net.onelitefeather.pica.dialog.DialogTemplate; +import net.onelitefeather.pica.dialog.type.DialogType; + +/** + * The dialog that asks for the name of a new creek route. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekRouteDialogs extends DialogBase { + + public static final Key ROUTE_NAME_KEY = create("creek_route_name"); + public static final Key PAUSE_KEY = create("creek_route_pause"); + public static final String START_PAUSE_INPUT = "start"; + public static final String END_PAUSE_INPUT = "end"; + + /** Long enough for a descriptive name, short enough for the debug line. */ + private static final int MAX_NAME_LENGTH = 48; + + /** Longest pause the dialog offers, in seconds. */ + private static final float MAX_PAUSE_SECONDS = 30f; + + /** + * Opens the dialog. + * + * @param player the player + */ + public static void openNameDialog(Player player) { + DialogTemplate dialogTemplate = DialogType.confirm(ROUTE_NAME_KEY) + .meta(dialogMeta -> { + dialogMeta.closeWithEscape(false); + dialogMeta.pause(false); + dialogMeta.afterAction(DialogAfterAction.CLOSE); + dialogMeta.title(Component.text("Creek route")); + dialogMeta.emptyMessage(); + dialogMeta.messageBody(template -> + template.contents(Component.text("Enter the name of the new route"))); + dialogMeta.text("name", textInputTemplate -> + textInputTemplate.maxLength(MAX_NAME_LENGTH).initial("")); + }) + .yesButton(button -> button.width(101).label(Component.text("Create")) + .action(new DialogAction.DynamicCustom(ROUTE_NAME_KEY, getEmptyPayload()))) + .noButton(button -> button.width(101).label(NO_COMPONENT)) + .build(); + dialogTemplate.open(player); + } + + /** + * Opens the dialog for the pauses at the start and the end of a route. + * + * @param player the player + * @param context the route and its current pauses + */ + public static void openPauseDialog(Player player, DialogContext.CreekPauseContext context) { + DialogTemplate dialogTemplate = DialogType.confirm(PAUSE_KEY) + .meta(dialogMeta -> { + dialogMeta.closeWithEscape(true); + dialogMeta.pause(false); + dialogMeta.afterAction(DialogAfterAction.CLOSE); + dialogMeta.title(Component.text("Creek route")); + dialogMeta.emptyMessage(); + dialogMeta.messageBody(template -> + template.contents(Component.text("Pauses of " + context.route()))); + dialogMeta.range(START_PAUSE_INPUT, range -> { + range.label(Component.text("Pause at the start")); + range.width(202); + range.start(0f); + range.end(MAX_PAUSE_SECONDS); + range.step(0.5f); + range.initial(context.startMillis() / 1000f); + range.labelFormat("%s: %s s"); + }); + dialogMeta.range(END_PAUSE_INPUT, range -> { + range.label(Component.text("Pause at the end")); + range.width(202); + range.start(0f); + range.end(MAX_PAUSE_SECONDS); + range.step(0.5f); + range.initial(context.endMillis() / 1000f); + range.labelFormat("%s: %s s"); + }); + }) + .yesButton(button -> button.width(101).label(Component.text("Save")) + .action(new DialogAction.DynamicCustom(PAUSE_KEY, getEmptyPayload()))) + .noButton(button -> button.width(101).label(NO_COMPONENT)) + .build(); + dialogTemplate.open(player); + } + + private CreekRouteDialogs() { + } +} diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java new file mode 100644 index 00000000..060634f5 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java @@ -0,0 +1,47 @@ +package net.onelitefeather.cygnus.setup.dialog.handler; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.StringBinaryTag; +import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerCustomClickEvent; +import net.onelitefeather.cygnus.setup.data.GameData; +import net.onelitefeather.cygnus.setup.item.SetupItems; +import net.onelitefeather.cygnus.setup.util.SetupMessages; +import net.onelitefeather.guira.SetupDataService; + +/** + * Creates a creek route from the name entered in the dialog. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekRouteNameHandler implements DialogHandler { + + private final SetupDataService dataService; + + public CreekRouteNameHandler(SetupDataService dataService) { + this.dataService = dataService; + } + + @Override + public void handle(PlayerCustomClickEvent event, CompoundBinaryTag payload) { + if (!(payload.get("name") instanceof StringBinaryTag nameTag)) return; + Player player = event.getPlayer(); + String name = nameTag.value().trim(); + this.dataService.get(player.getUuid()).ifPresent(data -> { + if (!(data instanceof GameData gameData)) return; + if (name.isEmpty()) { + player.sendMessage(SetupMessages.EMPTY_NAME); + return; + } + if (!gameData.createCreekRoute(name)) { + player.sendMessage(SetupMessages.getDuplicateCreekRoute(name)); + return; + } + player.sendMessage(SetupMessages.getCreekRouteCreated(name)); + player.sendMessage(SetupMessages.CREEK_POINT_HINT); + SetupItems.setCreekRouteEditItems(player); + }); + } +} diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java new file mode 100644 index 00000000..d119cf2c --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java @@ -0,0 +1,70 @@ +package net.onelitefeather.cygnus.setup.dialog.handler; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.DoubleBinaryTag; +import net.kyori.adventure.nbt.FloatBinaryTag; +import net.kyori.adventure.nbt.IntBinaryTag; +import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerCustomClickEvent; +import net.onelitefeather.cygnus.setup.data.GameData; +import net.onelitefeather.cygnus.setup.dialog.CreekRouteDialogs; +import net.onelitefeather.cygnus.setup.util.SetupMessages; +import net.onelitefeather.guira.SetupDataService; +import org.jetbrains.annotations.Nullable; + +/** + * Applies the pauses from the dialog to the active creek route. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekRoutePauseHandler implements DialogHandler { + + private final SetupDataService dataService; + + public CreekRoutePauseHandler(SetupDataService dataService) { + this.dataService = dataService; + } + + @Override + public void handle(PlayerCustomClickEvent event, CompoundBinaryTag payload) { + int startMillis = millis(payload.get(CreekRouteDialogs.START_PAUSE_INPUT)); + int endMillis = millis(payload.get(CreekRouteDialogs.END_PAUSE_INPUT)); + if (startMillis < 0 || endMillis < 0) return; + Player player = event.getPlayer(); + this.dataService.get(player.getUuid()).ifPresent(data -> { + if (!(data instanceof GameData gameData)) return; + String activeRoute = gameData.activeCreekRoute(); + if (activeRoute == null) { + player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE); + return; + } + if (!gameData.setCreekStartPause(startMillis)) { + player.sendMessage(SetupMessages.CREEK_PAUSE_NEEDS_POINT); + return; + } + if (!gameData.setCreekEndPause(endMillis)) { + player.sendMessage(SetupMessages.getCreekStartPauseSet(activeRoute, startMillis)); + player.sendMessage(SetupMessages.CREEK_END_PAUSE_NEEDS_TWO_POINTS); + return; + } + player.sendMessage(SetupMessages.getCreekPausesSet(activeRoute, startMillis, endMillis)); + }); + } + + /** + * Turns a slider value in seconds into milliseconds. + * + * @return the milliseconds, or {@code -1} if the client sent nothing usable + */ + private static int millis(@Nullable BinaryTag tag) { + return switch (tag) { + case FloatBinaryTag value -> Math.round(value.value() * 1000f); + case DoubleBinaryTag value -> (int) Math.round(value.value() * 1000D); + case IntBinaryTag value -> value.value() * 1000; + case null, default -> -1; + }; + } +} diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java index bcd9768c..8935d17a 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java @@ -18,7 +18,8 @@ public sealed interface DialogContext permits DialogContext.PositionContent, DialogContext.AuthorAmount, DialogContext.PageContent, - DialogContext.AtmosphereContext { + DialogContext.AtmosphereContext, + DialogContext.CreekPauseContext { /** * Specific context for the update or deletion of a name. @@ -65,4 +66,16 @@ record PageContent(PageResource resource) implements DialogContext { record AtmosphereContext(MapAtmosphere atmosphere) implements DialogContext { } + + + /** + * Carries the creek route whose pauses are edited, so the dialog can pre-fill its sliders. + * + * @param route the route's name + * @param startMillis the current pause at the start, in milliseconds + * @param endMillis the current pause at the end, in milliseconds + */ + record CreekPauseContext(String route, int startMillis, int endMillis) implements DialogContext { + + } } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java index 3006252e..dcb74bf1 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java @@ -15,4 +15,6 @@ public enum DialogTarget { UPDATE_NAME, ATMOSPHERE_PRESET, ATMOSPHERE_VALUES, + CREEK_ROUTE_NAME, + CREEK_ROUTE_PAUSE, } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java new file mode 100644 index 00000000..8a8db4c8 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java @@ -0,0 +1,86 @@ +package net.onelitefeather.cygnus.setup.inventory.slot; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.entity.Player; +import net.minestom.server.inventory.click.Click; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; +import net.theevilreaper.aves.inventory.click.ClickHolder; +import net.theevilreaper.aves.inventory.slot.Slot; + +import java.util.List; +import java.util.Locale; +import java.util.function.BiConsumer; + +import static net.onelitefeather.cygnus.setup.util.SetupMessages.DELETE_CLICK; +import static net.onelitefeather.cygnus.setup.util.SetupMessages.SELECT_CLICK; + +/** + * One creek route in the route overview. Left click selects it, right click deletes it. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekRouteSlot extends Slot { + + private final CreekRoute route; + private final boolean active; + + /** + * Creates the slot. + * + * @param route the route to show + * @param active whether the route is being edited + * @param select called with the clicking player and the name on a left click + * @param delete called with the clicking player and the name on a right click + */ + public CreekRouteSlot(CreekRoute route, boolean active, BiConsumer select, + BiConsumer delete) { + this.route = route; + this.active = active; + String name = route.name(); + this.setClick((player, _, click, _, result) -> { + result.accept(ClickHolder.cancelClick()); + switch (click) { + case Click.Left _ -> { + player.closeInventory(); + select.accept(player, name); + } + case Click.Right _ -> delete.accept(player, name); + default -> { + // Nothing to do here + } + } + }); + } + + @Override + public ItemStack getItem() { + List points = this.route.points(); + int startPause = points.isEmpty() ? 0 : points.getFirst().pauseMillis(); + int endPause = points.size() < CreekRoute.MIN_POINTS ? 0 : points.getLast().pauseMillis(); + return ItemStack.builder(Material.LEAD) + .customName(Component.text(this.route.name(), this.active ? NamedTextColor.GREEN : NamedTextColor.YELLOW)) + .lore(List.of( + Component.empty(), + Component.text("Points: ", NamedTextColor.GRAY) + .append(Component.text(points.size(), NamedTextColor.GOLD)), + Component.text("Pause: start ", NamedTextColor.GRAY) + .append(Component.text(seconds(startPause), NamedTextColor.GOLD)) + .append(Component.text(" · end ", NamedTextColor.GRAY)) + .append(Component.text(seconds(endPause), NamedTextColor.GOLD)), + Component.empty(), + SELECT_CLICK, + DELETE_CLICK, + Component.empty())) + .build(); + } + + private static String seconds(int millis) { + return String.format(Locale.ROOT, "%.1f s", millis / 1000.0D); + } +} diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java index 16737e4b..2e199caf 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java @@ -18,6 +18,13 @@ public final class SetupItemId { public static final byte SURVIVOR = (byte) 0x06; public static final byte SPAWNS = (byte) 0x07; public static final byte LEAVE_MODE = (byte) 0x08; + public static final byte CREEK_ROUTES = (byte) 0x09; + public static final byte CREEK_NEW = (byte) 0x0A; + public static final byte CREEK_UNDO = (byte) 0x0B; + public static final byte CREEK_LIST = (byte) 0x0C; + public static final byte CREEK_LEAVE = (byte) 0x0D; + public static final byte CREEK_PAUSE = (byte) 0x0E; + public static final byte CREEK_FINISH = (byte) 0x0F; private SetupItemId() { // Nothing to do here diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java index 39358213..6c99e3e0 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java @@ -29,6 +29,8 @@ public final class SetupItems { private static final HotBarLayout gameSetupLayout; private static final HotBarLayout pageLayout; private static final HotBarLayout survivorSpawnLayout; + private static final HotBarLayout creekRouteOverviewLayout; + private static final HotBarLayout creekRouteEditLayout; static { DECORATION_PANE = ItemStack.builder(Material.BLACK_STAINED_GLASS_PANE) @@ -59,13 +61,13 @@ public final class SetupItems { lobbySetupLayout.set(6, saveItem); gameSetupLayout = new HotBarLayout(); - gameSetupLayout.set(1, ItemStack.builder(Material.COMPASS) + gameSetupLayout.set(0, ItemStack.builder(Material.COMPASS) .customName(Component.text("Data", NamedTextColor.AQUA)) .lore(getLore(SPACE_SEPARATOR.append(Component.text("View basic map data", NamedTextColor.WHITE)))) .set(Tags.ITEM_TAG, SetupItemId.DATA) .build() ); - gameSetupLayout.set(3, ItemStack.builder(Material.PAPER) + gameSetupLayout.set(1, ItemStack.builder(Material.PAPER) .customName(Component.text("Page", NamedTextColor.AQUA)) .lore( getLore( @@ -80,7 +82,7 @@ public final class SetupItems { .build() ); - gameSetupLayout.set(5, ItemStack.builder(Material.MINECART) + gameSetupLayout.set(4, ItemStack.builder(Material.MINECART) .customName(Component.text("Survivor", NamedTextColor.GREEN)) .lore( getLore( @@ -93,7 +95,60 @@ public final class SetupItems { .set(Tags.ITEM_TAG, SetupItemId.SURVIVOR) .build() ); - gameSetupLayout.set(7, saveItem); + gameSetupLayout.set(7, ItemStack.builder(Material.LEAD) + .customName(Component.text("Creek routes", NamedTextColor.DARK_GREEN)) + .lore(getLore(SPACE_SEPARATOR + .append(Component.text("Switch to the ", NamedTextColor.WHITE)) + .append(Component.text("Creek route ", NamedTextColor.DARK_GREEN)) + .append(Component.text("setup mode", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_ROUTES) + .build() + ); + gameSetupLayout.set(8, saveItem); + + ItemStack leaveCreekMode = ItemStack.builder(Material.BARRIER) + .customName(Component.text("Leave mode", NamedTextColor.RED)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Exit the current mode", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_LEAVE) + .build(); + + creekRouteOverviewLayout = new HotBarLayout(); + creekRouteOverviewLayout.set(0, ItemStack.builder(Material.NAME_TAG) + .customName(Component.text("New route", NamedTextColor.GREEN)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Create a route and name it", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_NEW) + .build() + ); + creekRouteOverviewLayout.set(4, ItemStack.builder(Material.LEAD) + .customName(Component.text("Routes", NamedTextColor.AQUA)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Edit or delete a route", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_LIST) + .build() + ); + creekRouteOverviewLayout.set(8, leaveCreekMode); + + creekRouteEditLayout = new HotBarLayout(); + creekRouteEditLayout.set(0, ItemStack.builder(Material.FEATHER) + .customName(Component.text("Remove last point", NamedTextColor.YELLOW)) + .lore(getLore( + SPACE_SEPARATOR.append(Component.text("Undo the last point of the route", NamedTextColor.WHITE)), + SPACE_SEPARATOR.append(Component.text("Break a block to add or remove a point", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_UNDO) + .build() + ); + creekRouteEditLayout.set(2, ItemStack.builder(Material.CLOCK) + .customName(Component.text("Pauses", NamedTextColor.GOLD)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Set how long the creek waits at the ends", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_PAUSE) + .build() + ); + creekRouteEditLayout.set(6, ItemStack.builder(Material.EMERALD) + .customName(Component.text("Finish route", NamedTextColor.GREEN)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Stop editing and go back to the routes", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_FINISH) + .build() + ); + creekRouteEditLayout.set(8, leaveCreekMode); pageLayout = new HotBarLayout(); pageLayout.set(2, ItemStack.builder(Material.COMPASS) @@ -198,4 +253,24 @@ public static void setPageItems(Player player) { private SetupItems() { } -} \ No newline at end of file + + /** + * Sets the items for the creek route setup while no route is being edited. + * + * @param player the player + */ + public static void setCreekRouteOverviewItems(Player player) { + creekRouteOverviewLayout.apply(player); + player.setHeldItemSlot(DEFAULT); + } + + /** + * Sets the items for editing a creek route. + * + * @param player the player + */ + public static void setCreekRouteEditItems(Player player) { + creekRouteEditLayout.apply(player); + player.setHeldItemSlot(DEFAULT); + } +} diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java new file mode 100644 index 00000000..e581cde1 --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java @@ -0,0 +1,75 @@ +package net.onelitefeather.cygnus.setup.listener; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerBlockBreakEvent; +import net.minestom.server.instance.block.Block; +import net.onelitefeather.cygnus.setup.data.GameData; +import net.onelitefeather.cygnus.setup.util.SetupMessages; +import net.onelitefeather.cygnus.setup.util.SetupTags; +import net.onelitefeather.guira.SetupDataService; +import net.onelitefeather.guira.data.SetupData; + +import java.util.function.Consumer; + +/** + * Appends a point to the active creek route when a block is broken in the creek route mode, or + * removes the point that already sits on that block. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekRouteListener implements Consumer { + + private final SetupDataService setupService; + + public CreekRouteListener(SetupDataService setupService) { + this.setupService = setupService; + } + + @Override + public void accept(PlayerBlockBreakEvent event) { + event.setCancelled(true); + + Player player = event.getPlayer(); + + if (!player.hasTag(SetupTags.SETUP_ID_TAG)) return; + + SetupData setupData = this.setupService.get(player.getUuid()).orElse(null); + + if (setupData == null) return; + + if (!(setupData instanceof GameData gameData) || !gameData.hasCreekRouteMode()) return; + + String activeRoute = gameData.activeCreekRoute(); + if (activeRoute == null) { + player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE); + return; + } + + Vec point = pointOnTop(event.getBlockPosition(), event.getBlock()); + int removed = gameData.removeCreekPointAt(point); + if (removed > 0) { + player.sendMessage(SetupMessages.getCreekPointRemovedAt(activeRoute, removed, gameData.activeCreekPointCount())); + return; + } + + gameData.addCreekPoint(point); + player.sendMessage(SetupMessages.getCreekPointAdded(activeRoute, gameData.activeCreekPointCount())); + } + + /** + * Returns where the creek's feet stand on a block: in the middle, on top of its collision + * shape. A slab puts the feet half a block up, a plant without collision at its own bottom. + * + * @param position the position of the clicked block + * @param block the clicked block + * @return the point + */ + static Vec pointOnTop(Point position, Block block) { + double top = block.registry().collisionShape().relativeEnd().y(); + return new Vec(position.blockX() + 0.5D, position.blockY() + top, position.blockZ() + 0.5D); + } +} diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java index 7a5b92c0..13a2b308 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java @@ -6,7 +6,10 @@ import net.minestom.server.event.player.PlayerCustomClickEvent; import net.onelitefeather.cygnus.setup.atmosphere.AtmospherePreviewService; import net.onelitefeather.cygnus.setup.dialog.AtmosphereDialogs; +import net.onelitefeather.cygnus.setup.dialog.CreekRouteDialogs; import net.onelitefeather.cygnus.setup.dialog.MapDialogs; +import net.onelitefeather.cygnus.setup.dialog.handler.CreekRouteNameHandler; +import net.onelitefeather.cygnus.setup.dialog.handler.CreekRoutePauseHandler; import net.onelitefeather.cygnus.setup.dialog.handler.AtmosphereAdjustHandler; import net.onelitefeather.cygnus.setup.dialog.handler.AtmosphereConfirmHandler; import net.onelitefeather.cygnus.setup.dialog.handler.AtmospherePresetHandler; @@ -29,6 +32,8 @@ public class DialogPayloadListener implements Consumer { public DialogPayloadListener(SetupDataService dataService, AtmospherePreviewService previewService) { this.handlers = Map.ofEntries( Map.entry(MapDialogs.MAP_KEY, new MapNameHandler(dataService)), + Map.entry(CreekRouteDialogs.ROUTE_NAME_KEY, new CreekRouteNameHandler(dataService)), + Map.entry(CreekRouteDialogs.PAUSE_KEY, new CreekRoutePauseHandler(dataService)), Map.entry(MapDialogs.AUTHOR_AMOUNT_KEY, new AuthorAmountHandler()), Map.entry(MapDialogs.AUTHOR_INPUT_ENTRY_KEY, new AuthorInputHandler(dataService)), Map.entry(MapDialogs.NON_DYNAMIC_DELETE_KEY, new NonDynamicDataHandler(dataService)), diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java index 2cc690fa..82c27013 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java @@ -3,6 +3,7 @@ import net.minestom.server.entity.Player; import net.onelitefeather.cygnus.setup.dialog.AtmosphereDialogs; import net.onelitefeather.cygnus.setup.dialog.AuthorDialogs; +import net.onelitefeather.cygnus.setup.dialog.CreekRouteDialogs; import net.onelitefeather.cygnus.setup.dialog.MapDialogs; import net.onelitefeather.cygnus.setup.event.dialog.DialogContext; import net.onelitefeather.cygnus.setup.event.dialog.DialogRequestEvent; @@ -20,6 +21,12 @@ public void accept(DialogRequestEvent event) { switch (target) { case CREATE_NAME -> MapDialogs.openNameCreateDialog(player); + case CREEK_ROUTE_NAME -> CreekRouteDialogs.openNameDialog(player); + case CREEK_ROUTE_PAUSE -> { + if (context instanceof DialogContext.CreekPauseContext pauseContext) { + CreekRouteDialogs.openPauseDialog(player, pauseContext); + } + } case UPDATE_NAME -> { if (context instanceof DialogContext.NameContext(String name)) { MapDialogs.openNameUpdateDialog(player, name); diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java index 2ee31d36..ef87975f 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; /** * The class contains method and static constant values to handle specific messages during the setup @@ -35,6 +36,15 @@ public final class SetupMessages { public static final Component SURVIVOR_MODE_ENABLED; public static final Component SURVIVOR_MODE_DISABLED; public static final Component DUPLICATE_PAGE; + public static final Component CREEK_MODE_ENABLED; + public static final Component CREEK_MODE_DISABLED; + public static final Component NO_ACTIVE_CREEK_ROUTE; + public static final Component NO_CREEK_POINT_TO_REMOVE; + public static final Component SELECT_CLICK; + public static final Component CREEK_PAUSE_NEEDS_POINT; + public static final Component CREEK_END_PAUSE_NEEDS_TWO_POINTS; + public static final Component CREEK_ROUTE_NOT_USABLE; + public static final Component CREEK_POINT_HINT; static { SPACE_SEPARATOR = Component.text("» ", NamedTextColor.GRAY); @@ -50,6 +60,16 @@ public final class SetupMessages { .append(Component.space()) .append(Component.text("teleport", NamedTextColor.GREEN)); + SELECT_CLICK = NO_SPACE_SEPARATOR + .append(Component.space()) + .append(Component.text("Left", NamedTextColor.GREEN)) + .append(Component.space()) + .append(Component.text("click", NamedTextColor.GRAY)) + .append(Component.space()) + .append(Component.text("->", NamedTextColor.GRAY)) + .append(Component.space()) + .append(Component.text("edit", NamedTextColor.GREEN)); + DELETE_CLICK = NO_SPACE_SEPARATOR .append(Component.space()) .append(Component.text("Right", NamedTextColor.RED)) @@ -89,6 +109,34 @@ public final class SetupMessages { DUPLICATE_PAGE = Messages.withPrefix( Component.text("A page with this direction already exists at this position", NamedTextColor.RED) ); + CREEK_MODE_ENABLED = Messages.withPrefix( + Component.text("The creek route mode has been", NamedTextColor.GRAY) + .append(Component.space()) + .append(Component.text("enabled", NamedTextColor.GREEN)) + ); + CREEK_MODE_DISABLED = Messages.withPrefix( + Component.text("The creek route mode has been", NamedTextColor.GRAY) + .append(Component.space()) + .append(Component.text("disabled", NamedTextColor.RED)) + ); + NO_ACTIVE_CREEK_ROUTE = Messages.withPrefix( + Component.text("Create or select a creek route first", NamedTextColor.RED) + ); + NO_CREEK_POINT_TO_REMOVE = Messages.withPrefix( + Component.text("The active creek route has no point to remove", NamedTextColor.RED) + ); + CREEK_PAUSE_NEEDS_POINT = Messages.withPrefix( + Component.text("Add a point to the creek route before setting its pauses", NamedTextColor.RED) + ); + CREEK_END_PAUSE_NEEDS_TWO_POINTS = Messages.withPrefix( + Component.text("The end pause needs a creek route with at least two points", NamedTextColor.RED) + ); + CREEK_ROUTE_NOT_USABLE = Messages.withPrefix( + Component.text("The creek route needs at least two points before the game uses it", NamedTextColor.RED) + ); + CREEK_POINT_HINT = Messages.withPrefix( + Component.text("Break a block to add a point on top of it, break it again to remove the point", NamedTextColor.GRAY) + ); } private SetupMessages() { @@ -121,6 +169,148 @@ private SetupMessages() { return Messages.withMiniPrefix("Don't forget to disable mode", modeTag); } + /** + * Creates a new {@link Component} instance which confirms that a creek route was created. + * + * @param name of the created route + * @return the created component + */ + @Contract(value = "_ -> new", pure = true) + public static @NotNull Component getCreekRouteCreated(@NotNull String name) { + return Messages.withMiniPrefix("Created creek route ", nameTag(name)); + } + + /** + * Creates a new {@link Component} instance which informs the player that the route name is taken. + * + * @param name which is already taken + * @return the created component + */ + @Contract(value = "_ -> new", pure = true) + public static @NotNull Component getDuplicateCreekRoute(@NotNull String name) { + return Messages.withMiniPrefix("A creek route with the name already exists", nameTag(name)); + } + + /** + * Creates a new {@link Component} instance which confirms that a point was added to a creek route. + * + * @param name of the route + * @param count of points the route has now + * @return the created component + */ + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Component getCreekPointAdded(@NotNull String name, int count) { + return Messages.withMiniPrefix("Added point to ", + nameTag(name), countTag(count)); + } + + /** + * Creates a new {@link Component} instance which confirms that the last point of a creek route was removed. + * + * @param name of the route + * @param count of points the route has left + * @return the created component + */ + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Component getCreekPointRemoved(@NotNull String name, int count) { + return Messages.withMiniPrefix("Removed the last point of ( left)", + nameTag(name), countTag(count)); + } + + /** + * Creates a new {@link Component} instance which confirms that a certain point of a creek route was removed. + * + * @param name of the route + * @param number of the removed point, counting from 1 + * @param count of points the route has left + * @return the created component + */ + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Component getCreekPointRemovedAt(@NotNull String name, int number, int count) { + return Messages.withMiniPrefix("Removed point of ( left)", + nameTag(name), countTag(count), + TagResolver.builder().tag("number", Tag.inserting(Component.text(number))).build()); + } + + /** + * Creates a new {@link Component} instance which confirms that a creek route is now being edited. + * + * @param name of the selected route + * @return the created component + */ + @Contract(value = "_ -> new", pure = true) + public static @NotNull Component getCreekRouteSelected(@NotNull String name) { + return Messages.withMiniPrefix("Now editing creek route ", nameTag(name)); + } + + /** + * Creates a new {@link Component} instance which confirms that a creek route was deleted. + * + * @param name of the deleted route + * @return the created component + */ + @Contract(value = "_ -> new", pure = true) + public static @NotNull Component getCreekRouteDeleted(@NotNull String name) { + return Messages.withMiniPrefix("Deleted creek route ", nameTag(name)); + } + + /** + * Creates a new {@link Component} instance which confirms that a creek route is no longer edited. + * + * @param name of the route + * @param count of points the route has + * @return the created component + */ + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Component getCreekRouteFinished(@NotNull String name, int count) { + return Messages.withMiniPrefix("Finished creek route ( points)", + nameTag(name), countTag(count)); + } + + /** + * Creates a new {@link Component} instance which confirms the pauses of a creek route. + * + * @param name of the route + * @param startMillis pause at the start, in milliseconds + * @param endMillis pause at the end, in milliseconds + * @return the created component + */ + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Component getCreekPausesSet(@NotNull String name, int startMillis, int endMillis) { + return Messages.withMiniPrefix( + "Set the pauses of : start s, end s", + nameTag(name), secondsTag("start", startMillis), secondsTag("end", endMillis)); + } + + /** + * Creates a new {@link Component} instance which confirms the start pause of a creek route. + * + * @param name of the route + * @param startMillis pause at the start, in milliseconds + * @return the created component + */ + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Component getCreekStartPauseSet(@NotNull String name, int startMillis) { + return Messages.withMiniPrefix("Set the start pause of to s", + nameTag(name), secondsTag("start", startMillis)); + } + + /** + * Route names come from player input, so they are inserted as plain text instead of being parsed. + */ + private static TagResolver nameTag(String name) { + return TagResolver.builder().tag("name", Tag.inserting(Component.text(name))).build(); + } + + private static TagResolver countTag(int count) { + return TagResolver.builder().tag("count", Tag.inserting(Component.text(count))).build(); + } + + private static TagResolver secondsTag(String key, int millis) { + String seconds = String.format(Locale.ROOT, "%.1f", millis / 1000.0D); + return TagResolver.builder().tag(key, Tag.inserting(Component.text(seconds))).build(); + } + /** * Returns a list that contains the given components which some additional empty components * diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java new file mode 100644 index 00000000..31ac3724 --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java @@ -0,0 +1,46 @@ +package net.onelitefeather.cygnus.setup.creek; + +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CreekRoutePreviewTest { + + @Test + @DisplayName("A line has a particle every step, ends excluded") + void lineSteps() { + List line = CreekRoutePreview.line(new Vec(0, 80, 0), new Vec(2, 80, 0), 0.5D); + + assertEquals(List.of(new Vec(0.5, 80, 0), new Vec(1, 80, 0), new Vec(1.5, 80, 0)), line); + } + + @Test + @DisplayName("Ends of the edited route are marked if another route's end is close") + void linkedEndsOfTheEditedRoute() { + CreekRoute a = CreekRoute.ofPositions("A", List.of(new Vec(0, 80, 0), new Vec(10, 80, 0))); + CreekRoute b = CreekRoute.ofPositions("B", List.of(new Vec(12, 80, 0), new Vec(20, 80, 0))); + CreekRoute c = CreekRoute.ofPositions("C", List.of(new Vec(50, 80, 0), new Vec(60, 80, 0))); + List routes = List.of(a, b, c); + + assertEquals(Set.of(new Vec(10, 80, 0)), CreekRoutePreview.linkedPoints(a, routes, 3.0D)); + assertEquals(Set.of(new Vec(12, 80, 0)), CreekRoutePreview.linkedPoints(b, routes, 3.0D)); + assertEquals(Set.of(), CreekRoutePreview.linkedPoints(c, routes, 3.0D)); + } + + @Test + @DisplayName("Where another route's end meets the middle, both points are marked") + void crossingsInTheMiddle() { + CreekRoute middle = CreekRoute.ofPositions("M", List.of(new Vec(0, 80, 0), new Vec(10, 80, 0), new Vec(20, 80, 0))); + CreekRoute branch = CreekRoute.ofPositions("N", List.of(new Vec(10, 80, 2), new Vec(10, 80, 20))); + List routes = List.of(middle, branch); + + assertEquals(Set.of(new Vec(10, 80, 0)), CreekRoutePreview.linkedPoints(middle, routes, 3.0D)); + assertEquals(Set.of(new Vec(10, 80, 2)), CreekRoutePreview.linkedPoints(branch, routes, 3.0D)); + } +} diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java index 9ffe1307..46c152a9 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java @@ -7,8 +7,13 @@ import net.minestom.server.utils.Direction; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekRoutesFile; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import net.onelitefeather.cygnus.common.map.GameMap; import net.onelitefeather.cygnus.common.map.GameMapBuilder; +import net.onelitefeather.cygnus.setup.item.SetupItemId; import net.onelitefeather.cygnus.setup.map.MapDataCategory; import net.theevilreaper.aves.map.MapEntry; import org.junit.jupiter.api.Test; @@ -20,10 +25,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @ExtendWith(MicrotusExtension.class) @@ -153,4 +160,178 @@ void loadDataReadsPageFacesBackFromTheSiblingFile(Env env, @TempDir Path root) t env.destroyInstance(instance, true); } + + @Test + void testCreekRouteManagement(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + assertFalse(gameData.addCreekPoint(Vec.ZERO), "no active route yet"); + assertTrue(gameData.createCreekRoute("Waldweg")); + assertFalse(gameData.createCreekRoute("Waldweg")); + assertEquals("Waldweg", gameData.activeCreekRoute()); + assertTrue(gameData.addCreekPoint(new Vec(1, 80, 1))); + assertTrue(gameData.addCreekPoint(new Vec(5, 80, 1))); + assertEquals(2, gameData.activeCreekPointCount()); + assertTrue(gameData.removeLastCreekPoint()); + assertEquals(1, gameData.activeCreekPointCount()); + + assertTrue(gameData.createCreekRoute("Lichtung")); + assertTrue(gameData.selectCreekRoute("Waldweg")); + assertFalse(gameData.selectCreekRoute("Unknown")); + assertTrue(gameData.deleteCreekRoute("Waldweg")); + assertNull(gameData.activeCreekRoute(), "deleting the active route clears the selection"); + + env.destroyInstance(instance, true); + } + + @Test + void testSwapCreekRouteMode(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + assertFalse(gameData.hasCreekRouteMode()); + gameData.swapCreekRouteMode(); + assertTrue(gameData.hasCreekRouteMode()); + + env.destroyInstance(instance, true); + } + + @Test + void saveAndLoadKeepAnUnfinishedCreekRoute(Env env, @TempDir Path root) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + MapEntry mapEntry = MapEntry.of(root); + GameData gameData = new GameData(player, mapEntry); + gameData.createCreekRoute("Stub"); + gameData.addCreekPoint(new Vec(1, 80, 1)); + + gameData.save(); + GameData reloaded = new GameData(player, mapEntry); + + assertEquals(List.of(CreekRoute.ofPositions("Stub", List.of(new Vec(1, 80, 1)))), + ((GameMapBuilder) reloaded.getMapBuilder()).getCreekRoutes()); + assertTrue(Files.exists(CreekRoutesFile.resolve(root.resolve("map.json")))); + + env.destroyInstance(instance, true); + } + + @Test + void testCreekRouteItemTogglesTheMode(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES); + assertTrue(gameData.hasCreekRouteMode()); + gameData.handleItemInteraction(player, SetupItemId.CREEK_LEAVE); + assertFalse(gameData.hasCreekRouteMode()); + + env.destroyInstance(instance, true); + } + + @Test + void testCreekRouteModeStaysOffWhileAnotherModeIsActive(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + gameData.swapPageMode(); + + gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES); + + assertFalse(gameData.hasCreekRouteMode()); + env.destroyInstance(instance, true); + } + + @Test + void testCreekRoutePauses(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + assertFalse(gameData.setCreekStartPause(1000), "no active route"); + gameData.createCreekRoute("Waldweg"); + assertFalse(gameData.setCreekStartPause(1000), "no point yet"); + gameData.addCreekPoint(new Vec(1, 80, 1)); + assertTrue(gameData.setCreekStartPause(1000)); + assertFalse(gameData.setCreekEndPause(2000), "the end needs two points"); + gameData.addCreekPoint(new Vec(5, 80, 1)); + assertTrue(gameData.setCreekEndPause(2000)); + + List points = ((GameMapBuilder) gameData.getMapBuilder()).getCreekRoutePoints("Waldweg"); + assertEquals(1000, points.getFirst().pauseMillis()); + assertEquals(2000, points.getLast().pauseMillis()); + + env.destroyInstance(instance, true); + } + + @Test + void testFinishCreekRoute(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + assertFalse(gameData.finishCreekRoute(), "nothing to finish yet"); + gameData.createCreekRoute("Waldweg"); + gameData.addCreekPoint(new Vec(1, 80, 1)); + + assertTrue(gameData.finishCreekRoute()); + assertNull(gameData.activeCreekRoute()); + assertTrue(((GameMapBuilder) gameData.getMapBuilder()).hasCreekRoute("Waldweg"), "finishing keeps the route"); + + env.destroyInstance(instance, true); + } + + @Test + void testFinishItemSwitchesBackToTheOverview(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES); + gameData.createCreekRoute("Waldweg"); + + gameData.handleItemInteraction(player, SetupItemId.CREEK_FINISH); + + assertNull(gameData.activeCreekRoute()); + assertEquals(SetupItemId.CREEK_NEW, player.getInventory().getItemStack(0).getTag(Tags.ITEM_TAG).byteValue()); + + env.destroyInstance(instance, true); + } + + @Test + void testLeavingTheCreekRouteModeFinishesTheRoute(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES); + gameData.createCreekRoute("Waldweg"); + + gameData.handleItemInteraction(player, SetupItemId.CREEK_LEAVE); + + assertFalse(gameData.hasCreekRouteMode()); + assertNull(gameData.activeCreekRoute()); + + env.destroyInstance(instance, true); + } + + @Test + void testRemoveCreekPointAt(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + assertEquals(-1, gameData.removeCreekPointAt(new Vec(5, 80, 1)), "no active route"); + gameData.createCreekRoute("Waldweg"); + gameData.addCreekPoint(new Vec(1, 80, 1)); + gameData.addCreekPoint(new Vec(5, 80, 1)); + gameData.addCreekPoint(new Vec(9, 80, 1)); + + assertEquals(2, gameData.removeCreekPointAt(new Vec(5, 80, 1))); + assertEquals(2, gameData.activeCreekPointCount()); + assertEquals(-1, gameData.removeCreekPointAt(new Vec(5, 80, 1)), "already gone"); + + env.destroyInstance(instance, true); + } } diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java index 3272ae56..988c9d34 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java @@ -14,7 +14,6 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; -import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -34,7 +33,7 @@ class InstanceSetupDataIntegrationTest { @Test void testInstanceUsesFalcoChunkLoader(Env env, @TempDir Path worldRoot) { - TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot)); data.loadData(); assertNotNull(data.instance); @@ -47,7 +46,7 @@ void testInstanceUsesFalcoChunkLoader(Env env, @TempDir Path worldRoot) { @Test void testResetClosesChunkLoader(Env env, @TempDir Path worldRoot) { - TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot)); data.loadData(); assertNotNull(data.instance); @@ -57,8 +56,8 @@ void testResetClosesChunkLoader(Env env, @TempDir Path worldRoot) { } @Test - void testResetWithoutInstanceIsHarmless(@TempDir Path worldRoot) { - TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + void testResetWithoutInstanceIsHarmless(Env env, @TempDir Path worldRoot) { + TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot)); data.reset(); @@ -66,14 +65,21 @@ void testResetWithoutInstanceIsHarmless(@TempDir Path worldRoot) { assertNull(data.chunkLoader); } + /** + * Creates the player who owns the setup. Nothing in these tests looks at them. + */ + private static Player owner(Env env) { + return env.createPlayer(env.createFlatInstance()); + } + /** * Minimal {@link InstanceSetupData} which only creates the instance, so the test observes the * chunk loader handling of the base class and nothing else. */ private static final class TestSetupData extends InstanceSetupData { - private TestSetupData(MapEntry mapEntry) { - super(UUID.randomUUID(), mapEntry, BossBar.Color.WHITE); + private TestSetupData(Player owner, MapEntry mapEntry) { + super(owner, mapEntry, BossBar.Color.WHITE); } @Override diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlotTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlotTest.java new file mode 100644 index 00000000..32ca30fb --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlotTest.java @@ -0,0 +1,36 @@ +package net.onelitefeather.cygnus.setup.inventory.slot; + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.component.DataComponents; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.item.ItemStack; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekRouteSlotTest { + + @Test + @DisplayName("The item shows the route's name, point count and pauses") + void itemShowsNameAndPoints() { + CreekRoute route = new CreekRoute("Waldweg", List.of( + CreekWaypoint.of(new Vec(0, 80, 0)).withPause(2000), + CreekWaypoint.of(new Vec(5, 80, 0)), + CreekWaypoint.of(new Vec(9, 80, 0)), + CreekWaypoint.of(new Vec(12, 80, 0)).withPause(4000))); + ItemStack item = new CreekRouteSlot(route, true, (_, _) -> {}, (_, _) -> {}).getItem(); + + assertEquals("Waldweg", PlainTextComponentSerializer.plainText().serialize(item.get(DataComponents.CUSTOM_NAME))); + List lore = item.get(DataComponents.LORE).stream() + .map(line -> PlainTextComponentSerializer.plainText().serialize(line)) + .toList(); + assertTrue(lore.contains("Points: 4")); + assertTrue(lore.contains("Pause: start 2.0 s · end 4.0 s")); + } +} diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java index 28b4dd0f..8bd64603 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java @@ -67,4 +67,31 @@ private void assertItem(@NotNull Player player, int slot, byte itemId) { assertNotEquals(Material.AIR, itemStack.material()); assertEquals(itemId, itemStack.getTag(Tags.ITEM_TAG).byteValue()); } + + @Test + void testCreekRouteOverviewItemSet(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + + SetupItems.setCreekRouteOverviewItems(player); + assertItem(player, 0, SetupItemId.CREEK_NEW); + assertItem(player, 4, SetupItemId.CREEK_LIST); + assertItem(player, 8, SetupItemId.CREEK_LEAVE); + + env.destroyInstance(instance, true); + } + + @Test + void testCreekRouteEditItemSet(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + + SetupItems.setCreekRouteEditItems(player); + assertItem(player, 0, SetupItemId.CREEK_UNDO); + assertItem(player, 2, SetupItemId.CREEK_PAUSE); + assertItem(player, 6, SetupItemId.CREEK_FINISH); + assertItem(player, 8, SetupItemId.CREEK_LEAVE); + + env.destroyInstance(instance, true); + } } diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java new file mode 100644 index 00000000..9c697344 --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java @@ -0,0 +1,31 @@ +package net.onelitefeather.cygnus.setup.listener; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CreekRouteListenerTest { + + @Test + @DisplayName("A point sits in the middle on top of the clicked block") + void pointOnTopOfTheBlock() { + assertEquals(new Vec(3.5, 81, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.STONE)); + } + + @Test + @DisplayName("A point sits on the real height of a lower block") + void pointOnTopOfALowerBlock() { + assertEquals(new Vec(3.5, 80.5, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.STONE_SLAB)); + assertEquals(new Vec(3.5, 80.9375, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.DIRT_PATH)); + assertEquals(new Vec(3.5, 80.0625, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.WHITE_CARPET)); + } + + @Test + @DisplayName("A point on a plant sits where the plant grows") + void pointOnAPlant() { + assertEquals(new Vec(3.5, 80, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.FERN)); + } +}