diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 855bb0c7..111abcfd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: pull_request: push: - branches: [master] + branches: [master, live-server] paths: - .github/workflows/build.yml - deepworld-config diff --git a/api/src/main/java/brainwine/api/Api.java b/api/src/main/java/brainwine/api/Api.java index 0105dcef..1ed619ac 100644 --- a/api/src/main/java/brainwine/api/Api.java +++ b/api/src/main/java/brainwine/api/Api.java @@ -7,6 +7,7 @@ import java.util.Collections; import java.util.List; +import brainwine.api.config.BetaEntry; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -21,10 +22,11 @@ public class Api { private static final Logger logger = LogManager.getLogger(); private final ApiConfig config; private final List news; + private final BetaEntry beta; private final DataFetcher dataFetcher; private final GatewayService gatewayService; private final PortalService portalService; - + public Api() { this(new DefaultDataFetcher()); } @@ -38,6 +40,7 @@ public Api(DataFetcher dataFetcher) { config = loadConfig(); logger.info(SERVER_MARKER, "Is SSL enabled? {}", config.getSslConfig().isSslEnabled() ? "Yes" : "No"); news = new ArrayList<>(config.getNews()); // Explicit copy + beta = config.getBeta(); Collections.reverse(news); LoomUtil.useLoomThreadPool = false; gatewayService = new GatewayService(this, config.getGatewayPort()); @@ -71,10 +74,18 @@ private ApiConfig loadConfig() { return ApiConfig.DEFAULT_CONFIG; } + + public void broadcast(String type, Object data) { + portalService.broadcast(type, data); + } public List getNews() { return news; } + + public BetaEntry getBeta() { + return beta; + } public String getGameServerHost() { return config.getGameServerIp() + ":" + config.getGameServerPort(); diff --git a/api/src/main/java/brainwine/api/DataFetcher.java b/api/src/main/java/brainwine/api/DataFetcher.java index a5f2737a..bb533d9b 100644 --- a/api/src/main/java/brainwine/api/DataFetcher.java +++ b/api/src/main/java/brainwine/api/DataFetcher.java @@ -1,7 +1,11 @@ package brainwine.api; import java.util.Collection; +import java.util.List; +import java.util.Map; +import brainwine.api.models.PlayerInfo; +import brainwine.api.models.PlayerInfoSummary; import brainwine.api.models.ZoneInfo; public interface DataFetcher { @@ -12,7 +16,10 @@ public interface DataFetcher { public String fetchPlayerName(String name); public String fetchPlayerId(String apiToken); public boolean verifyAuthToken(String name, String token); + public PlayerInfo getPlayerInfo(String nameOrId); + public Collection fetchPlayerInfo(); public ZoneInfo getZoneInfo(String nameOrId); + public List> getZoneMetaBlocks(String documentId); public Collection fetchZoneInfo(); public Collection fetchRecentZoneInfo(String apiToken); public Collection fetchBookmarkedZoneInfo(String apiToken); diff --git a/api/src/main/java/brainwine/api/DefaultDataFetcher.java b/api/src/main/java/brainwine/api/DefaultDataFetcher.java index 27d224cf..4fc330ac 100644 --- a/api/src/main/java/brainwine/api/DefaultDataFetcher.java +++ b/api/src/main/java/brainwine/api/DefaultDataFetcher.java @@ -1,7 +1,11 @@ package brainwine.api; import java.util.Collection; +import java.util.List; +import java.util.Map; +import brainwine.api.models.PlayerInfo; +import brainwine.api.models.PlayerInfoSummary; import brainwine.api.models.ZoneInfo; public class DefaultDataFetcher implements DataFetcher { @@ -37,12 +41,27 @@ public String fetchPlayerId(String apiToken) { public boolean verifyAuthToken(String name, String token) { throw exception; } - + + @Override + public PlayerInfo getPlayerInfo(String nameOrId) { + throw exception; + } + + @Override + public Collection fetchPlayerInfo() { + throw exception; + } + @Override public ZoneInfo getZoneInfo(String nameOrId) { throw exception; } - + + @Override + public List> getZoneMetaBlocks(String documentId) { + throw exception; + } + @Override public Collection fetchZoneInfo() { throw exception; diff --git a/api/src/main/java/brainwine/api/GatewayService.java b/api/src/main/java/brainwine/api/GatewayService.java index 970610db..35f16b37 100644 --- a/api/src/main/java/brainwine/api/GatewayService.java +++ b/api/src/main/java/brainwine/api/GatewayService.java @@ -1,12 +1,17 @@ package brainwine.api; import static brainwine.api.util.ContextUtils.error; +import static brainwine.api.util.ContextUtils.handleQueryParam; import static brainwine.shared.LogMarkers.SERVER_MARKER; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.regex.Pattern; +import brainwine.api.models.PlayerInfo; +import brainwine.api.models.PlayerInfoSummary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -21,7 +26,8 @@ import io.javalin.plugin.json.JavalinJackson; public class GatewayService { - + + private static final int playerSearchPageSize = 50; private static final Pattern namePattern = Pattern.compile("^[a-zA-Z0-9_.-]{4,20}$"); private static final Logger logger = LogManager.getLogger(); private final Api api; @@ -42,6 +48,8 @@ public GatewayService(Api api, int port) { }) .exception(Exception.class, this::handleException) .get("/clients", this::handleNewsRequest) + .get("/players", this::handlePlayerSearch) + .get("/players/{player}", this::handleGetPlayer) .post("/players", this::handlePlayerRegistration) .post("/sessions", this::handlePlayerLogin) .post("/passwords/request", this::handlePasswordForget) @@ -64,8 +72,65 @@ private void handleException(Exception exception, Context ctx) { private void handleNewsRequest(Context ctx) { Map news = new HashMap<>(); news.put("posts", api.getNews()); + news.put("beta", api.getBeta()); ctx.json(news); } + + private void handleGetPlayer(Context ctx) { + String nameOrId = ctx.pathParam("player"); + PlayerInfo info = dataFetcher.getPlayerInfo(nameOrId); + if(info == null) { + error(ctx, "Player not found."); + return; + } + + handleQueryParam(ctx, "api_token", String.class, token -> { + if(Objects.equals(info.getApiToken(), token)) { + info.setTokenValidated(); + } + }); + + ctx.json(info); + } + + private void handlePlayerSearch(Context ctx) { + List players = (List)dataFetcher.fetchPlayerInfo(); + + handleQueryParam(ctx, "name", String.class, name -> { + players.removeIf(player -> !player.getName().toLowerCase().contains(name.toLowerCase())); + }); + + handleQueryParam(ctx, "min_level", Integer.class, minLevel -> { + players.removeIf(player -> player.getLevel() < minLevel); + }); + + handleQueryParam(ctx, "max_level", Integer.class, maxLevel -> { + players.removeIf(player -> player.getLevel() > maxLevel); + }); + + handleQueryParam(ctx, "sort", String.class, sort -> { + switch(sort) { + case "items_mined": // Sort by total items mined + players.sort((a, b) -> Integer.compare(b.getItemsMined(), a.getItemsMined())); + break; + case "items_scavenged": // Sort by total items scavenged + players.sort((a, b) -> Integer.compare(b.getItemsScavenged(), a.getItemsMined())); + break; + case "items_placed": // Sort by total items placed + players.sort((a, b) -> Integer.compare(b.getItemsPlaced(), a.getItemsPlaced())); + break; + case "items_crafted": // Sort by total items crafted + players.sort((a, b) -> Integer.compare(b.getItemsCrafted(), a.getItemsCrafted())); + break; + } + }); + + // Page + int page = ctx.queryParamAsClass("page", Integer.class).getOrDefault(1); + int fromIndex = (page - 1) * playerSearchPageSize; + int toIndex = page * playerSearchPageSize; + ctx.json(players.subList(fromIndex < 0 ? 0 : fromIndex > players.size() ? players.size() : fromIndex, toIndex > players.size() ? players.size() : toIndex)); + } /** * Handler function for registering a new account. diff --git a/api/src/main/java/brainwine/api/PortalService.java b/api/src/main/java/brainwine/api/PortalService.java index 9b6d1fb6..f35599b7 100644 --- a/api/src/main/java/brainwine/api/PortalService.java +++ b/api/src/main/java/brainwine/api/PortalService.java @@ -10,9 +10,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import javax.imageio.ImageIO; +import com.fasterxml.jackson.core.type.TypeReference; +import io.javalin.core.validation.Validator; +import io.javalin.websocket.WsConfig; +import io.javalin.websocket.WsContext; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -36,7 +42,8 @@ public class PortalService { private final Map surfaceMapCache = new HashMap<>(); private final DataFetcher dataFetcher; private final Javalin portal; - + private final Set wsConnections = ConcurrentHashMap.newKeySet(); + public PortalService(Api api, int port) { this.dataFetcher = api.getDataFetcher(); logger.info(SERVER_MARKER, "Starting PortalService @ port {} ...", port); @@ -51,6 +58,7 @@ public PortalService(Api api, int port) { .exception(Exception.class, this::handleException) .get("/v1/map/{zone}", this::handleMapRequest) .get("/v1/worlds", this::handleZoneSearch) + .ws("/v1/listen", this::handleWsConfig) .start(port); } @@ -140,7 +148,11 @@ private void handleZoneSearch(Context ctx) { handleQueryParam(ctx, "pvp", boolean.class, pvp -> { zones.removeIf(zone -> zone.isPvp() != pvp); }); - + + handleQueryParam(ctx, "market", boolean.class, pvp -> { + zones.removeIf(zone -> zone.isMarket() != pvp); + }); + handleQueryParam(ctx, "protected", boolean.class, value -> { zones.removeIf(zone -> zone.isProtected() != value); }); @@ -170,14 +182,59 @@ private void handleZoneSearch(Context ctx) { break; } }); - + + // TODO this modifies the objects returned from the direct data fetcher directly + Validator param = ctx.queryParamAsClass("metablocks", Boolean.class); + Boolean value = param.getOrDefault(null); + if(value != null && value.equals(true)) { + zones.forEach(z -> z.setMetablocks(dataFetcher.getZoneMetaBlocks(z.getDocumentId()))); + } + // Page int page = ctx.queryParamAsClass("page", Integer.class).getOrDefault(1); int fromIndex = (page - 1) * zoneSearchPageSize; int toIndex = page * zoneSearchPageSize; ctx.json(zones.subList(fromIndex < 0 ? 0 : fromIndex > zones.size() ? zones.size() : fromIndex, toIndex > zones.size() ? zones.size() : toIndex)); } - + + private void handleWsConfig(WsConfig config) { + config.onConnect(wsConnections::add); + config.onClose(wsConnections::remove); + + config.onMessage(context -> { + Map message = JsonHelper.MAPPER.readValue(context.message(), new TypeReference>() {}); + Object messageTypeObj = message.get("type"); + if(!(messageTypeObj instanceof String)) { + wsError(context, "Bad message type."); + } + switch((String)messageTypeObj) { + default: + wsError(context, "Unknown message type: " + (String)messageTypeObj); + } + }); + } + + private void wsError(WsContext context, String message) { + Map map = new HashMap<>(); + map.put("type", "error"); + map.put("data", message); + + if(context.session.isOpen()) { + context.send(map); + } + } + + public void broadcast(String type, Object data) { + Map msg = new HashMap<>(); + msg.put("type", type); + msg.put("data", data); + for(WsContext context : wsConnections) { + if(context.session.isOpen()) { + context.send(msg); + } + } + } + /** * Stops the portal service. * @see Javalin#stop() diff --git a/api/src/main/java/brainwine/api/config/ApiConfig.java b/api/src/main/java/brainwine/api/config/ApiConfig.java index 5f0412bd..83915f63 100644 --- a/api/src/main/java/brainwine/api/config/ApiConfig.java +++ b/api/src/main/java/brainwine/api/config/ApiConfig.java @@ -2,6 +2,7 @@ import java.beans.ConstructorProperties; import java.util.Arrays; +import java.util.Collections; import java.util.List; import com.fasterxml.jackson.annotation.JsonGetter; @@ -10,16 +11,17 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class ApiConfig { - public static final ApiConfig DEFAULT_CONFIG = new ApiConfig("127.0.0.1", 5002, 5001, 5003, SslConfig.DEFAULT_CONFIG, Arrays.asList(NewsEntry.DEFAULT_NEWS)); + public static final ApiConfig DEFAULT_CONFIG = new ApiConfig("127.0.0.1", 5002, 5001, 5003, SslConfig.DEFAULT_CONFIG, Arrays.asList(NewsEntry.DEFAULT_NEWS), new BetaEntry()); private final String gameServerIp; private final int gameServerPort; private final int gatewayPort; private final int portalPort; private final SslConfig sslConfig; private final List news; - - @ConstructorProperties({"game_server_ip", "game_server_port", "gateway_port", "portal_port", "ssl", "news"}) - public ApiConfig(String gameServerIp, int gameServerPort, int gatewayPort, int portalPort, SslConfig sslConfig, List news) { + private final BetaEntry beta; + + @ConstructorProperties({"game_server_ip", "game_server_port", "gateway_port", "portal_port", "ssl", "news", "beta"}) + public ApiConfig(String gameServerIp, int gameServerPort, int gatewayPort, int portalPort, SslConfig sslConfig, List news, BetaEntry beta) { this.gameServerIp = gameServerIp; this.gameServerPort = gameServerPort; this.gatewayPort = gatewayPort; @@ -27,6 +29,8 @@ public ApiConfig(String gameServerIp, int gameServerPort, int gatewayPort, int p this.sslConfig = sslConfig == null ? SslConfig.DEFAULT_CONFIG : sslConfig; System.out.println("ssl config: " + sslConfig); this.news = news; + this.beta = beta; + Collections.reverse(this.news); } public String getGameServerIp() { @@ -49,8 +53,12 @@ public int getPortalPort() { public SslConfig getSslConfig() { return sslConfig; } - + public List getNews() { return news; } + + public BetaEntry getBeta() { + return beta; + } } diff --git a/api/src/main/java/brainwine/api/config/BetaButton.java b/api/src/main/java/brainwine/api/config/BetaButton.java new file mode 100644 index 00000000..3bf59495 --- /dev/null +++ b/api/src/main/java/brainwine/api/config/BetaButton.java @@ -0,0 +1,28 @@ +package brainwine.api.config; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.beans.ConstructorProperties; + +public class BetaButton { + private String title = "Visit Forums"; + private String url = "https://forums.deepworldgame.com/"; + + public BetaButton() {} + + @ConstructorProperties({"title", "url"}) + public BetaButton(String title, String url) { + this.title = title; + this.url = url; + } + + @JsonProperty("title") + public String getTitle() { + return title; + } + + @JsonProperty("url") + public String getUrl() { + return url; + } +} diff --git a/api/src/main/java/brainwine/api/config/BetaEntry.java b/api/src/main/java/brainwine/api/config/BetaEntry.java new file mode 100644 index 00000000..1060fd86 --- /dev/null +++ b/api/src/main/java/brainwine/api/config/BetaEntry.java @@ -0,0 +1,35 @@ +package brainwine.api.config; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.beans.ConstructorProperties; + +public class BetaEntry { + private String title = "Learn More"; + private String content = "Thank you for joining us on this BrainWine server!"; + private BetaButton button = new BetaButton(); + + public BetaEntry() {} + + @ConstructorProperties({"title", "content", "button"}) + public BetaEntry(String title, String content, BetaButton button) { + this.title = title; + this.content = content; + this.button = button; + } + + @JsonProperty("title") + public String getTitle() { + return title; + } + + @JsonProperty("content") + public String getContent() { + return content; + } + + @JsonProperty("button") + public BetaButton getButton() { + return button; + } +} diff --git a/api/src/main/java/brainwine/api/models/PlayerInfo.java b/api/src/main/java/brainwine/api/models/PlayerInfo.java new file mode 100644 index 00000000..f71dadb6 --- /dev/null +++ b/api/src/main/java/brainwine/api/models/PlayerInfo.java @@ -0,0 +1,51 @@ +package brainwine.api.models; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.util.Map; + +public class PlayerInfo extends PlayerInfoSummary { + private String apiToken = ""; + private boolean tokenValidated = false; + private Map appearance; + private Map statistics; + public PlayerInfo( + String name, + int level, + int skillLevel, + int deaths, + int itemsMined, + int itemsScavenged, + int itemsPlaced, + int itemsCrafted, + String apiToken, + Map appearance, + Map statistics + ) { + super(name, level, skillLevel, deaths, itemsMined, itemsScavenged, itemsPlaced, itemsCrafted); + this.apiToken = apiToken; + this.appearance = appearance; + this.statistics = statistics; + } + + public void setTokenValidated() { + tokenValidated = true; + } + + public boolean isTokenValidated() { + return tokenValidated; + } + + @JsonIgnore + public String getApiToken() { + return apiToken; + } + + public Map getAppearance() { + return appearance; + } + + public Map getStatistics() { + return statistics; + } +} diff --git a/api/src/main/java/brainwine/api/models/PlayerInfoSummary.java b/api/src/main/java/brainwine/api/models/PlayerInfoSummary.java new file mode 100644 index 00000000..a35384ba --- /dev/null +++ b/api/src/main/java/brainwine/api/models/PlayerInfoSummary.java @@ -0,0 +1,55 @@ +package brainwine.api.models; + +public class PlayerInfoSummary { + private String name = "Unknown Player"; + private int level; + private int skillLevel; + private int deaths; + private int itemsMined; + private int itemsScavenged; + private int itemsPlaced; + private int itemsCrafted; + + public PlayerInfoSummary(String name, int level, int skillLevel, int deaths, int itemsMined, int itemsScavenged, int itemsPlaced, int itemsCrafted) { + this.name = name; + this.level = level; + this.skillLevel = skillLevel; + this.deaths = deaths; + this.itemsMined = itemsMined; + this.itemsScavenged = itemsScavenged; + this.itemsPlaced = itemsPlaced; + this.itemsCrafted = itemsCrafted; + } + + public String getName() { + return name; + } + + public int getLevel() { + return level; + } + + public int getSkillLevel() { + return skillLevel; + } + + public int getDeaths() { + return deaths; + } + + public int getItemsMined() { + return itemsMined; + } + + public int getItemsScavenged() { + return itemsScavenged; + } + + public int getItemsPlaced() { + return itemsPlaced; + } + + public int getItemsCrafted() { + return itemsCrafted; + } +} diff --git a/api/src/main/java/brainwine/api/models/ZoneInfo.java b/api/src/main/java/brainwine/api/models/ZoneInfo.java index 41982c71..cdee25f8 100644 --- a/api/src/main/java/brainwine/api/models/ZoneInfo.java +++ b/api/src/main/java/brainwine/api/models/ZoneInfo.java @@ -3,19 +3,24 @@ import java.time.OffsetDateTime; import java.util.Collections; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; /** * TODO split model in two: one for internal use & one for {@code /v1/worlds} serialization. */ public class ZoneInfo { - + + private final String documentId; private final String name; private final String biome; private final String activity; private final boolean pvp; + private final boolean market; + private final boolean tutorial; private final boolean premium; private final boolean isPrivate; private final boolean isProtected; @@ -27,13 +32,17 @@ public class ZoneInfo { private final OffsetDateTime creationDate; private final String owner; private final List members; - - public ZoneInfo(String name, String biome, String activity, boolean pvp, boolean premium, boolean isPrivate, boolean isProtected, - int playerCount, int width, int height, int[] surface, double explorationProgress, OffsetDateTime creationDate, String owner, List members) { + private List> metablocks; + + public ZoneInfo(String documentId, String name, String biome, String activity, boolean pvp, boolean market, boolean tutorial, boolean premium, boolean isPrivate, boolean isProtected, + int playerCount, int width, int height, int[] surface, double explorationProgress, OffsetDateTime creationDate, String owner, List members, List> metablocks) { + this.documentId = documentId; this.name = name; this.biome = biome; this.activity = activity; this.pvp = pvp; + this.market = market; + this.tutorial = tutorial; this.premium = premium; this.isPrivate = isPrivate; this.isProtected = isProtected; @@ -45,8 +54,14 @@ public ZoneInfo(String name, String biome, String activity, boolean pvp, boolean this.creationDate = creationDate; this.owner = owner; this.members = members; + this.metablocks = metablocks; } - + + @JsonIgnore + public String getDocumentId() { + return documentId; + } + public String getName() { return name; } @@ -62,7 +77,15 @@ public String getActivity() { public boolean isPvp() { return pvp; } - + + public boolean isMarket() { + return market; + } + + public boolean isTutorial() { + return tutorial; + } + public boolean isPremium() { return premium; } @@ -70,7 +93,7 @@ public boolean isPremium() { public boolean isPrivate() { return isPrivate; } - + public boolean isProtected() { return !isPrivate && isProtected; // Only display protection lock if world is public } @@ -104,14 +127,23 @@ public double getExplorationProgress() { public OffsetDateTime getCreationDate() { return creationDate; } - + @JsonIgnore public String getOwner() { return owner; } - + @JsonIgnore public List getMembers() { return Collections.unmodifiableList(members); } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public List> getMetablocks() { + return metablocks; + } + + public void setMetablocks(List> metablocks) { + this.metablocks = metablocks; + } } diff --git a/gameserver/build.gradle b/gameserver/build.gradle index b707887c..e5591c10 100644 --- a/gameserver/build.gradle +++ b/gameserver/build.gradle @@ -10,7 +10,6 @@ sourceSets { main { resources { srcDir '../deepworld-config' - exclude 'quests' exclude 'biomes.yml' exclude 'daily_challenges.yml' exclude 'README.md' @@ -21,6 +20,7 @@ sourceSets { dependencies { implementation 'org.msgpack:jackson-dataformat-msgpack:0.9.3' implementation 'org.yaml:snakeyaml:1.30' + implementation 'commons-io:commons-io:2.19.0' implementation 'org.reflections:reflections:0.10.2' implementation 'io.netty:netty-all:4.1.79.Final' implementation 'org.mindrot:jbcrypt:0.4' diff --git a/gameserver/src/main/java/brainwine/gameserver/Fake.java b/gameserver/src/main/java/brainwine/gameserver/Fake.java new file mode 100644 index 00000000..ada5cc21 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/Fake.java @@ -0,0 +1,136 @@ +package brainwine.gameserver; + +import java.io.IOException; +import java.net.URL; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.fasterxml.jackson.core.type.TypeReference; + +import static brainwine.shared.LogMarkers.SERVER_MARKER; + +import brainwine.gameserver.resource.ResourceFinder; +import brainwine.gameserver.util.MapHelper; +import brainwine.shared.JsonHelper; + +public class Fake { + private static final Logger logger = LogManager.getLogger(); + public static Map fake = null; + + public static enum Type { + SALUTATION, + JOKE, + } + + public static enum Degree { + UNFRIENDLY(-1), + NEUTRAL(0), + FRIENDLY(1); + + public final int value; + + private Degree(int value) { + this.value = value; + } + } + + /** Load fake.json and if successful cache its contents into the fake variable. + */ + public static void loadFake() { + logger.info(SERVER_MARKER, "Loading fakes ..."); + + try { + URL url = ResourceFinder.getResourceUrl("fake.json"); + fake = JsonHelper.readValue(url, new TypeReference>(){}); + } catch (IOException e) { + logger.error(SERVER_MARKER, "Failed to load fakes", e); + } + } + + /** Get cached fake.json contents or attempt to load it and return. + * + * @return cached or newly loaded fake.json contents or null if unsuccessful + */ + private static Map getFake() { + if(fake == null) { + loadFake(); + } + + return fake; + } + + /** Get fake from a list whose type is not defined in the Fake.Type enum. + * + * @param the type of object to return + * @param listKey the path to the list in configuration (automatically prefixed with fake.) + * @return random selection + */ + public static T get(String listKey) { + List list = MapHelper.getList(getFake(), "fake." + listKey); + + if(list != null) { + return pickFromList(list); + }else { + throw new NoSuchElementException(); + } + } + + /** Get a fake of known type. Also see the overload get(type, degree). + * + * @param type the type of fake + * @return random selection + */ + public static String get(Type type) { + return get(type, Degree.NEUTRAL); + } + + /** Get a fake of known type and of given mood, if applicable. Only use with SALUTATION. + * + * @param type the type of fake + * @param degree the degree of the fake + * @return random selection + */ + public static String get(Type type, Degree degree) { + if(type == Type.SALUTATION) { + if(degree == Degree.UNFRIENDLY) { + return pickFromList("fake.salutations.unfriendly"); + } + if(degree == Degree.FRIENDLY) { + return pickFromList("fake.salutations.friendly"); + } + + return pickFromList("fake.salutations.neutral"); + } + + if(type == Type.JOKE) { + return pickFromList("fake.jokes"); + } + + return ""; + } + + private static String pickFromList(String path) { + Map myFake = getFake(); + + if (myFake == null) { + return "missingno"; + } else { + return pickFromList(MapHelper.getList(getFake(), path)); + } + } + + /** Pick one item randomly from the given list. It uses Math.random() internally. + * @param list the list + * @return random selection + */ + public static T pickFromList(List list) { + int length = list.size(); + int index = (int) Math.round(Math.floor(length * Math.random())); + return list.get(index); + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/GameConfiguration.java b/gameserver/src/main/java/brainwine/gameserver/GameConfiguration.java index a06e55e9..1de8ce8a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/GameConfiguration.java +++ b/gameserver/src/main/java/brainwine/gameserver/GameConfiguration.java @@ -69,10 +69,31 @@ private static void cacheVersionedConfigs() { merge(config, update); } }); + + if(VersionUtils.isGreaterOrEqualTo(version, "3.0.0")) { + processV3Config(config); + } versionedConfigs.put(version, config); }); } + + private static void processV3Config(Map clientConfig) { + // Set item title colors + if(clientConfig.get("items") instanceof Map) { + Map itemConfigs = (Map) clientConfig.get("items"); + for(Object val : itemConfigs.values()) { + if(val instanceof Map) { + Map config = (Map)val; + Object titleColor = config.getOrDefault("title color", config.get("title_color")); + Object title = config.get("title"); + if(title instanceof String && titleColor instanceof String) { + config.put("title", String.format("%s", titleColor, title)); + } + } + } + } + } private static void configure() { // Client wants this @@ -82,7 +103,7 @@ private static void configure() { // Clear shop data MapHelper.put(baseConfig, "shop.sections", new ArrayList<>()); MapHelper.put(baseConfig, "shop.items", new ArrayList<>()); - + // Add custom commands to the client config CommandManager.getCommandNames().forEach(command -> { MapHelper.put(baseConfig, String.format("commands.%s", command), true); @@ -165,6 +186,9 @@ private static void configure() { case "liquid": config.put("layer", category); break; + case "shields": + case "accessories": + break; default: // Big brain or big stupid? config.put("layer", "front"); break; @@ -184,7 +208,8 @@ private static void configure() { items.remove(item); } } - + ItemRegistry.registerItemRelationships(); + logger.info(SERVER_MARKER, "Successfully loaded {} item(s)", ItemRegistry.getItems().size()); } @@ -193,7 +218,7 @@ private static void loadConfigFiles() { Reflections reflections = new Reflections(new ConfigurationBuilder() .setUrls(ClasspathHelper.forPackage("brainwine.gameserver")) .setScanners(Scanners.Resources)); - Set fileNames = reflections.getResources("^config.*\\.yml$"); + Set fileNames = reflections.getResources("^(config|quests).*\\.yml$"); for(String fileName : fileNames) { Map config = yaml.load(GameConfiguration.class.getResourceAsStream(String.format("/%s", fileName))); diff --git a/gameserver/src/main/java/brainwine/gameserver/GameServer.java b/gameserver/src/main/java/brainwine/gameserver/GameServer.java index f31c8d94..e1314be0 100644 --- a/gameserver/src/main/java/brainwine/gameserver/GameServer.java +++ b/gameserver/src/main/java/brainwine/gameserver/GameServer.java @@ -5,9 +5,19 @@ import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; +import brainwine.gameserver.androidshop.AndroidShop; +import brainwine.gameserver.androidshop.AndroidShopPerIpHistory; +import brainwine.gameserver.scrapmarket.ScrapMarket; +import brainwine.gameserver.anticheat.AnticheatManager; +import brainwine.gameserver.chat.ProfanityManager; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import brainwine.gameserver.order.OrderManager; +import brainwine.gameserver.server.DefaultPusher; +import brainwine.gameserver.server.IpBans; +import brainwine.gameserver.server.Pusher; +import brainwine.gameserver.zone.ZoneActivityManager; import brainwine.gameserver.achievement.AchievementManager; import brainwine.gameserver.command.CommandExecutor; import brainwine.gameserver.command.CommandManager; @@ -17,6 +27,7 @@ import brainwine.gameserver.player.NotificationType; import brainwine.gameserver.player.PlayerManager; import brainwine.gameserver.prefab.PrefabManager; +import brainwine.gameserver.quest.Quests; import brainwine.gameserver.server.NetworkRegistry; import brainwine.gameserver.server.Server; import brainwine.gameserver.zone.EntityManager; @@ -34,8 +45,12 @@ public class GameServer implements CommandExecutor { private final LootManager lootManager; private final PrefabManager prefabManager; private final ZoneManager zoneManager; + private final ZoneActivityManager zoneActivityManager; private final PlayerManager playerManager; + private final IpBans ipBans; + private final ProfanityManager profanityManager; private final Server server; + private Pusher pusher; private long lastTick = System.currentTimeMillis(); private long lastSave = lastTick; private volatile boolean shouldStop; @@ -45,19 +60,31 @@ public GameServer() { handlerThread = Thread.currentThread(); long startTime = System.currentTimeMillis(); logger.info(SERVER_MARKER, "Starting GameServer ..."); + ipBans = new IpBans(); + profanityManager = new ProfanityManager(); CommandManager.init(); GameConfiguration.init(); AchievementManager.loadAchievements(); + OrderManager.loadOrders(); EntityRegistry.init(); EntityManager.loadEntitySpawns(); GrowthManager.loadGrowthData(); Pandora.loadConfig(); + Quests.loadQuests(); + AndroidShop.getInstance().loadShopData(); + AndroidShopPerIpHistory.getInstance().load(); + Fake.loadFake(); + AnticheatManager.loadConfig(); + ipBans.loadIpBans(); lootManager = new LootManager(); prefabManager = new PrefabManager(); ZoneGenerator.init(); zoneManager = new ZoneManager(); zoneManager.tryGenerateDefaultZone(); + zoneActivityManager = new ZoneActivityManager(); playerManager = new PlayerManager(); + ScrapMarket.getInstance().loadScrapMarketData(); + pusher = new DefaultPusher(); NetworkRegistry.init(); server = new Server(); server.addEndpoint(5002); @@ -87,6 +114,9 @@ public void tick() { if(lastSave + GLOBAL_SAVE_INTERVAL < System.currentTimeMillis()) { zoneManager.saveZones(); playerManager.savePlayers(); + ipBans.saveIpBans(); + AndroidShopPerIpHistory.getInstance().save(); + ScrapMarket.getInstance().saveJson(); lastSave = System.currentTimeMillis(); } @@ -129,6 +159,9 @@ public void onShutdown() { zoneManager.onShutdown(); logger.info(SERVER_MARKER, "Saving player data ..."); playerManager.savePlayers(); + ipBans.saveIpBans(); + AndroidShopPerIpHistory.getInstance().save(); + ScrapMarket.getInstance().saveJson(); } public void stopGracefully() { @@ -150,8 +183,28 @@ public PrefabManager getPrefabManager() { public ZoneManager getZoneManager() { return zoneManager; } - + + public ZoneActivityManager getZoneActivityManager() { + return zoneActivityManager; + } + public PlayerManager getPlayerManager() { return playerManager; } + + public Pusher getPusher() { + return pusher; + } + + public void setPusher(Pusher pusher) { + this.pusher = pusher; + } + + public IpBans getIpBans() { + return ipBans; + } + + public ProfanityManager getProfanityManager() { + return profanityManager; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/achievement/Achievement.java b/gameserver/src/main/java/brainwine/gameserver/achievement/Achievement.java index 36abee7d..99092b72 100644 --- a/gameserver/src/main/java/brainwine/gameserver/achievement/Achievement.java +++ b/gameserver/src/main/java/brainwine/gameserver/achievement/Achievement.java @@ -31,6 +31,7 @@ @Type(name = "UndertakerAchievement", value = UndertakerAchievement.class), @Type(name = "DeliveranceAchievement", value = DeliveranceAchievement.class), @Type(name = "TrappingAchievement", value = TrappingAchievement.class), + @Type(name = "InsurrectionAchievement", value = InsurrectionAchievement.class), @Type(name = "Journeyman", value = JourneymanAchievement.class), @Type(name = "ArchitectAchievement", value = ArchitectAchievement.class), @Type(name = "VotingAchievement", value = VotingAchievement.class), diff --git a/gameserver/src/main/java/brainwine/gameserver/achievement/HuntingAchievement.java b/gameserver/src/main/java/brainwine/gameserver/achievement/HuntingAchievement.java index c2b0faeb..c2de4643 100644 --- a/gameserver/src/main/java/brainwine/gameserver/achievement/HuntingAchievement.java +++ b/gameserver/src/main/java/brainwine/gameserver/achievement/HuntingAchievement.java @@ -21,10 +21,6 @@ public HuntingAchievement(@JacksonInject("title") String title) { @Override public int getProgress(Player player) { - return player.getStatistics().getKills().entrySet().stream() - .filter(entry -> entry.getKey().getGroup() == group) - .map(Entry::getValue) - .reduce(Integer::sum) - .orElse(0); + return player.getStatistics().getKills(group); } } diff --git a/gameserver/src/main/java/brainwine/gameserver/achievement/InsurrectionAchievement.java b/gameserver/src/main/java/brainwine/gameserver/achievement/InsurrectionAchievement.java new file mode 100644 index 00000000..9d1d09f7 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/achievement/InsurrectionAchievement.java @@ -0,0 +1,15 @@ +package brainwine.gameserver.achievement; + +import brainwine.gameserver.player.Player; +import com.fasterxml.jackson.annotation.JacksonInject; + +public class InsurrectionAchievement extends Achievement { + public InsurrectionAchievement(@JacksonInject("title") String title) { + super(title); + } + + @Override + public int getProgress(Player player) { + return player.getStatistics().getEvokersInhibited(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/achievement/JourneymanAchievement.java b/gameserver/src/main/java/brainwine/gameserver/achievement/JourneymanAchievement.java index c0f48aeb..a366f0b5 100644 --- a/gameserver/src/main/java/brainwine/gameserver/achievement/JourneymanAchievement.java +++ b/gameserver/src/main/java/brainwine/gameserver/achievement/JourneymanAchievement.java @@ -14,6 +14,6 @@ public JourneymanAchievement(@JacksonInject("title") String title) { @Override public boolean isCompleted(Player player) { - return true; + return player.getZone() != null && !player.getZone().isTutorial(); } } diff --git a/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShop.java b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShop.java new file mode 100644 index 00000000..f68d4c49 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShop.java @@ -0,0 +1,93 @@ +package brainwine.gameserver.androidshop; + +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.resource.ResourceFinder; +import brainwine.gameserver.shop.ShopSection; +import brainwine.gameserver.util.MapHelper; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static brainwine.shared.LogMarkers.SERVER_MARKER; + +public class AndroidShop { + private static final Logger logger = LogManager.getLogger(); + private final Map sections = new LinkedHashMap<>(); + private final Map products = new LinkedHashMap<>(); + private AndroidShopAdjustments adjustments = new AndroidShopAdjustments(); + + private static AndroidShop instance; + + public void loadShopData() { + logger.info(SERVER_MARKER, "Loading android shop data ..."); + sections.clear(); + products.clear(); + + try { + URL url = ResourceFinder.getResourceUrl("android-shop.json"); + Map data = JsonHelper.readValue(url, new TypeReference>() {}); + Map> sectionData = (Map>)data.get("sections"); + for(String sectionId : sectionData.keySet()) { + String name = (String)sectionData.get(sectionId).get("name"); + String icon = (String)sectionData.get(sectionId).get("icon"); + Map items = MapHelper.getMap(sectionData.get(sectionId), "items"); + Map maxQuantitiesPerDay = MapHelper.getMap(sectionData.get(sectionId), "quantity_per_day", new HashMap<>()); + List productKeys = new ArrayList<>(items.keySet()); + + for(String productId : items.keySet()) { + Item item = ItemRegistry.getItem(productId); + if(item.isAir()) { + productKeys.remove(productId); + continue; + } + + AndroidShopProduct product = new AndroidShopProduct( + item, + items.get(item.getId()), + maxQuantitiesPerDay.getOrDefault(item.getId(), Integer.MAX_VALUE) + ); + + products.put(productId, product); + } + + if(!productKeys.isEmpty()) { + ShopSection section = new ShopSection(name, icon, productKeys.toArray(new String[0])); + sections.put(sectionId, section); + } + } + + adjustments = JsonHelper.readValue(url, AndroidShopAdjustments.class); + } catch(Exception e) { + logger.error(SERVER_MARKER, "Could not load android shop data", e); + return; + } + + logger.info(SERVER_MARKER, "Successfully loaded the android shop with {} product{}", products.size(), products.size() == 1 ? "" : "s"); + } + + public Map getSections() { + return sections; + } + + public Map getProducts() { + return products; + } + + public AndroidShopAdjustments getAdjustments() { + return adjustments; + } + + public static AndroidShop getInstance() { + if (instance == null) instance = new AndroidShop(); + return instance; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopAdjustments.java b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopAdjustments.java new file mode 100644 index 00000000..dc36eea7 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopAdjustments.java @@ -0,0 +1,62 @@ +package brainwine.gameserver.androidshop; + +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.Skill; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class AndroidShopAdjustments { + private double[] sellPriceAdjustment = new double[] { 1.0, 1.0, 0.98, 0.96, 0.94, 0.92, 0.90, 0.88, 0.86, 0.84, 0.82, 0.80, 0.78, 0.76, 0.74, 0.72 }; + private double[] buyPriceAdjustment = new double[] { 1.00, 1.00, 1.01, 1.02, 1.03, 1.04, 1.05, 1.06, 1.07, 1.08, 1.09, 1.10, 1.11, 1.12, 1.13, 1.14 }; + private double[] maxPrice = new double[] { 25, 50, 75, 100, 250, 500, 750, 1000, 1500, 2000, 2500, 3000, 5000 }; + + private static double[] makeLevelLookupArray(Map config, double startingValue) { + double[] result = new double[Player.MAX_SKILL_LEVEL + 1]; + result[0] = startingValue; + for(int level = 1; level < result.length; level++) { + Object val = config.get(Integer.toString(level)); + result[level] = result[level - 1]; + if(val != null) try { + result[level] = Double.parseDouble(val.toString()); + } catch(NumberFormatException ignored) {} + } + + return result; + } + + public AndroidShopAdjustments() {} + + @JsonCreator + public AndroidShopAdjustments( + @JsonSetter(value = "sell_price_adjustment", nulls = Nulls.SKIP) Map sellPriceAdjustment, + @JsonSetter(value = "buy_price_adjustment", nulls = Nulls.SKIP) Map buyPriceAdjustment, + @JsonSetter(value = "max_price", nulls = Nulls.SKIP) Map maxPrice + ) { + if(sellPriceAdjustment != null) { + this.sellPriceAdjustment = makeLevelLookupArray(sellPriceAdjustment, 1.00); + } + if(buyPriceAdjustment != null) { + this.buyPriceAdjustment = makeLevelLookupArray(buyPriceAdjustment, 1.00); + } + if(maxPrice != null) { + this.maxPrice = makeLevelLookupArray(maxPrice, 25); + } + } + + public int getAdjustedSellPrice(Player player, int price) { + return (int)Math.ceil(sellPriceAdjustment[player.getTotalSkillLevel(Skill.BARTER)] * price); + } + + public int getAdjustedBuyPrice(Player player, int price) { + return (int)Math.ceil(buyPriceAdjustment[player.getTotalSkillLevel(Skill.BARTER)] * price); + } + + public int getMaxPrice(Player player) { + return (int)maxPrice[player.getTotalSkillLevel(Skill.BARTER)]; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopHistory.java b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopHistory.java new file mode 100644 index 00000000..2b6e00a7 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopHistory.java @@ -0,0 +1,61 @@ +package brainwine.gameserver.androidshop; + +import brainwine.gameserver.item.Item; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AndroidShopHistory { + private static final int FORGETTING_INTERVAL_HOURS = 24; + + @JsonProperty + List purchases = new ArrayList<>(); + + @JsonIgnore + Map summary = new HashMap(); + + public AndroidShopHistory() { + removeOldPurchases(); + } + + public void removeOldPurchases() { + OffsetDateTime now = OffsetDateTime.now(); + purchases.removeIf(item -> now.isAfter(item.date.plusHours(FORGETTING_INTERVAL_HOURS))); + summary.clear(); + for(Purchase purchase : purchases) { + summary.merge(purchase.item, purchase.quantity, Integer::sum); + } + } + + public int getPurchases(Item item) { + return summary.getOrDefault(item, 0); + } + + public void recordPurchase(Item item, int quantity) { + OffsetDateTime now = OffsetDateTime.now(); + purchases.add(new Purchase(now, item, quantity)); + summary.merge(item, quantity, Integer::sum); + } + + public static class Purchase { + @JsonProperty("date") + private OffsetDateTime date; + @JsonProperty("item") + private Item item; + @JsonProperty("quantity") + private int quantity; + + public Purchase() {} + + public Purchase(OffsetDateTime date, Item item, int quantity) { + this.date = date; + this.item = item; + this.quantity = quantity; + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopPerIpHistory.java b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopPerIpHistory.java new file mode 100644 index 00000000..f48b6f30 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopPerIpHistory.java @@ -0,0 +1,104 @@ +package brainwine.gameserver.androidshop; + +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.ValueWithExpiry; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AndroidShopPerIpHistory { + private static final String FILE_NAME = "android-purchases-per-ip.json"; + Map> historyByIp = new HashMap<>(); + private static final Logger logger = LogManager.getLogger(); + + private static AndroidShopPerIpHistory instance = null; + + public void load() { + logger.info("Loading android shop purchase history per IP..."); + try { + historyByIp.clear(); + + File file = new File(FILE_NAME); + + if(file.exists()) { + historyByIp.putAll(JsonHelper.readValue(file, new TypeReference>>() {})); + } + } catch(Exception e) { + logger.error("Could not read the android shop purchase history by IP.", e); + } + } + + public void purgeExpired() { + List entriesToRemove = new ArrayList<>(); + + for(Map.Entry> entry : historyByIp.entrySet()) { + if(entry.getValue().isExpired()) entriesToRemove.add(entry.getKey()); + } + + entriesToRemove.forEach(historyByIp::remove); + } + + public void save() { + try { + File file = new File(FILE_NAME); + + if(!file.exists()) { + file.createNewFile(); + } + + JsonHelper.writeValue(file, historyByIp); + } catch(Exception e) { + logger.error("Could not write the android shop purchase history by IP.", e); + } + } + + public String getKey(Player player) { + if(player.isOnline()) { + return player.getConnection().getIpAddress().toString(); + } + + return null; + } + + public AndroidShopHistory getHistory(Player player) { + String key = getKey(player); + if(key != null) { + ValueWithExpiry stored = historyByIp.get(key); + if(stored != null && !stored.isExpired()) { + return stored.getValue(); + } + } + + return null; + } + + public void recordPurchase(Player player, Item item, int quantity) { + String key = getKey(player); + if(key == null) return; + + AndroidShopHistory shopHistory = getHistory(player); + if(shopHistory == null) { + shopHistory = new AndroidShopHistory(); + } + + shopHistory.recordPurchase(item, quantity); + + historyByIp.put(key, new ValueWithExpiry(shopHistory, "2d")); + } + + public static AndroidShopPerIpHistory getInstance() { + if(instance == null) { + instance = new AndroidShopPerIpHistory(); + } + + return instance; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopProduct.java b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopProduct.java new file mode 100644 index 00000000..c40cf2ac --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopProduct.java @@ -0,0 +1,52 @@ +package brainwine.gameserver.androidshop; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; + +public class AndroidShopProduct { + private Item item = Item.AIR; + private int price = 0; + private int maxQuantityPerDay = Integer.MAX_VALUE; + + public AndroidShopProduct(Item item, int price, int maxQuantityPerDay) { + this.item = item; + this.price = price; + this.maxQuantityPerDay = maxQuantityPerDay; + } + + public Item getItem() { + return item; + } + + public int getPrice() { + return price; + } + + public int getMaxQuantityPerDay() { + return maxQuantityPerDay; + } + + public void purchase(Player player, int quantity) { + DialogSection section = new DialogSection().setTitle("You received:"); + Dialog dialog = new Dialog().addSection(section); + + if(quantity > 0) { + section.addItem(new DialogListItem() + .setItem(item.getCode()) + .setImage(String.format("inventory/%s", item.getId())) + .setText(String.format("%s x %s", item.getTitle(), quantity))); + player.getInventory().addItem(item, quantity, true); + } + + // Show dialog + if(player.isV3()) { + player.showDialog(dialog); + } else { + player.notify(dialog, NotificationType.REWARD); + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopSession.java b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopSession.java new file mode 100644 index 00000000..565355aa --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/androidshop/AndroidShopSession.java @@ -0,0 +1,314 @@ +package brainwine.gameserver.androidshop; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.TradeSession; +import brainwine.gameserver.shop.ShopSection; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.function.Consumer; + +public class AndroidShopSession { + private final AndroidShop shop; + private final Npc me; + private final Player player; + private final Consumer onOutcome; + private Optional currentSection = Optional.empty(); + private Optional currentProduct = Optional.empty(); + private OptionalInt currentQuantity = OptionalInt.empty(); + + private static final Logger logger = LogManager.getLogger(); + + public AndroidShopSession(AndroidShop shop, Npc me, Player player) { + this(shop, me, player, null); + } + + public AndroidShopSession(AndroidShop shop, Npc me, Player player, Consumer onOutcome) { + this.shop = shop; + this.me = me; + this.player = player; + this.onOutcome = onOutcome; + player.getAndroidShopHistory().removeOldPurchases(); + } + + private enum CanBuy { + OK(true, "", "How many are you buying?"), + TOO_HIGH_PRICE(false, "This item is too expensive for you.", "Sorry, but I believe that this item is too expensive for you. I won't even tell you the price."), + NOT_ENOUGH_SHILLINGS(true, "", "Sorry, you don't have enough shillings to buy any of this item."), + BOUGHT_TOO_FREQUENTLY(true, "", "Sorry, I don't have any more of that item at the moment. Come back tomorrow to see if I have more in stock!"), + ; + + CanBuy(boolean showInShop, String buttonMessage, String dialogMessage) { + this.showInShop = showInShop; + this.buttonMessage = buttonMessage; + this.dialogMessage = dialogMessage; + } + + public final boolean showInShop; + public final String buttonMessage; + public final String dialogMessage; + } + + private CanBuy canBuy(AndroidShopProduct product) { + return canBuy(product, 0); + } + + private CanBuy canBuy(AndroidShopProduct product, int quantity) { + int maxPrice = shop.getAdjustments().getMaxPrice(player); + int adjustedPrice = getAdjustedPrice(product); + int account = player.getInventory().getQuantity(ItemRegistry.getItem("accessories/shillings")); + int purchasedPlayer = player.getAndroidShopHistory().getPurchases(product.getItem()); + AndroidShopHistory ipHistory = AndroidShopPerIpHistory.getInstance().getHistory(player); + int purchasedIp = ipHistory != null ? ipHistory.getPurchases(product.getItem()) : 0; + int purchased = Math.max(purchasedPlayer, purchasedIp); + + if (adjustedPrice > maxPrice) { + return CanBuy.TOO_HIGH_PRICE; + } else if(adjustedPrice > account || adjustedPrice * quantity > account) { + return CanBuy.NOT_ENOUGH_SHILLINGS; + } else if(purchased >= product.getMaxQuantityPerDay() || purchased + quantity > product.getMaxQuantityPerDay()) { + return CanBuy.BOUGHT_TOO_FREQUENTLY; + } else { + return CanBuy.OK; + } + } + + private int getAdjustedPrice(AndroidShopProduct product) { + return shop.getAdjustments().getAdjustedSellPrice(player, product.getPrice()); + } + + public void showNextDialog() { + if(!player.isOnline() || me != null && (player.getZone() != me.getZone())) { + end(false); + return; + } + + try { + if(!currentSection.isPresent()) showAllSectionsDialog(); + else if(!currentProduct.isPresent()) showSectionDialog(); + else if(!currentQuantity.isPresent()) showQuantityDialog(); + else showConfirmationDialog(); + } catch(Exception e) { + player.notify("A problem occurred during your trade session."); + logger.error("A problem occurred during an android trade session.", e); + } + } + + public void showAllSectionsDialog() { + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle(me != null ? me.getName() + "'s Wares" : "Android Shop"); + dialog.addSection(new DialogSection().setText("I have items in different categories to offer. Click or tap on one to proceed!")); + + for(Map.Entry sectionEntry : shop.getSections().entrySet()) { + String key = sectionEntry.getKey(); + ShopSection section = sectionEntry.getValue(); + + dialog.addSection(new DialogSection().setChoice(key).setText(section.getName())); + } + + dialog.setActions("Cancel"); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || !(ans[0] instanceof String) || "cancel".equals(ans[0])) { + end(false); + return; + } + + String sectionKey = (String)ans[0]; + if(shop.getSections().containsKey(sectionKey)) { + currentSection = Optional.of(sectionKey); + } + + showNextDialog(); + }); + } + + public DialogSection getProductSection1(AndroidShopProduct product) { + Item item = product.getItem(); + + // This is eyeballed + int spaces = (int)Math.max(0.0f, 1.5f * (18 - item.getTitle().length())); + String padding = String.join("", Collections.nCopies(spaces, " ")); + return new DialogSection() + .addItem(new DialogListItem().setItem(item.getCode()).setText(padding + item.getTitle())); + } + + public DialogSection getProductSection2(AndroidShopProduct product) { + return new DialogSection().setText(product.getItem().getHint()); + } + + public void showSectionDialog() { + ShopSection shopSection = shop.getSections().get(currentSection.get()); + + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle(shopSection.getName()); + + for(String productId : shopSection.getProducts()) { + AndroidShopProduct product = shop.getProducts().get(productId); + CanBuy canBuy = canBuy(product); + int adjustedCost = getAdjustedPrice(product); + + // Do not show items if the player is not worth them anyway. + if(!canBuy.showInShop) continue; + + dialog.addSection(getProductSection1(product)); + dialog.addSection(getProductSection2(product)); + + DialogSection buySection = new DialogSection() + .setChoice(productId); + + boolean buttonReddened = canBuy != CanBuy.OK; + String buttonMessage = canBuy == CanBuy.TOO_HIGH_PRICE + ? canBuy.buttonMessage + : String.format("Buy %s | %d shilling%s each", product.getItem().getTitle(), adjustedCost, adjustedCost == 1 ? "" : "s"); + + if(buttonReddened) { + if(player.isV3()) { + buySection.setText("" + buttonMessage + ""); + } else { + buySection.setText(buttonMessage).setTextColor("ff8844"); + } + } else { + buySection.setText(buttonMessage); + } + + dialog.addSection(buySection); + } + + dialog.setActions("Back"); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || !(ans[0] instanceof String)) { + end(false); + return; + } + + if("cancel".equals(ans[0])) { + currentSection = Optional.empty(); + showNextDialog(); + return; + } + + String productId = (String)ans[0]; + if(shop.getProducts().containsKey(productId)) { + currentProduct = Optional.of(productId); + } + + showNextDialog(); + }); + } + + public void showQuantityDialog() { + AndroidShopProduct product = shop.getProducts().get(currentProduct.get()); + CanBuy canBuy = canBuy(product); + int adjustedCost = getAdjustedPrice(product); + + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Buying " + product.getItem().getTitle()); + dialog.addSection(getProductSection1(product)); + dialog.addSection(getProductSection2(product)); + + if(canBuy != CanBuy.TOO_HIGH_PRICE) { + DialogSection buySection = new DialogSection(); + + boolean buttonReddened = canBuy != CanBuy.OK; + String buttonMessage = String.format("I sell these for %d shilling%s each.", adjustedCost, adjustedCost == 1 ? "" : "s"); + + if(buttonReddened) { + if(player.isV3()) { + buySection.setText("" + buttonMessage + ""); + } else { + buySection.setText(buttonMessage).setTextColor("ff8844"); + } + } else { + buySection.setText(buttonMessage); + } + + dialog.addSection(buySection); + } + + if(canBuy == CanBuy.OK) { + int allowedByPrice = player.getInventory().getQuantity(ItemRegistry.getItem("accessories/shillings")) / getAdjustedPrice(product); + int purchased = player.getAndroidShopHistory().getPurchases(product.getItem()); + int maxQuantity = Math.min(allowedByPrice, product.getMaxQuantityPerDay() - purchased); + dialog.addSection(TradeSession.Dialogs.createQuantitySelector(maxQuantity).setTitle("How many are you buying?")); + } else { + dialog.addSection(new DialogSection().setText(canBuy.dialogMessage)); + } + + player.showDialog(dialog, ans -> { + if(ans.length == 0) { + return; + } + + if("cancel".equals(ans[0])) { + currentProduct = Optional.empty(); + showNextDialog(); + return; + } + + try { + int quantity = Integer.parseInt(ans[0].toString()); + currentQuantity = OptionalInt.of(quantity); + } catch(NumberFormatException ignored) {} + + showNextDialog(); + }); + } + + public void showConfirmationDialog() { + String productId = currentProduct.get(); + AndroidShopProduct product = shop.getProducts().get(productId); + if(product == null) { + end(false); + return; + } + + Item shillings = ItemRegistry.getItem("accessories/shillings"); + Item purchasedItem = ItemRegistry.getItem(productId); + int quantity = currentQuantity.getAsInt(); + int totalPrice = quantity * shop.getAdjustments().getAdjustedSellPrice(player, product.getPrice()); + + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Confirming Purchase"); + dialog.addSection(new DialogSection().setTitle("For your") + .addItem(new DialogListItem().setItem(shillings.getCode()).setText(totalPrice + (totalPrice == 1 ? " shilling" : " shillings"))) + ); + dialog.addSection(new DialogSection().setTitle("you will get") + .addItem(new DialogListItem().setItem(purchasedItem.getCode()).setText(purchasedItem.getTitle() + " x " + quantity)) + ); + dialog.addSection(new DialogSection().setText("Do you accept?")); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || !"cancel".equals(ans[0])) { + CanBuy canBuy = canBuy(product, quantity); + if(canBuy == CanBuy.OK) { + player.getInventory().removeItem(shillings, quantity * getAdjustedPrice(product), true); + product.purchase(player, quantity); + player.getAndroidShopHistory().recordPurchase(product.getItem(), quantity); + AndroidShopPerIpHistory.getInstance().recordPurchase(player, product.getItem(), quantity); + if(me != null) me.emote("Good trade!"); + end(true); + } else { + player.showDialog(DialogHelper.messageDialog(canBuy.dialogMessage).setType(DialogType.ANDROID)); + end(false); + } + } else { + currentQuantity = OptionalInt.empty(); + showNextDialog(); + } + }); + } + + public void end(boolean outcome) { + if(onOutcome != null) onOutcome.accept(outcome); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/anticheat/AfkEntitySpawn.java b/gameserver/src/main/java/brainwine/gameserver/anticheat/AfkEntitySpawn.java new file mode 100644 index 00000000..770cbc24 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/anticheat/AfkEntitySpawn.java @@ -0,0 +1,19 @@ +package brainwine.gameserver.anticheat; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class AfkEntitySpawn { + @JsonProperty + private boolean enabled = false; + + @JsonProperty + private long duration = 0; + + public boolean isEnabled() { + return enabled; + } + + public long getDuration() { + return duration; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/anticheat/AnticheatConfig.java b/gameserver/src/main/java/brainwine/gameserver/anticheat/AnticheatConfig.java new file mode 100644 index 00000000..f354ee54 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/anticheat/AnticheatConfig.java @@ -0,0 +1,33 @@ +package brainwine.gameserver.anticheat; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class AnticheatConfig { + @JsonProperty("afk_entity_spawn") + private AfkEntitySpawn afkEntitySpawn = new AfkEntitySpawn(); + + @JsonProperty("exoskeleton") + private Exoskeleton exoskeleton = new Exoskeleton(); + + @JsonProperty("exploder_farm") + private ExploderFarm exploderFarm = new ExploderFarm(); + + @JsonProperty("exploration") + private Exploration exploration = new Exploration(); + + public AfkEntitySpawn getAfkEntitySpawn() { + return afkEntitySpawn; + } + + public Exoskeleton getExoskeleton() { + return exoskeleton; + } + + public ExploderFarm getExploderFarm() { + return exploderFarm; + } + + public Exploration getExploration() { + return exploration; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/anticheat/AnticheatManager.java b/gameserver/src/main/java/brainwine/gameserver/anticheat/AnticheatManager.java new file mode 100644 index 00000000..96c0a5e6 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/anticheat/AnticheatManager.java @@ -0,0 +1,32 @@ +package brainwine.gameserver.anticheat; + +import brainwine.gameserver.resource.ResourceFinder; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.net.URL; + +import static brainwine.shared.LogMarkers.SERVER_MARKER; + +public class AnticheatManager { + private static AnticheatConfig config = new AnticheatConfig(); + private static final Logger logger = LogManager.getLogger(); + + public static void loadConfig() { + logger.info(SERVER_MARKER, "Loading anti-cheat ..."); + + try { + URL url = ResourceFinder.getResourceUrl("anticheat.json"); + config = JsonHelper.readValue(url, new TypeReference(){}); + } catch (IOException e) { + logger.error(SERVER_MARKER, "Failed to load anti-cheat config", e); + } + } + + public static AnticheatConfig getConfig() { + return config; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/anticheat/Exoskeleton.java b/gameserver/src/main/java/brainwine/gameserver/anticheat/Exoskeleton.java new file mode 100644 index 00000000..be2d7ea6 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/anticheat/Exoskeleton.java @@ -0,0 +1,13 @@ +package brainwine.gameserver.anticheat; + +import brainwine.gameserver.item.InventoryType; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class Exoskeleton { + @JsonProperty + public InventoryType inventoryType = InventoryType.ACCESSORY; + + public InventoryType getInventoryType() { + return inventoryType; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/anticheat/ExploderFarm.java b/gameserver/src/main/java/brainwine/gameserver/anticheat/ExploderFarm.java new file mode 100644 index 00000000..3e4a3729 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/anticheat/ExploderFarm.java @@ -0,0 +1,24 @@ +package brainwine.gameserver.anticheat; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ExploderFarm { + @JsonProperty + private boolean enabled = false; + @JsonProperty("loot_counter_max") + private int lootCounterMax = 1; + @JsonProperty("xp_factor") + private double xpFactor = 1.0; + + public boolean isEnabled() { + return enabled; + } + + public int getLootCounterMax() { + return lootCounterMax; + } + + public double getXpFactor() { + return xpFactor; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/anticheat/Exploration.java b/gameserver/src/main/java/brainwine/gameserver/anticheat/Exploration.java new file mode 100644 index 00000000..05d565cf --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/anticheat/Exploration.java @@ -0,0 +1,42 @@ +package brainwine.gameserver.anticheat; + +import brainwine.gameserver.zone.Biome; +import brainwine.gameserver.zone.Zone; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class Exploration { + public enum Region { + SKY, + UNDERGROUND, + ALL, + } + + private Set excludedBiomes = new HashSet<>(Arrays.asList(Biome.SPACE)); + private Region stats = Region.ALL; + private Region worldExplorationPercent = Region.ALL; + + public Set getExcludedBiomes() { + return excludedBiomes; + } + + public Region getStats() { + return stats; + } + + public Region getWorldExplorationPercent() { + return worldExplorationPercent; + } + + public boolean isIncluded(Zone zone) { + return !excludedBiomes.contains(zone.getBiome()); + } + + public boolean shouldTrackStats(Zone zone, int x, int y) { + return !isIncluded(zone) + || stats == Region.ALL + || zone.isChunkUndergroundXY(x, y) ? stats == Region.UNDERGROUND : stats == Region.SKY; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/chat/Corpus.java b/gameserver/src/main/java/brainwine/gameserver/chat/Corpus.java new file mode 100644 index 00000000..e6bac267 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/chat/Corpus.java @@ -0,0 +1,59 @@ +package brainwine.gameserver.chat; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Scanner; + +public class Corpus { + Map next = new HashMap<>(); + + public void addPhrase(String item) { + try(Scanner sc = new Scanner(item)) { + sc.useDelimiter("\\s+"); + addPhrase(sc); + } + } + + public void addPhrase(Scanner sc) { + if(sc.hasNext()) { + String word = sc.next(); + Corpus nextCorpus = next.computeIfAbsent(word, s -> new Corpus()); + nextCorpus.addPhrase(sc); + } else { + next.put("", null); + } + } + + public int findLongestMatch(List words, int i) { + return findLongestMatch(words, i, 0); + } + + public int findLongestMatch(List words, int i, int currentCount) { + while(i < words.size() && words.get(i).getType() != TokenType.WORD) { + i++; + currentCount++; + } + + int myCount = next.containsKey("") ? currentCount : 0; + + if(i >= words.size()) { + return myCount; + } + + String lower = words.get(i).getValue().toLowerCase(); + if(next.containsKey(lower)) { + Corpus nextCorpus = next.get(lower); + + int nextCount = nextCorpus == null ? 0 : nextCorpus.findLongestMatch(words, i + 1, currentCount + 1); + + return Math.max(myCount, nextCount); + } + + return myCount; + } + + public boolean isEmpty() { + return next.isEmpty(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/chat/PlayerProfanity.java b/gameserver/src/main/java/brainwine/gameserver/chat/PlayerProfanity.java new file mode 100644 index 00000000..74b4f8e1 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/chat/PlayerProfanity.java @@ -0,0 +1,25 @@ +package brainwine.gameserver.chat; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.item.DamageType; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; + +public class PlayerProfanity { + public static String filterAndPunish(Player player, String unfiltered) { + if(player.isGodMode()) { + return unfiltered; + } + + String text = GameServer.getInstance().getProfanityManager().filter(unfiltered); + if(!text.equals(unfiltered)) { + punish(player); + } + + return text; + } + + public static void punish(Player player) { + player.attack(null, Item.AIR, 0.5f, DamageType.ENERGY); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/chat/ProfanityManager.java b/gameserver/src/main/java/brainwine/gameserver/chat/ProfanityManager.java new file mode 100644 index 00000000..a040304d --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/chat/ProfanityManager.java @@ -0,0 +1,139 @@ +package brainwine.gameserver.chat; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Scanner; +import java.util.StringJoiner; +import java.util.stream.Collectors; + +public class ProfanityManager { + private static final char[] obscuredCharacters = "!@#$%&".toCharArray(); + + private static final Logger logger = LogManager.getLogger(); + Corpus root = new Corpus(); + + public ProfanityManager() { + File file = new File("profanity.txt"); + if(!file.exists()) { + logger.warn("No profanity filter has been enabled. Add profanity.txt to the working directory to enable it."); + return; + } + + try(Scanner sc = new Scanner(new FileInputStream(file))) { + while(sc.hasNextLine()) { + String line = sc.nextLine(); + if(!line.matches("\\s*") && !line.startsWith("##") && !line.startsWith("--")) { + String[] words = line.split(", ?"); + for(String word : words) { + try { + root.addPhrase(word); + } catch(Exception e) { + logger.warn("Couldn't register profane phrase {}", word, e); + } + } + } + } + } catch(IOException e) { + logger.error("Couldn't load the profanity filter.", e); + } + } + + public String filter(String text) { + if(root.isEmpty()) return text; + + try { + List words = splitIntoTokens(text); + for(int i = 0; i < words.size(); i++) { + int longest = root.findLongestMatch(words, i); + int start = -1; + int end = -1; + for(int j = i; j < i + longest; j++) { + if(words.get(j).getType() == TokenType.WORD) { + if(start == -1) start = j; + end = j; + } + } + if(start == -1) continue; + for(int j = start; j <= end; j++) { + StringBuilder obscuredWord = new StringBuilder(words.get(j).getValue().substring(0, 1)); + int lastChoice = (int) (Math.random() * obscuredCharacters.length); + for(int k = 1; k < words.get(j).getValue().length(); k++) { + int choice = (int) (Math.random() * obscuredCharacters.length); + obscuredWord.append(obscuredCharacters[choice == lastChoice ? (choice + 1) % obscuredCharacters.length : choice]); + lastChoice = choice; + } + words.get(j).setValue(obscuredWord.toString()); + } + if(longest > 1) i += longest - 1; + } + + return words.stream().map(Token::getValue).collect(Collectors.joining("")); + } catch(Exception e) { + logger.error("Couldn't filter phrase {}", text, e); + return text; + } + } + + private List splitIntoTokens(String text) { + StringBuilder sb = null; + String[] chars = text.split(""); + List tokens = new ArrayList<>(); + + TokenType lastTokenType = TokenType.NONE; + for(String aChar : chars) { + TokenType tokenType = aChar.matches("\\s") ? TokenType.WHITESPACE : aChar.matches("[!\"#%&'()*+,\\n\\-./:;<=>?@\\[\\\\\\]^_`{|}~]") ? TokenType.PUNCTUATION : TokenType.WORD; + if(lastTokenType != tokenType) { + if(sb != null) { + tokens.add(new Token(sb.toString(), lastTokenType)); + } + sb = new StringBuilder(); + } + sb.append(aChar); + lastTokenType = tokenType; + } + + if(sb != null && sb.length() > 0) { + tokens.add(new Token(sb.toString(), lastTokenType)); + } + + return tokens; + } + + public boolean filterAll(Map strings) { + try { + StringJoiner sj = new StringJoiner(" "); + for(Map.Entry entry : strings.entrySet()) { + sj.add(entry.getValue()); + } + String initial = sj.toString(); + String result = filter(initial); + if(initial.equals(result)) { + return false; + } + + if(initial.length() != result.length()) { + logger.warn("initial and result are {} and {} long respectively.", initial.length(), result.length()); + } + + int used = 0; + for(Map.Entry entry : strings.entrySet()) { + int start = used; + int end = Math.min(start + entry.getValue().length(), result.length()); + entry.setValue(start < end ? result.substring(start, end) : ""); + used = end + 1; + } + + return true; + } catch(Exception e) { + logger.error("Failed to filter all!", e); + return false; + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/chat/Token.java b/gameserver/src/main/java/brainwine/gameserver/chat/Token.java new file mode 100644 index 00000000..29ebcdce --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/chat/Token.java @@ -0,0 +1,23 @@ +package brainwine.gameserver.chat; + +public class Token { + private String value; + private TokenType type; + + public Token(String value, TokenType type) { + this.value = value; + this.type = type; + } + + public String getValue() { + return value; + } + + public TokenType getType() { + return type; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/chat/TokenType.java b/gameserver/src/main/java/brainwine/gameserver/chat/TokenType.java new file mode 100644 index 00000000..921a3719 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/chat/TokenType.java @@ -0,0 +1,8 @@ +package brainwine.gameserver.chat; + +public enum TokenType { + WHITESPACE, + PUNCTUATION, + WORD, + NONE, +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/Command.java b/gameserver/src/main/java/brainwine/gameserver/command/Command.java index 1e1b1661..f5533e92 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/Command.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/Command.java @@ -10,6 +10,10 @@ public abstract class Command { public boolean canExecute(CommandExecutor executor) { return true; } + + public boolean useSmartArguments() { + return false; + } protected final boolean checkArgumentCount(CommandExecutor executor, String[] args, int... counts) { int highestCount = 0; @@ -35,4 +39,5 @@ protected final boolean checkArgumentCount(CommandExecutor executor, String[] ar protected final void sendUsageMessage(CommandExecutor executor) { executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM); } + } diff --git a/gameserver/src/main/java/brainwine/gameserver/command/CommandAccessLevel.java b/gameserver/src/main/java/brainwine/gameserver/command/CommandAccessLevel.java new file mode 100644 index 00000000..a4166b9d --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/CommandAccessLevel.java @@ -0,0 +1,45 @@ +package brainwine.gameserver.command; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Block; +import brainwine.gameserver.zone.Zone; + +// Don't change the order of owners, members, everyone +public enum CommandAccessLevel { + OWNERS(true, false, false), + MEMBERS(true, true, false), + EVERYONE(true, true, true), + NON_OWNERS(false, true, true), + NON_MEMBERS(false, false, true), + NO_ONE(false, false, false), + ONLY_MEMBERS(false, true, false), + OWNERS_AND_OTHERS(true, false, true); + + private boolean owners; + private boolean members; + private boolean others; + + CommandAccessLevel(boolean owners, boolean members, boolean others) { + this.owners = owners; + this.members = members; + this.others = others; + } + + public boolean isPrivileged(CommandExecutor executor, Zone zone) { + if(executor == null || zone == null) return false; + if(executor instanceof GameServer) return true; + if(executor.isAdmin()) return true; + + Player player = (Player)executor; + if(zone.isOwner(player)) return this.owners; + else if(zone.isMember(player)) return this.members; + else return this.others; + + } + + public boolean isPrivileged(Player player, Block block) { + if(player.isGodMode()) return true; + return player.getBlockHash() == block.getOwnerHash() ? this.owners : this.others; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/CommandManager.java b/gameserver/src/main/java/brainwine/gameserver/command/CommandManager.java index b11de02d..0326bb90 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/CommandManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/CommandManager.java @@ -3,6 +3,7 @@ import static brainwine.gameserver.player.NotificationType.SYSTEM; import static brainwine.shared.LogMarkers.SERVER_MARKER; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -57,7 +58,7 @@ public static void executeCommand(CommandExecutor executor, String commandLine) if(commandLine.isEmpty()) { return; } - + commandLine.trim().replaceAll(" +", " "); String[] sections = commandLine.split(" ", 2); @@ -86,8 +87,52 @@ public static void executeCommand(CommandExecutor executor, String commandName, Player player = (Player)executor; logger.info(SERVER_MARKER, "{} used command '/{}'", player.getName(), commandName + (args.length == 0 ? "" : " " + String.join(" ", args))); } - + + if(command.useSmartArguments()) { + try { + ArrayList newArgs = new ArrayList<>(); + String joinedArgs = String.join(" ", args); + + int currentIndex = 0; + while(currentIndex < joinedArgs.length()) { + while(currentIndex < joinedArgs.length() && Character.isWhitespace(joinedArgs.charAt(currentIndex))) { + currentIndex++; + } + + if(currentIndex >= joinedArgs.length()) { + break; + } + + if(joinedArgs.charAt(currentIndex) == '"') { + int endIndex = joinedArgs.indexOf('"', currentIndex + 1); + if(endIndex == -1) { + executor.notify("Command parsing failed: unbalanced quotes.", SYSTEM); + return; + } + newArgs.add(joinedArgs.substring(currentIndex + 1, endIndex)); + currentIndex = endIndex + 1; + } else { + int endIndex = joinedArgs.indexOf(' ', currentIndex + 1); + if(endIndex == -1) { + newArgs.add(joinedArgs.substring(currentIndex).trim()); + break; + } else { + newArgs.add(joinedArgs.substring(currentIndex, endIndex).trim()); + currentIndex = endIndex + 1; + } + } + } + + args = newArgs.toArray(new String[0]); + } catch(Exception e) { + e.printStackTrace(); + executor.notify("There has been an error parsing the smart command arguments.", SYSTEM); + return; + } + } + command.execute(executor, args); + } public static void registerCommand(Class type) { diff --git a/gameserver/src/main/java/brainwine/gameserver/command/ExoCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/ExoCommand.java index 0c1c3792..dfa7498c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/ExoCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/ExoCommand.java @@ -1,22 +1,82 @@ package brainwine.gameserver.command; +import static brainwine.gameserver.player.NotificationType.SYSTEM; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; import brainwine.gameserver.dialog.DialogSection; import brainwine.gameserver.dialog.input.DialogSelectInput; import brainwine.gameserver.item.Item; import brainwine.gameserver.player.AppearanceSlot; import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; -@CommandInfo(name = "exo", description = "Configure exosuit visibility settings.") +@CommandInfo(name = "exo", description = "Lets you toggle the visibility of your exoskeleton parts.") public class ExoCommand extends Command { + public enum Mode { + SIMPLE, + ADVANCED, + } + + public Mode chooseMode(CommandExecutor executor) { + return Mode.SIMPLE; // TODO it should be based on which type of accessory slot is used + } @Override public void execute(CommandExecutor executor, String[] args) { + Mode mode = null; + if(args.length == 0) { + mode = chooseMode(executor); + } else { + if(args[0].startsWith("s")) mode = Mode.SIMPLE; + if(args[0].startsWith("a")) mode = Mode.ADVANCED; + } + + if(mode == null) { + executor.notify("First argument needs to start with s (for simple) or a (for advanced).", SYSTEM); + return; + } + + if(mode == Mode.SIMPLE) executeSimple(executor, args); + if(mode == Mode.ADVANCED) executeAdvanced(executor, args); + } + + public void executeSimple(CommandExecutor executor, String[] args) { + Player player = (Player)executor; + + String headgearKey = AppearanceSlot.FACIAL_GEAR.getId(); + String torsoKey = AppearanceSlot.TOPS_OVERLAY.getId(); + String legsKey = AppearanceSlot.LEGS_OVERLAY.getId(); + + Dialog dialog = DialogHelper.getDialog("exo"); + + if(dialog.getSections().size() >= 4) { + try { + dialog.getSections().get(1).getInput().setValue(MapHelper.getBoolean(player.getAppearance(), headgearKey) ? "Visible" : "Hidden"); + dialog.getSections().get(2).getInput().setValue(MapHelper.getBoolean(player.getAppearance(), torsoKey) ? "Visible" : "Hidden"); + dialog.getSections().get(3).getInput().setValue(MapHelper.getBoolean(player.getAppearance(), legsKey) ? "Visible" : "Hidden"); + } catch(Exception e) { + e.printStackTrace(); + } + } + + player.showDialog(dialog, ans -> { + if(ans.length < 3) return; + + player.updateAppearance(MapHelper.map(String.class, Object.class, + headgearKey, "Visible".equals(ans[0]), + torsoKey, "Visible".equals(ans[1]), + legsKey, "Visible".equals(ans[2]) + )); + }); + } + + public void executeAdvanced(CommandExecutor executor, String[] args) { Player player = (Player)executor; // TODO: text index would be far more appropriate for this @@ -50,12 +110,12 @@ public void execute(CommandExecutor executor, String[] args) { player.updateAppearance(appearance); }); } - + @Override public String getUsage(CommandExecutor executor) { return "/exo"; } - + @Override public boolean canExecute(CommandExecutor executor) { return executor instanceof Player; diff --git a/gameserver/src/main/java/brainwine/gameserver/command/OrderCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/OrderCommand.java new file mode 100644 index 00000000..817c89b7 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/OrderCommand.java @@ -0,0 +1,54 @@ +package brainwine.gameserver.command; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.input.DialogSelectInput; +import brainwine.gameserver.order.Order; +import brainwine.gameserver.order.OrderManager; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.EntityChangeMessage; +import brainwine.gameserver.server.messages.EventMessage; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@CommandInfo(name = "order", description = "Shows a prompt where you can update your displayed order icon.", aliases = "orders") +public class OrderCommand extends Command { + @Override + public void execute(CommandExecutor executor, String[] args) { + if(!(executor instanceof Player)) { + executor.notify("Only players can update their order icon.", NotificationType.SYSTEM); + } + Player player = (Player)executor; + + List orderKeys = player.getOrders().entrySet().stream() + .filter(e -> e.getValue() > 0) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + + final Map orders = OrderManager.getOrders(player); + List options = orders.keySet().stream().map(Order::getTitle).collect(Collectors.toList()); + options.add("None"); + + Dialog dialog = new Dialog() + .setTitle("Change the Order displayed by your name") + .addSection(new DialogSection() + .setInput(new DialogSelectInput().setOptions(options).setKey("order")) + ); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || "cancel".equals(ans[0]) || !(ans[0] instanceof String)) return; + String newValue = "None".equals(ans[0]) ? null : (String)ans[0]; + player.setDisplayedOrder(OrderManager.getOrderKeyFromTitle(newValue)); + player.sendMessage(new EventMessage("playerIconDidChange", player.getIconEmoji())); + player.sendMessageToPeers(new EntityChangeMessage(player.getId(), player.getStatusConfig())); + }); + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/order"; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/QuestsCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/QuestsCommand.java new file mode 100644 index 00000000..8fb07e0d --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/QuestsCommand.java @@ -0,0 +1,51 @@ +package brainwine.gameserver.command; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.PlayerQuestDialog; + +import java.util.Objects; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "quests", description = "Lists your ongoing and completed quests.") +public class QuestsCommand extends Command { + + @Override + public void execute(CommandExecutor executor, String[] args) { + if(!(executor instanceof Player)) { + executor.notify("You can only view your quests as a player!", NotificationType.SYSTEM); + } + + if(args.length > 0) { + if(!executor.isAdmin() && !Objects.equals(args[0], ((Player) executor).getName())) { + executor.notify("You are not allowed to view other player's quests.", SYSTEM); + return; + } + + Player target = GameServer.getInstance().getPlayerManager().getPlayer(args[0]); + + if(target == null) { + executor.notify("That player does not exist.", SYSTEM); + return; + } + + PlayerQuestDialog.showPlayerQuests((Player) executor, target); + } else { + PlayerQuestDialog.showPlayerQuests((Player) executor); + } + + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/quests [player]"; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor.isAdmin() && executor instanceof Player; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/SayCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/SayCommand.java index c41fec0e..ef146d46 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/SayCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/SayCommand.java @@ -2,6 +2,7 @@ import static brainwine.gameserver.player.NotificationType.SYSTEM; +import brainwine.gameserver.chat.PlayerProfanity; import brainwine.gameserver.player.ChatType; import brainwine.gameserver.player.Player; @@ -22,7 +23,9 @@ public void execute(CommandExecutor executor, String[] args) { return; } - String text = String.join(" ", args); + String unfiltered = String.join(" ", args); + String text = PlayerProfanity.filterAndPunish(player, unfiltered); + player.getZone().sendChatMessage(player, text, ChatType.SPEECH); } diff --git a/gameserver/src/main/java/brainwine/gameserver/command/StatsCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/StatsCommand.java new file mode 100644 index 00000000..2803c72d --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/StatsCommand.java @@ -0,0 +1,58 @@ +package brainwine.gameserver.command; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.order.Order; +import brainwine.gameserver.order.OrderManager; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; + +import java.util.Objects; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "stats", description = "Lists your stats and progress needed to advance into orders.") +public class StatsCommand extends Command { + private void showStats(Player executor, Player target) { + Dialog dialog = new Dialog() + .setTitle(executor == target ? "Your Order Progress" : target.getName() + "'s Order Progress"); + + for(Order order : OrderManager.getOrders().values()) { + dialog.addSection(order.getStatDialogSection(target)); + } + + executor.showDialog(dialog); + } + + @Override + public void execute(CommandExecutor executor, String[] args) { + if(!(executor instanceof Player)) { + executor.notify("Only online players can check out their stats.", NotificationType.SYSTEM); + return; + } + + if(args.length > 0) { + if(!executor.isAdmin() && !Objects.equals(args[0], ((Player) executor).getName())) { + executor.notify("You are not allowed to view other player's stats.", SYSTEM); + return; + } + + Player target = GameServer.getInstance().getPlayerManager().getPlayer(args[0]); + + if(target == null) { + executor.notify("That player does not exist.", SYSTEM); + return; + } + + showStats((Player)executor, target); + } else { + showStats((Player)executor, (Player)executor); + } + + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/stats [player]"; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/TellCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/TellCommand.java new file mode 100644 index 00000000..b2e411c0 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/TellCommand.java @@ -0,0 +1,73 @@ +package brainwine.gameserver.command; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.chat.PlayerProfanity; +import brainwine.gameserver.player.ChatType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.ChatMessage; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "tell", description = "Send a private message to a fellow player.", aliases = "t") +public class TellCommand extends Command { + private static final String COLOR = "#8080FF"; + + @Override + public void execute(CommandExecutor executor, String[] args) { + if(args.length == 0) { + executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM); + return; + } + + Player executorPlayer = executor instanceof Player ? (Player) executor : null; + Player target = GameServer.getInstance().getPlayerManager().getPlayer(args[0]); + if(target == null) { + executor.notify("That player does not exist.", SYSTEM); + return; + } + + if(!target.isOnline()) { + executor.notify("Player is not online.", SYSTEM); + return; + } + + if(executorPlayer != null && !executorPlayer.isGodMode() && target.getZone() != executorPlayer.getZone()) { + executor.notify("Player is not in the same world.", SYSTEM); + return; + } + + String message = Arrays.stream(args).skip(1).collect(Collectors.joining(" ")); + if(executorPlayer != null) { + message = PlayerProfanity.filterAndPunish(executorPlayer, message); + } + + String executorName = executorPlayer != null ? executorPlayer.getName() : "Server"; + int executorId = executorPlayer != null ? executorPlayer.getId() : 0; + + if(target.isV3()) { + String targetMessage = String.format("%s: %s", COLOR, executorName, message); + target.sendMessage(new ChatMessage(0, targetMessage, ChatType.PRIVATE)); + } else { + target.sendMessage(new ChatMessage(executorId, message, ChatType.PRIVATE)); + } + + if(executorPlayer == null) { + executor.notify(String.format("%s: %s", target.getName(), message), SYSTEM); + } else if(executorPlayer != target) { + if (executorPlayer.isV3()) { + String executorMessage = String.format("%s: %s", COLOR, executorName, message); + executorPlayer.sendMessage(new ChatMessage(0, executorMessage, ChatType.PRIVATE)); + } else { + executorPlayer.sendMessage(new ChatMessage(executorId, message, ChatType.PRIVATE)); + } + } + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/tell "; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/ThinkCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/ThinkCommand.java index 2c5f9e01..1c854fd2 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/ThinkCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/ThinkCommand.java @@ -2,6 +2,7 @@ import static brainwine.gameserver.player.NotificationType.SYSTEM; +import brainwine.gameserver.chat.PlayerProfanity; import brainwine.gameserver.player.ChatType; import brainwine.gameserver.player.Player; @@ -22,7 +23,9 @@ public void execute(CommandExecutor executor, String[] args) { return; } - String text = String.join(" ", args); + String unfiltered = String.join(" ", args); + String text = PlayerProfanity.filterAndPunish(player, unfiltered); + player.getZone().sendChatMessage(player, text, ChatType.THOUGHT); } diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/BanCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/BanCommand.java index ebdc8fd1..e64915b1 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/admin/BanCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/BanCommand.java @@ -18,7 +18,7 @@ public class BanCommand extends Command { @Override public void execute(CommandExecutor executor, String[] args) { - if(args.length < 2) { + if(args.length < 3) { executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM); return; } @@ -47,22 +47,36 @@ public void execute(CommandExecutor executor, String[] args) { executor.notify("Time units: y = years, w = weeks, d = days, h = hours, m = minutes", SYSTEM); return; } + + boolean ipBan; + if("true".equalsIgnoreCase(args[2])) { + ipBan = true; + } else if("false".equalsIgnoreCase(args[2])){ + ipBan = false; + } else { + executor.notify("IP ban argument must be true or false.", SYSTEM); + return; + } String reason = "The ban hammer has spoken!"; - if(args.length > 2) { - reason = String.join(" ", Arrays.copyOfRange(args, 2, args.length)); + if(args.length > 3) { + reason = String.join(" ", Arrays.copyOfRange(args, 3, args.length)); } OffsetDateTime endDate = OffsetDateTime.now().plusMinutes(duration); target.ban(executor instanceof Player ? (Player)executor : null, reason, endDate); + if(ipBan) { + GameServer.getInstance().getIpBans().ban(target); + } + executor.notify(String.format("Banned %s until %s for '%s'", target.getName(), endDate.format(DateTimeFormatter.RFC_1123_DATE_TIME), reason), SYSTEM); } @Override public String getUsage(CommandExecutor executor) { - return "/ban [reason]"; + return "/ban [reason]"; } @Override diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/BanIpCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/BanIpCommand.java new file mode 100644 index 00000000..01d11e62 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/BanIpCommand.java @@ -0,0 +1,75 @@ +package brainwine.gameserver.command.admin; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.PlayerManager; +import brainwine.gameserver.util.Cidr; +import brainwine.gameserver.util.DateTimeUtils; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "banip", description = "Bans a player from the server.") +public class BanIpCommand extends Command { + + @Override + public void execute(CommandExecutor executor, String[] args) { + if(args.length < 1) { + executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM); + return; + } + + Cidr target; + try { + target = Cidr.create(args[0]); + } catch(IllegalArgumentException e) { + executor.notify("Provided IP address is not in a valid format. " + e.getMessage(), SYSTEM); + return; + } + + Player scapegoat = null; + if(args.length >= 2) { + scapegoat = GameServer.getInstance().getPlayerManager().getPlayer(args[1]); + if(scapegoat == null) { + executor.notify("Scapegoat " + args[1] + " does not exist.", SYSTEM); + return; + } + } + + if(executor instanceof Player && target.matches(((Player)executor).getConnection().getIpAddress())) { + executor.notify("You would be banning your own IP this way.", SYSTEM); + return; + } + + GameServer.getInstance().getIpBans().banCidr(target, scapegoat); + + if(scapegoat == null) { + executor.notify(String.format("Connections from %s will be blocked.", target), SYSTEM); + } else { + executor.notify(String.format("Players connecting from %s will be banned for the same reason as %s.", target, scapegoat.getName()), SYSTEM); + } + + for(Player player : GameServer.getInstance().getPlayerManager().getOnlinePlayers()) { + if(target.matches(player.getConnection().getIpAddress())) { + player.kick("Your IP address has been blocked."); + } + } + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/banip [scapegoat player]"; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor.isAdmin(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/CancelQuestCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/CancelQuestCommand.java new file mode 100644 index 00000000..699f0b83 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/CancelQuestCommand.java @@ -0,0 +1,98 @@ +package brainwine.gameserver.command.admin; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.PlayerQuests; +import brainwine.gameserver.quest.Quest; +import brainwine.gameserver.quest.Quests; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "cancelquest", description = "Cancel a quest for a specific player or everyone.") +public class CancelQuestCommand extends Command { + @Override + public void execute(CommandExecutor executor, String[] args) { + if(args.length < 2) { + executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM); + return; + } + boolean everyone = args[0].equalsIgnoreCase("(everyone)"); + Player player = null; + if(!everyone) { + player = GameServer.getInstance().getPlayerManager().getPlayer(args[0]); + if(player == null) { + executor.notify("This player does not exist.", SYSTEM); + return; + } + } + + Quest quest = player != null ? Quests.get(player, args[1]) : Quests.get(args[1]); + + if(quest == null) { + String questId = Quests.findQuestIdByTitle(player, args[1]); + if(questId != null) quest = player != null ? Quests.get(player, questId) : Quests.get(questId); + } + + if(quest == null) { + executor.notify("Quest not found!", SYSTEM); + return; + } + + final Quest foundQuest = quest; + final Player singleTarget = player; + + final Runnable task = () -> { + if(everyone) { + int count = 0; + for(Player target : GameServer.getInstance().getPlayerManager().getPlayers()) { + if(target.getQuestProgresses().containsKey(foundQuest.getId())) { + PlayerQuests.cancelQuest(target, foundQuest.getId(), true); + count++; + } + } + executor.notify("Cancelled this quest for " + count + (count == 1 ? "player." : "players."), SYSTEM); + } else { + if(singleTarget.getQuestProgresses().containsKey(foundQuest.getId())) { + PlayerQuests.cancelQuest(singleTarget, foundQuest.getId()); + } else { + executor.notify("Target player doesn't have this quest!", SYSTEM); + } + } + }; + + if(executor instanceof Player) { + Dialog dialog = new Dialog() + .setTitle("Cancelling Quest \"" + quest.getTitle() + "\"") + .addSection(new DialogSection().setText("You will be cancelling " + foundQuest.getTitle() + " (" + foundQuest.getId() + ") for " + (everyone ? "everyone" : singleTarget.getName()) + ". Are you sure?")) + .setActions("yesno"); + + ((Player)executor).showDialog(dialog, ans -> { + if(ans.length == 0 || !"cancel".equals(ans[0])) { + task.run(); + } + }); + } else { + task.run(); + } + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/cancelquest <\"(everyone)\" including the parentheses or player name> "; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor.isAdmin(); + } + + @Override + public boolean useSmartArguments() { + return true; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/EvokeCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/EvokeCommand.java new file mode 100644 index 00000000..2d45cfa7 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/EvokeCommand.java @@ -0,0 +1,82 @@ +package brainwine.gameserver.command.admin; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandAccessLevel; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Zone; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "evoke", description = "Makes brains or revenants invade the personal space of the player!") +public class EvokeCommand extends Command { + @Override + public void execute(CommandExecutor executor, String[] args) { + if(args.length < 1) { + executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM); + return; + } + + Player target = GameServer.getInstance().getPlayerManager().getPlayer(args[0]); + + if(target == null) { + executor.notify("This player does not exist.", NotificationType.POPUP); + return; + } + + if(!target.isOnline()) { + executor.notify(String.format("Player '%s' is not online.", target.getName()), NotificationType.POPUP); + return; + } + + Zone zone = target.getZone(); + int difficulty = zone.getMassSpawnerConfiguration().getDifficulty(); + if(args.length >= 2) { + try { + difficulty = Integer.parseInt(args[1]); + } catch(Exception e) { + executor.notify("Invalid difficulty.", SYSTEM); + return; + } + } + + if(!executor.isAdmin() && !zone.getMassSpawnerConfiguration().isEnabled()) { + executor.notify("No mass spawner is enabled in this world.", NotificationType.POPUP); + return; + } + + if(!isPrivileged(executor, zone, zone.getMassSpawnerConfiguration().getEvokeAccess())) { + executor.notify("You are not allowed to evoke players in this world.", NotificationType.POPUP); + return; + } + + if(System.currentTimeMillis() < zone.getEntityManager().getLastInvasionAt() + 3000) { + executor.notify("You must wait 3 seconds between invasions.", NotificationType.POPUP); + return; + } + + zone.getEntityManager().startInvasion(target, difficulty); + executor.notify("Commencing evocation!", NotificationType.POPUP); + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/evoke "; + } + + private boolean isPrivileged(CommandExecutor executor, Zone zone, CommandAccessLevel needed) { + if(executor == null || zone == null || needed == null) return false; + if(executor instanceof GameServer) return true; + if(executor.isAdmin()) return true; + + Player player = (Player)executor; + if(needed == CommandAccessLevel.OWNERS) return zone.isOwner(player); + if(needed == CommandAccessLevel.MEMBERS) return zone.isOwner(player) || zone.isMember(player); + + return true; + + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/ExploreCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/ExploreCommand.java new file mode 100644 index 00000000..84e45fe4 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/ExploreCommand.java @@ -0,0 +1,103 @@ +package brainwine.gameserver.command.admin; + +import brainwine.gameserver.anticheat.Exploration; +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Zone; + +@CommandInfo(name = "explore", description = "Explores the sky and underground of the zone until reaching a given exploration progress.") +public class ExploreCommand extends Command { + + @Override + public void execute(CommandExecutor executor, String[] args) { + if(args.length > 2) { + executor.notify("Usage: " + getUsage(executor), NotificationType.SYSTEM); + return; + } + if(executor instanceof Player) { + Zone zone = ((Player) executor).getZone(); + float progress = 1.0f; + Exploration.Region region = Exploration.Region.ALL; + + // Parse the arguments. Any of them can be omitted. + boolean argCheck = args.length == 0; + if(args.length == 2) { + try { + progress = parsePercent(args[1]); + region = Exploration.Region.valueOf(args[0].toUpperCase()); + argCheck = true; + } catch(Exception ignored) {} + } + if(args.length == 1) { + try { + progress = parsePercent(args[0]); + argCheck = true; + } catch(Exception ignored) {} + try { + region = Exploration.Region.valueOf(args[0].toUpperCase()); + argCheck = true; + } catch(Exception ignored) {} + } + if(!argCheck) { + executor.notify("Usage: " + getUsage(executor), NotificationType.SYSTEM); + return; + } + + if(progress < -0.01 || progress > 1.01) { + executor.notify("Percent must be between 0.0-1.0 or 0%-100%", NotificationType.SYSTEM); + return; + } + + int skyChunksSeen = 0; + int undergroundChunksSeen = 0; + int skyChunksExplored = 0; + int undergroundChunksExplored = 0; + int chunksExploredNow = 0; + for(int i = 0; i < zone.getNumChunksWidth(); i++) { + int firstUnderground = zone.getSurface()[zone.getChunkWidth() * i + zone.getChunkWidth() / 2] / zone.getChunkHeight(); + if(region == Exploration.Region.ALL || region == Exploration.Region.SKY) for(int j = 0; j < firstUnderground; j++) { + skyChunksSeen++; + if(zone.getChunksExplored()[j * zone.getNumChunksWidth() + i]) skyChunksExplored++; + else if(skyChunksSeen * progress > skyChunksExplored) { + zone.exploreArea(i * zone.getChunkWidth(), j * zone.getChunkHeight(), (Player)executor); + skyChunksExplored++; + chunksExploredNow++; + } + } + if(region == Exploration.Region.ALL || region == Exploration.Region.UNDERGROUND) for(int j = firstUnderground; j < zone.getNumChunksHeight(); j++) { + undergroundChunksSeen++; + if(zone.getChunksExplored()[j * zone.getNumChunksWidth() + i]) undergroundChunksExplored++; + else if(undergroundChunksSeen * progress > undergroundChunksExplored) { + zone.exploreArea(i * zone.getChunkWidth(), j * zone.getChunkHeight(), (Player)executor); + undergroundChunksExplored++; + chunksExploredNow++; + } + } + } + + executor.notify(String.format("%d more chunks explored.", chunksExploredNow), NotificationType.SYSTEM); + zone.recalculateChunksExploredCount(); + } + } + + float parsePercent(String input) throws NumberFormatException { + if(input.contains("%")) { + return Float.parseFloat(input.replaceAll("%", "")) / 100.0f; + } else { + return Float.parseFloat(input); + } + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/explore [all|sky|underground] [0%-100% or 0.0-1.0]"; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor instanceof Player && executor.isAdmin(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/MarketCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/MarketCommand.java new file mode 100644 index 00000000..07a11018 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/MarketCommand.java @@ -0,0 +1,67 @@ +package brainwine.gameserver.command.admin; + +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Zone; +import brainwine.gameserver.zone.ZoneActivity; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "market", description = "Set the market status of the current zone.") +public class MarketCommand extends Command { + @Override + public void execute(CommandExecutor executor, String[] args) { + if(!(executor instanceof Player)) { + executor.notify("Market worlds can only be set up by a player in a zone.", NotificationType.SYSTEM); + return; + } + + Player player = (Player)executor; + + boolean value = true; + + if(args.length >= 1) { + try { + value = Boolean.parseBoolean(args[0]); + } catch(NumberFormatException e) { + executor.notify("First argument must be a boolean indicating whether the current zone should be a market.", SYSTEM); + return; + } + } + + Zone zone = player.getZone(); + + if(zone == null) { + executor.notify("Your zone was not found.", NotificationType.SYSTEM); + return; + } + + if(!value && !zone.isMarket()) { + executor.notify("Zone is already not a market." + + (zone.getActivity() != ZoneActivity.NONE ? " The zone is a " + zone.getActivity() + "." : "") + , NotificationType.SYSTEM); + } else if(value && zone.isMarket()) { + executor.notify("Zone is already a market.", NotificationType.SYSTEM); + } else { + executor.notify(value + ? "Zone is now a market. Trading is possible and further placement of protectors is limited." + : "Zone is now not a market. Protectors in the zone will keep their owners." + , NotificationType.SYSTEM); + } + + zone.setMarket(value); + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/market [true|false]"; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor.isAdmin(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/TeleportCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/TeleportCommand.java index 3f59df01..a39a0f9a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/admin/TeleportCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/TeleportCommand.java @@ -1,127 +1,356 @@ package brainwine.gameserver.command.admin; -import static brainwine.gameserver.player.NotificationType.SYSTEM; - import brainwine.gameserver.GameServer; import brainwine.gameserver.command.Command; import brainwine.gameserver.command.CommandExecutor; import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.player.Player; import brainwine.gameserver.player.PlayerManager; +import brainwine.gameserver.util.MathUtils; +import brainwine.gameserver.util.Vector2i; +import brainwine.gameserver.zone.MetaBlock; import brainwine.gameserver.zone.Zone; +import brainwine.gameserver.zone.ZoneManager; + +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; @CommandInfo(name = "teleport", description = "Teleports you or another player to the specified target position or player.", aliases = "tp") public class TeleportCommand extends Command { - @Override public void execute(CommandExecutor executor, String[] args) { - if(args.length == 0 || args.length > 3) { + if(args.length == 0 || args.length > 4) { executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM); return; } - - PlayerManager playerManager = GameServer.getInstance().getPlayerManager(); + Player player = (Player)executor; - Player subject = player; // Player that is being teleported (executor by default) - Zone targetZone = subject.getZone(); - int x = 0; - int y = 0; - + PlayerManager playerManager = GameServer.getInstance().getPlayerManager(); + ZoneManager zoneManager = GameServer.getInstance().getZoneManager(); + if(args.length == 1) { - // Teleport executor to target player - Player target = playerManager.getPlayer(args[0]); - - if(target == null) { - player.notify(String.format("Player '%s' not found.", args[0])); - return; - } - - if(!target.isOnline()) { - player.notify(String.format("Player '%s' is not online.", target.getName())); - return; - } - - if(subject == target) { - player.notify("You cannot teleport to yourself."); - return; - } - - x = target.getBlockX(); - y = target.getBlockY(); - targetZone = target.getZone(); + teleportToPlayerOrPlaque(player, player, player.getZone(), args[0]); } else if(args.length == 2) { - // Teleport executor to target position OR teleport subject to player try { - x = Integer.parseInt(args[0]); - y = Integer.parseInt(args[1]); + int x = parseXCoordinate(args[0], player.getZone()); + int y = parseYCoordinate(args[1], player.getZone()); + teleportToCoordinates(player, player, player.getZone(), x, y); } catch(NumberFormatException e) { - // If first 2 params are not numbers then we are probably teleporting a player to another player - subject = playerManager.getPlayer(args[0]); // Do null check later - Player target = playerManager.getPlayer(args[1]); - - if(target == null) { - player.notify(String.format("Player '%s' not found.", args[1])); + Player subject = playerManager.getPlayer(args[0]); + if(subject != null) { + teleportToPlayerOrPlaque(player, subject, player.getZone(), args[1]); + } else { + player.notify(String.format("Player '%s' not found.", args[0])); + } + } + } else if(args.length == 3) { + Zone targetZone = null; + Player subject = null; + int x = 0; + int y = 0; + boolean done = false; + + if(!done) { + try { + subject = playerManager.getPlayer(args[0]); + Objects.requireNonNull(subject); + targetZone = player.getZone(); + Objects.requireNonNull(targetZone); + x = parseXCoordinate(args[1], targetZone); + y = parseYCoordinate(args[2], targetZone); + done = true; + } catch (Exception e){ + } + + if(done) { + teleportToCoordinates(player, subject, targetZone, x, y); return; } - - if(!target.isOnline()) { - player.notify(String.format("Player '%s' is not online.", target.getName())); + } + + if(!done) { + try { + targetZone = zoneManager.getZoneByName(args[0]); + Objects.requireNonNull(targetZone); + x = parseXCoordinate(args[1], targetZone); + y = parseYCoordinate(args[2], targetZone); + subject = player; + done = true; + } catch (Exception e){ + } + + if(done) { + teleportToCoordinates(player, subject, targetZone, x, y); return; } - - if(subject == target) { - player.notify("You cannot teleport a player to themselves."); + } + + if(!done) { + try { + subject = playerManager.getPlayer(args[0]); + Objects.requireNonNull(subject); + targetZone = zoneManager.getZoneByName(args[1]); + Objects.requireNonNull(targetZone); + done = true; + } catch(Exception e) { + } + + if(done) { + teleportToPlayerOrPlaque(player, subject, targetZone, args[2]); return; } - - x = target.getBlockX(); - y = target.getBlockY(); - targetZone = target.getZone(); } - } else if(args.length == 3) { - // Teleport subject to a position - subject = playerManager.getPlayer(args[0]); // Do null check later - + + player.notify("Wrong arguments given."); + } else { try { - x = Integer.parseInt(args[1]); - y = Integer.parseInt(args[2]); - } catch(NumberFormatException e) { - player.notify("X and Y must be valid numbers."); + Player subject = playerManager.getPlayer(args[0]); + Objects.requireNonNull(subject); + Zone targetZone = zoneManager.getZoneByName(args[1]); + Objects.requireNonNull(targetZone); + int x = parseXCoordinate(args[2], targetZone); + int y = parseYCoordinate(args[3], targetZone); + + teleportToCoordinates(player, subject, targetZone, x, y); + } catch(Exception e) { + player.notify("Wrong arguments given."); + } + } + } + + private boolean checkSubjectAndZone(Player player, Player subject, Zone targetZone) { + if(targetZone == null) { + player.notify("Sorry, the target world is null."); + return false; + } + + if(!player.isAdmin()) { + if(!targetZone.hasMassTeleporter()) { + player.notify("No mass teleportation machine is operational in this world."); + return false; + } + + if(player != subject) { + player.notify("Only admins can teleport other players."); + return false; + } + + if(subject.getZone() != targetZone || player.getZone() != targetZone) { + player.notify("Sorry, only admins can teleport players out of and across worlds."); + return false; + } + } + + return true; + } + + private void teleportToPlayerOrPlaque(Player player, Player subject, Zone targetZone, String name) { + if(!checkSubjectAndZone(player, subject, targetZone)) return; + + Player target = GameServer.getInstance().getPlayerManager().getPlayer(name); + if(target != null) { + if(!targetZone.getMassTeleporterConfiguration().getTeleportToPlayerAccess().isPrivileged(player, targetZone)) { + player.notify("You are not allowed to teleport to players in the target world."); + return; + } + + if(!target.isOnline()) { + player.notify(String.format("Player '%s' is not online.", target.getName())); + return; + } + + if(subject.getZone() != target.getZone() && !targetZone.getMassTeleporterConfiguration().getSummonOtherPlayerAccess().isPrivileged(player, targetZone)) { + player.notify("You are not allowed to summon other players in this world."); return; } + + if(subject == target) { + player.notify("You cannot teleport a player to themselves."); + return; + } + + doTeleport(player, subject, target.getZone(), (int)target.getX(), (int)target.getY()); + return; + } + + Vector2i targetPosition = this.getLandmarkPosition(targetZone, name); + if(targetPosition != null) { + if(!targetZone.getMassTeleporterConfiguration().getTeleportToPlaqueAccess().isPrivileged(player, targetZone)) { + player.notify("You are not allowed to teleport to plaques in this world."); + return; + } + doTeleport(player, subject, targetZone, targetPosition.getX(), targetPosition.getY()); + return; } - - // Check if subject is present - if(subject == null) { - player.notify(String.format("Player '%s' not found.", args[0])); // Subject is always first parameter so this should be fine + + player.notify(String.format("Player or landmark '%s' not found.", name)); + } + + private void teleportToCoordinates(Player player, Player subject, Zone targetZone, int x, int y) { + if(!player.isAdmin()) { + player.notify("Only admins are allowed to teleport to exact coordinates."); return; } - + + if(!checkSubjectAndZone(player, subject, targetZone)) return; + + doTeleport(player, subject, targetZone, x, y); + } + + private void doTeleport(Player player, Player subject, Zone targetZone, int x, int y) { if(!subject.isOnline()) { player.notify(String.format("Player '%s' is not online.", subject.getName())); return; } - + + if(!player.isAdmin()) { + if(!targetZone.getMassTeleporterConfiguration().isEnabled()) { + player.notify(String.format("There is no mass teleporter enabled in %s.", targetZone.getName())); + return; + } + + if(!targetZone.isAreaExplored(x, y)) { + player.notify("That area hasn't been explored yet."); + return; + } + + if(targetZone.isChunkLoaded(x, y) && (targetZone.isBlockSolid(x, y) || targetZone.isBlockSolid(x, y - 1))) { + player.notify("Teleportation destination is obstructed."); + return; + } + + // We don't consider single blocks to be protected against teleportation. + if(!targetZone.getMassTeleporterConfiguration().getTeleportInProtectedAreaAccess().isPrivileged(player, targetZone) && targetZone.isBlockProtected(x, y, player, true)) { + player.notify("Sorry, you can't teleport to areas protected against you in this world."); + return; + } + } + // Check if coordinates are in bounds if(!targetZone.areCoordinatesInBounds(x, y)) { player.notify("Cannot teleport out of bounds!", SYSTEM); return; } - - if(targetZone == subject.getZone()) { - subject.teleport(x, y); + + final Runnable task = () -> { + if(targetZone == subject.getZone()) { + subject.teleport(x, y); + } else { + targetZone.giveTemporaryAccess(subject); + subject.changeZone(targetZone, x, y); + } + }; + + if(player.isGodMode() || player.equals(subject)) { + task.run(); + } else { + if(subject.getZone().isActionOnCooldown("failed teleport request", 10, ChronoUnit.SECONDS)) { + player.notify("Sorry, further teleport requests have been blocked for 10 seconds.", SYSTEM); + return; + } + + double distance = MathUtils.distance(x, y, player.getX(), player.getY()); + subject.showDialog( + new Dialog() + .setTitle("Teleport Request") + .addSection(new DialogSection().setText( + player.getName() + + " wants to teleport you to " + + targetZone.getReadableCoordinates(x, y) + + (distance <= 5.0 ? " (near themselves)" : "") + + (targetZone == subject.getZone() + ? "." + : " in " + targetZone.getName() + ".") + + " Click OK to accept." + )), + ans -> { + if(ans.length >= 1 && "cancel".equals(ans[0])) { + player.notify(subject.getName() + " has dismissed your teleport request.", SYSTEM); + subject.getZone().recordActionTime("failed teleport request"); + } else { + task.run(); + } + }); + player.notify("Your teleport request has been sent to " + subject.getName(), SYSTEM); + } + } + + private int parseXCoordinate(String value, Zone targetZone) throws NumberFormatException { + return parseNumberWithDirection(value, targetZone.getWidth() / 2, new String[] { "left", "west", "l", "w" }, new String[] { "right", "east", "r", "e" } ); + } + + private int parseYCoordinate(String value, Zone targetZone) throws NumberFormatException { + return parseNumberWithDirection(value, targetZone.getGroundHeight(), new String[] { "above", "up", "a", "u" }, new String[] { "below", "down", "b", "d" } ); + } + + private int parseNumberWithDirection(String value, int offset, String[] lowerDirection, String[] upperDirection) throws NumberFormatException { + int direction = 0; + int unitLength = 0; + + for(String unit : lowerDirection) { + if(value.endsWith(unit)) { + direction = -1; + unitLength = unit.length(); + break; + } + } + + for(String unit : upperDirection) { + if(value.endsWith(unit)) { + direction = 1; + unitLength = unit.length(); + break; + } + } + + if(unitLength == 0) { + return Integer.parseInt(value); } else { - subject.changeZone(targetZone, x, y); + return offset + direction * Integer.parseInt(value.substring(0, value.length() - unitLength)); + } + } + + private Vector2i getLandmarkPosition(Zone zone, String name) { + String landmarkName = name.toLowerCase(); + + int x = -1; + int y = -1; + boolean found = false; + for(MetaBlock metaBlock : zone.getMetaBlocksWithUse(ItemUseType.LANDMARK)) { + boolean thisIsIt = false; + + if(landmarkName.equalsIgnoreCase(metaBlock.getStringProperty("n"))) { + thisIsIt = true; + } + + if(thisIsIt) { + x = metaBlock.getX(); + y = metaBlock.getY(); + found = true; + break; + } } + + return found ? new Vector2i(x, y) : null; } - + @Override public String getUsage(CommandExecutor executor) { - return "/teleport [player] "; + return "/tp [target description]"; } - + @Override public boolean canExecute(CommandExecutor executor) { - return executor instanceof Player && executor.isAdmin(); + return executor instanceof Player; + } + + @Override + public boolean useSmartArguments() { + return true; } } diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/UnbanCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/UnbanCommand.java index 8e2f675c..df4e1ec4 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/admin/UnbanCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/UnbanCommand.java @@ -6,8 +6,11 @@ import brainwine.gameserver.command.Command; import brainwine.gameserver.command.CommandExecutor; import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.NotificationType; import brainwine.gameserver.player.Player; +import java.util.Set; + @CommandInfo(name = "unban", description = "Unbans a player.", aliases = "pardon") public class UnbanCommand extends Command { @@ -31,6 +34,16 @@ public void execute(CommandExecutor executor, String[] args) { } target.unban(executor instanceof Player ? (Player)executor : null); + + Set banBlocks = GameServer.getInstance().getIpBans().unbanAndCheckForCidrBlock(target); + if(!banBlocks.isEmpty()) { + executor.notify( + "Warning: This user might still be indirectly banned due to a IP address block ban. You can unban the following IP addresses to fix this: " + + String.join(", ", banBlocks), + NotificationType.SYSTEM + ); + } + executor.notify(String.format("Player %s has been unbanned.", target.getName()), SYSTEM); } diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/UnbanIpCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/UnbanIpCommand.java new file mode 100644 index 00000000..2245262e --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/UnbanIpCommand.java @@ -0,0 +1,54 @@ +package brainwine.gameserver.command.admin; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.server.IpBans; +import brainwine.gameserver.util.Cidr; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "unbanip", description = "Bans a player from the server.") +public class UnbanIpCommand extends Command { + + @Override + public void execute(CommandExecutor executor, String[] args) { + if(args.length < 1) { + executor.notify(String.format("Usage: %s", getUsage(executor)), SYSTEM); + return; + } + + Cidr target; + try { + target = Cidr.create(args[0]); + } catch(IllegalArgumentException e) { + executor.notify("Provided IP address is not in a valid format. " + e.getMessage(), SYSTEM); + return; + } + + if(!GameServer.getInstance().getIpBans().isCidrBanned(target)) { + IpBans.Item item = GameServer.getInstance().getIpBans().findMatchingIpBan(target); + if(item == null) { + executor.notify("This specific CIDR was not banned. No changes have been made.", SYSTEM); + } else { + executor.notify("This specific CIDR was not banned, however " + item.getIpAddress() + " is. No changes have been made.", SYSTEM); + } + return; + } + + GameServer.getInstance().getIpBans().unbanCidr(target); + + executor.notify(String.format("Players connecting from %s will no longer be kicked or banned.", target), SYSTEM); + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/unbanip "; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor.isAdmin(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/UnexploredCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/UnexploredCommand.java new file mode 100644 index 00000000..c19142c7 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/UnexploredCommand.java @@ -0,0 +1,32 @@ +package brainwine.gameserver.command.admin; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.zone.Zone; + +@CommandInfo(name = "unexplored", description = "Lists the unexplored worlds currently tracked by the automatic world generator.") +public class UnexploredCommand extends Command { + @Override + public void execute(CommandExecutor executor, String[] args) { + executor.notify("Here are the worlds currently awaiting exploration:", NotificationType.SYSTEM); + for(String id : GameServer.getInstance().getZoneManager().getUnexploredZones()) { + Zone zone = GameServer.getInstance().getZoneManager().getZone(id); + if(zone != null) { + executor.notify(zone.getName(), NotificationType.SYSTEM); + } + } + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/unexplored"; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor.isAdmin(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/admin/WorldChangeOwnerCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/admin/WorldChangeOwnerCommand.java new file mode 100644 index 00000000..c1674fa1 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/admin/WorldChangeOwnerCommand.java @@ -0,0 +1,69 @@ +package brainwine.gameserver.command.admin; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Zone; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "wown", description = "Change the owner of the current world.", aliases = {"wowner", "wchown", "chown"}) +public class WorldChangeOwnerCommand extends Command { + @Override + public void execute(CommandExecutor executor, String[] args) { + if(!(executor instanceof Player)) { + return; + } + + if(!executor.isAdmin()) { + executor.notify("You are not authorized to change world owners.", SYSTEM); + return; + } + + if(args.length < 1) { + executor.notify("Usage: " + getUsage(executor), SYSTEM); + return; + } + + Player newOwner; + if("(null)".equals(args[0])) { + newOwner = null; + } else { + newOwner = GameServer.getInstance().getPlayerManager().getPlayer(args[0]); + if(newOwner == null) { + executor.notify("This player does not exist.", SYSTEM); + return; + } + } + + Zone targetZone = ((Player)executor).getZone(); + if(targetZone == null) { + executor.notify("Zone not found.", SYSTEM); + return; + } + + Player oldOwner = GameServer.getInstance().getPlayerManager().getPlayerById(targetZone.getOwner()); + + if(oldOwner != null && oldOwner.isOnline()) { + oldOwner.notify("You no longer own the world " + targetZone.getName() + "."); + } + + if(newOwner != null && newOwner.isOnline()) { + newOwner.notify("You now own the world " + targetZone.getName() + "."); + } + + targetZone.setOwner(newOwner); + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/wown "; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor.isAdmin() && executor instanceof Player; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldCleanCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldCleanCommand.java new file mode 100644 index 00000000..ddbd8a12 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldCleanCommand.java @@ -0,0 +1,208 @@ +package brainwine.gameserver.command.world; + +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.input.DialogSelectInput; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Biome; +import brainwine.gameserver.zone.Block; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.time.temporal.ChronoUnit; +import java.util.function.BiConsumer; + +@CommandInfo(name = "wclean", description = "Clean the current world from blocks.") +public class WorldCleanCommand extends WorldCommand { + private static final Logger logger = LogManager.getLogger(); + public static final String ACTION_ID = "wclean"; + + @Override + public void execute(final Zone zone, Player player, String[] args) { + if(!checkArgumentCount(player, args, 1)) { + return; + } + + if(!player.isGodMode() && zone.isActionOnCooldown(ACTION_ID, 1, ChronoUnit.DAYS)) { + player.notify("Sorry, you can clean your world only once a day."); + return; + } + + if("junk".equals(args[0])) { + Dialog dialog = DialogHelper.messageDialog( + "Clean-Up Confirmation", + "This action will clean up all unprotected dirt, sandstone, " + + "and limestone without a backdrop in your zone. Are you sure you would like to proceed?" + ); + for(DialogSection section : getForm()) { + dialog.addSection(section); + } + player.showDialog(dialog, ans -> followUpJunk(player, zone, ans)); + } + + if("all".equals(args[0])) { + Dialog dialog = DialogHelper.messageDialog( + "Clean-Up Confirmation", + "This action will remove all blocks " + + "except those in the bedrock layer. Are you sure you would like to proceed?" + ); + for(DialogSection section : getForm()) { + dialog.addSection(section); + } + player.showDialog(dialog, ans -> followUpAll(player, zone, ans)); + } + } + + private List getForm() { + List result = new ArrayList<>(); + + result.add(new DialogSection().setText("Should these blocks remain in player-protected areas? ").setInput(new DialogSelectInput().setOptions("Yes", "No").setKey("player-protected-areas"))); + result.add(new DialogSection().setText("Should these blocks remain in naturally-protected areas? ").setInput(new DialogSelectInput().setOptions("Yes", "No").setKey("naturally-protected-areas"))); + + return result; + } + + private boolean shouldKeepPlayerProtectedAreas(Object[] ans) { + return "Yes".equals(ans[0]); + } + + private boolean shouldKeepNaturallyProtectedAreas(Object[] ans) { + return "Yes".equals(ans[1]); + } + + private List getProtectors(Zone zone, Object[] ans) { + final boolean playerProtectedAreas = shouldKeepPlayerProtectedAreas(ans); + final boolean naturallyProtectedAreas = shouldKeepNaturallyProtectedAreas(ans); + return zone.getMetaBlocks(m -> + playerProtectedAreas && m.getOwner() != null || + naturallyProtectedAreas && m.getOwner() == null + ); + } + + private boolean cancelled(Object[] ans) { + return ans.length >= 1 && "cancel".equals(ans[0]); + } + + private void followUpJunk(Player player, Zone zone, Object[] ans) { + if(cancelled(ans)) { + return; + } + zone.freeze(); + + new Thread(() -> { + try { + final List protectors = getProtectors(zone, ans); + transformBlocks(zone, (x, y) -> { + Block block = zone.getBlock(x, y); + if(block.getBaseItem().isAir() && block.getBackItem().isAir()) { + int code = block.getFrontItem().getCode(); + if(code == 510 || code == 511 || code == 512) { + if(!zone.isBlockProtected(x, y, null, protectors)) { + zone.updateBlock(x, y, Layer.FRONT, Item.AIR); + } + } + } + }); + player.notify("Zone " + zone.getName() + " is now cleaned up.", NotificationType.SYSTEM); + } catch (Exception e) { + logger.error(e); + player.notify("There has been an error during cleanup of zone " + zone.getName(), NotificationType.SYSTEM); + } finally { + zone.thaw(); + zone.recordActionTime(ACTION_ID); + } + }).start(); + } + + // followUpAll has a lot more mitigation than followUpJunk even if the implementations look similar, + // so be mindful when refactoring. + private void followUpAll(Player player, Zone zone, Object[] ans) { + if(cancelled(ans)) { + return; + } + + if(!player.isGodMode() && zone.getMetaBlocks().stream() + .anyMatch(p -> p.getItem().hasField() && p.hasOwner() && !p.isOwnedBy(player))) { + player.showDialog(DialogHelper.messageDialog( + "Cannot Clean Zone", + "This zone contains protectors that belong to other players. " + + "Please tell them to remove their protectors first or ask an admin for help." + )); + return; + } + + zone.freeze(); + + new Thread(() -> { + try { + final List protectors = getProtectors(zone, ans); + final boolean naturallyProtectedAreas = shouldKeepNaturallyProtectedAreas(ans); + transformBlocks(zone, (x, y) -> { + // Do not delete bedrock layers. + if(y < zone.getHeight() - 1 && (zone.getBiome() != Biome.DEEP || y > 0)) { + Item frontItem = zone.getBlock(x, y).getFrontItem(); + MetaBlock metaBlock = zone.getMetaBlock(x, y); + + // We need to delete natural protectors to prevent players from raiding dug out dungeons. + if( + ( + !naturallyProtectedAreas + && !frontItem.hasUse(ItemUseType.ZONE_TELEPORT) + && frontItem.hasField() + && metaBlock != null + && !metaBlock.hasOwner() + ) + || !zone.isBlockProtected(x, y, null, protectors) + ) { + zone.updateBlock(x, y, Layer.BASE, Item.AIR); + zone.updateBlock(x, y, Layer.BACK, Item.AIR); + zone.updateBlock(x, y, Layer.FRONT, Item.AIR); + zone.updateBlock(x, y, Layer.LIQUID, Item.AIR); + } + } + }); + Arrays.fill(zone.getChunksExplored(), true); + player.notify("Zone " + zone.getName() + " is now cleaned up.", NotificationType.SYSTEM); + } catch(Exception e) { + logger.error(e); + player.notify("There has been an error during cleanup of zone " + zone.getName(), NotificationType.SYSTEM); + } finally { + zone.thaw(); + zone.recordActionTime(ACTION_ID); + } + }).start(); + } + + private void transformBlocks(Zone zone, BiConsumer transformer) { + int width = zone.getWidth(); + int height = zone.getHeight(); + for(int ci = 0; ci < width; ci += zone.getChunkWidth()) { + for(int cj = 0; cj < height; cj += zone.getChunkHeight()) { + for(int i = ci; i < ci + zone.getChunkWidth(); i++) { + for (int j = cj; j < cj + zone.getChunkHeight(); j++) { + transformer.accept(i, j); + } + } + } + } + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/wclean [junk|all]"; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldMachineCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldMachineCommand.java new file mode 100644 index 00000000..97e9bae4 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldMachineCommand.java @@ -0,0 +1,58 @@ +package brainwine.gameserver.command.world; + +import brainwine.gameserver.command.Command; +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.WorldMachineConfiguration; +import brainwine.gameserver.zone.Zone; + +@CommandInfo(name = "wmachine", description = "Bring up the world machine configuration menu.") +public class WorldMachineCommand extends Command { + @Override + public void execute(CommandExecutor executor, String[] args) { + if(!(executor instanceof Player)) return; + Player player = (Player)executor; + Zone zone = player.getZone(); + if(zone == null) return; + + if(!checkArgumentCount(player, args, 1)) { + return; + } + + WorldMachineConfiguration machine = null; + switch(args[0]) { + case "spawn": + case "spawner": + machine = zone.getMassSpawnerConfiguration(); + break; + case "teleport": + case "teleporter": + machine = zone.getMassTeleporterConfiguration(); + break; + case "weather": + machine = zone.getWeatherMachineConfiguration(); + break; + case "holo": + case "holograph": + machine = zone.getHolographConfiguration(); + break; + default: + player.notify("World machine configuration for use " + args[0] + " not found.", NotificationType.SYSTEM); + return; + } + + machine.configure(player, zone, Float.POSITIVE_INFINITY, -1, -1); + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/wmachine [type]"; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor.isAdmin() && executor instanceof Player; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldPublicCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldPublicCommand.java index 6a415375..30e26d60 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldPublicCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldPublicCommand.java @@ -11,7 +11,7 @@ public class WorldPublicCommand extends WorldCommand { public static final String ACTION_ID = "wpublic"; - + @Override public void execute(Zone zone, Player player, String[] args) { if(!checkArgumentCount(player, args, 1)) { @@ -23,7 +23,7 @@ public void execute(Zone zone, Player player, String[] args) { player.notify("Sorry, you can toggle accessibility only once an hour."); return; } - + if(!args[0].equalsIgnoreCase("on") && !args[0].equalsIgnoreCase("off")) { sendUsageMessage(player); return; diff --git a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldPvpCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldPvpCommand.java index bca69332..b8b5c0a9 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldPvpCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldPvpCommand.java @@ -11,7 +11,7 @@ public class WorldPvpCommand extends WorldCommand { public static final String ACTION_ID = "wpvp"; - + @Override public void execute(Zone zone, Player player, String[] args) { if(!checkArgumentCount(player, args, 1)) { @@ -23,7 +23,7 @@ public void execute(Zone zone, Player player, String[] args) { player.notify("Sorry, you can toggle PvP only once an hour."); return; } - + if(!args[0].equalsIgnoreCase("on") && !args[0].equalsIgnoreCase("off")) { sendUsageMessage(player); return; diff --git a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldRenameCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldRenameCommand.java index 4bd23488..d16d61da 100644 --- a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldRenameCommand.java +++ b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldRenameCommand.java @@ -23,13 +23,13 @@ public void execute(Zone zone, Player player, String[] args) { if(!checkArgumentCount(player, args, 1)) { return; } - + // Check if command is on cooldown if(!player.isGodMode() && zone.isActionOnCooldown(ACTION_ID, 1, ChronoUnit.DAYS)) { player.notify("Sorry, you can rename your world only once a day."); return; } - + ZoneManager zoneManager = GameServer.getInstance().getZoneManager(); String name = String.join(" ", args).trim().replaceAll(" +", " "); @@ -56,7 +56,7 @@ public void execute(Zone zone, Player player, String[] args) { player.notify("An unexpected problem occured while renaming your world.", SYSTEM); return; } - + zone.recordActionTime(ACTION_ID); } diff --git a/gameserver/src/main/java/brainwine/gameserver/command/world/WorldRuleCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldRuleCommand.java new file mode 100644 index 00000000..b2604668 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/world/WorldRuleCommand.java @@ -0,0 +1,56 @@ +package brainwine.gameserver.command.world; + +import brainwine.gameserver.command.CommandExecutor; +import brainwine.gameserver.command.CommandInfo; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Zone; +import org.apache.commons.lang3.math.NumberUtils; + +import java.util.List; + +import static brainwine.gameserver.player.NotificationType.SYSTEM; + +@CommandInfo(name = "wrule", description = "Set world mechanics rules.") +public class WorldRuleCommand extends WorldCommand { + @Override + public void execute(Zone zone, Player player, String[] args) { + if(!checkArgumentCount(player, args, 0, 1, 2)) { + return; + } + + if(args.length == 2) { + String message = zone.getRules().setRule(player, args[0], args[1]); + + player.notify(message == null ? "Successfully set rule" : message, SYSTEM); + } else { + List rules = zone.getRules().getRules(player); + int pageSize = 10; + int pageCount = (int)Math.ceil((double)rules.size() / pageSize); + + int page = 1; + + if(args.length == 1) { + if(NumberUtils.isDigits(args[0])) { + page = Math.max(1, Math.min(pageCount, Integer.parseInt(args[0]))); + } else { + player.notify(zone.getRules().getRule(player, args[0]), SYSTEM); + return; + } + } + + int fromIndex = (page - 1) * pageSize; + int toIndex = Math.min(page * pageSize, rules.size()); + List rulesToDisplay = rules.subList(fromIndex, toIndex); + player.notify(String.format("========== Command List (Page %s of %s) ==========", page, pageCount), SYSTEM); + + for(String rule : rulesToDisplay) { + player.notify("/wrule " + rule, SYSTEM); + } + } + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/wrule [key] [value]"; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/dialog/DialogListItem.java b/gameserver/src/main/java/brainwine/gameserver/dialog/DialogListItem.java index 84df0678..4c1ec58f 100644 --- a/gameserver/src/main/java/brainwine/gameserver/dialog/DialogListItem.java +++ b/gameserver/src/main/java/brainwine/gameserver/dialog/DialogListItem.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; @JsonInclude(Include.NON_DEFAULT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -11,6 +12,7 @@ public class DialogListItem { private String text; private String image; private int item; + private boolean supportRichText; public DialogListItem setText(String text) { this.text = text; @@ -38,4 +40,14 @@ public DialogListItem setItem(int item) { public int getItem() { return item; } + + @JsonProperty(value="supportRichText") + public boolean isSupportRichText() { + return supportRichText; + } + + public DialogListItem setSupportRichText(boolean supportsRichText) { + this.supportRichText = supportsRichText; + return this; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/dialog/DialogSection.java b/gameserver/src/main/java/brainwine/gameserver/dialog/DialogSection.java index 813795b3..7cc342f9 100644 --- a/gameserver/src/main/java/brainwine/gameserver/dialog/DialogSection.java +++ b/gameserver/src/main/java/brainwine/gameserver/dialog/DialogSection.java @@ -20,11 +20,11 @@ public class DialogSection { private List items = new ArrayList<>(); private String title; private String text; - private String choice; private String textColor; private double textScale; private Vector2i location; private DialogInput input; + private String choice; public DialogSection addItem(DialogListItem item) { items.add(item); @@ -54,15 +54,6 @@ public String getText() { return text; } - public DialogSection setChoice(String choice) { - this.choice = choice; - return this; - } - - public String getChoice() { - return choice; - } - /** * v2 clients only! * For v3 clients, use {@link #setText} with HTML color tags. @@ -110,4 +101,13 @@ public DialogSection setInput(DialogInput input) { public DialogInput getInput() { return input; } + + public DialogSection setChoice(String choice) { + this.choice = choice; + return this; + } + + public String getChoice() { + return this.choice; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogInput.java b/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogInput.java index 21eeaa4d..10ec7c9c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogInput.java +++ b/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogInput.java @@ -13,6 +13,7 @@ @Type(name = "text", value = DialogTextInput.class), @Type(name = "item", value = DialogItemInput.class), @Type(name = "color", value = DialogColorInput.class), + @Type(names = "text index", value = DialogTextIndexInput.class), @Type(names = {"text select", "select"}, value = DialogSelectInput.class) }) @JsonInclude(Include.NON_DEFAULT) @@ -20,6 +21,7 @@ public abstract class DialogInput { protected String key; + public Object value; public DialogInput setKey(String key) { this.key = key; @@ -29,4 +31,13 @@ public DialogInput setKey(String key) { public String getKey() { return key; } + + public Object getValue() { + return value; + } + + public DialogInput setValue(Object value) { + this.value = value; + return this; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogSelectInput.java b/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogSelectInput.java index 39ce7672..fbc6e702 100644 --- a/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogSelectInput.java +++ b/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogSelectInput.java @@ -13,7 +13,8 @@ public class DialogSelectInput extends DialogInput { public DialogSelectInput setOptions(String... options) { return setOptions(Arrays.asList(options)); } - + + @JsonProperty("options") public DialogSelectInput setOptions(Collection options) { this.options = options; return this; diff --git a/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogTextIndexInput.java b/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogTextIndexInput.java new file mode 100644 index 00000000..afbeafbb --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/dialog/input/DialogTextIndexInput.java @@ -0,0 +1,3 @@ +package brainwine.gameserver.dialog.input; + +public class DialogTextIndexInput extends DialogSelectInput {} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java b/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java index a4775d86..0cf42005 100644 --- a/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java +++ b/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java @@ -12,6 +12,7 @@ import brainwine.gameserver.item.Layer; import brainwine.gameserver.minigame.Minigame; import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.QuestEvents; import brainwine.gameserver.server.Message; import brainwine.gameserver.server.messages.EffectMessage; import brainwine.gameserver.server.messages.EntityChangeMessage; @@ -107,12 +108,22 @@ public void attack(Entity attacker, Item weapon, float baseDamage, DamageType da // Kill entity if attacker is a player in god mode if(attacker != null && attacker.isPlayer() && ((Player)attacker).isGodMode()) { setHealth(0.0F); + + if(isDead()) { + QuestEvents.handleKill((Player) attacker, this); + } + return; } // Ignore multipliers if true damage should be dealt if(trueDamage) { setHealth(health - baseDamage); + + if(isDead() && attacker != null && attacker.isPlayer()) { + QuestEvents.handleKill((Player) attacker, this); + } + return; } @@ -120,6 +131,10 @@ public void attack(Entity attacker, Item weapon, float baseDamage, DamageType da float defense = Math.max(0.0F, 1.0F - getDefense(attack)); float damage = baseDamage * attackMultiplier * defense; setHealth(health - damage); + + if(isDead() && attacker != null && attacker.isPlayer()) { + QuestEvents.handleKill((Player) attacker, this); + } } public float getAttackMultiplier(EntityAttack attack) { diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/EntityConfig.java b/gameserver/src/main/java/brainwine/gameserver/entity/EntityConfig.java index ba6c7fb4..466b0671 100644 --- a/gameserver/src/main/java/brainwine/gameserver/entity/EntityConfig.java +++ b/gameserver/src/main/java/brainwine/gameserver/entity/EntityConfig.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSetter; @@ -32,6 +33,7 @@ public class EntityConfig { private float baseSpeed = 3; private boolean character; private boolean human; + private boolean friendly; private boolean named; private boolean trappable; private Item trappablePetItem; @@ -73,7 +75,15 @@ public int getType() { public String getTitle() { return title; } + + @JsonIgnore + public String getCategory() { + String id = getName(); + int index = id.indexOf('/'); + return index > 1 ? id.substring(0, index) : null; + } + @JsonProperty("xp") public int getExperienceYield() { return experienceYield; @@ -100,7 +110,11 @@ public boolean isHuman() { public boolean isNamed() { return named; } - + + public boolean isFriendly() { + return friendly; + } + public boolean isTrappable() { return trappable; } diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/Npc.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/Npc.java index e60ed8c0..eb35ba06 100644 --- a/gameserver/src/main/java/brainwine/gameserver/entity/npc/Npc.java +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/Npc.java @@ -16,7 +16,9 @@ import brainwine.gameserver.entity.EntityLoot; import brainwine.gameserver.entity.EntityRegistry; import brainwine.gameserver.entity.FacingDirection; +import brainwine.gameserver.entity.npc.behavior.BehaviorMessage; import brainwine.gameserver.entity.npc.behavior.SequenceBehavior; +import brainwine.gameserver.entity.npc.job.JobType; import brainwine.gameserver.item.DamageType; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.Layer; @@ -53,6 +55,7 @@ public class Npc extends Entity { private Entity owner; private Entity target; private boolean artificial; + private JobType job; private long lastBehavedAt = System.currentTimeMillis(); private long lastTrackedAt = System.currentTimeMillis(); @@ -167,6 +170,10 @@ public void die(EntityAttack cause) { if(guards != null) { guards.remove(typeName); + + if(guards.isEmpty()) { + zone.getEntityManager().updateRevenantDish(guardBlock.getX(), guardBlock.getY(), false); + } } } } @@ -198,14 +205,16 @@ public void die(EntityAttack cause) { // Track kill player.getStatistics().trackKill(config); } - - EntityLoot loot = getRandomLoot(player, cause.getWeapon()); - - if(loot != null) { - Item item = loot.getItem(); - - if(!item.isAir()) { - player.getInventory().addItem(item, loot.getQuantity(), true); + + if(zone != null && zone.entityShouldDrop()) { + EntityLoot loot = getRandomLoot(player, cause.getWeapon()); + + if (loot != null) { + Item item = loot.getItem(); + + if (!item.isAir()) { + player.getInventory().addItem(item, loot.getQuantity(), true); + } } } } @@ -252,6 +261,14 @@ public Map getStatusConfig() { return config; } + + public void interact(Player player, Object... data) { + interact(BehaviorMessage.INTERACT, player, data); + } + + public void interact(BehaviorMessage message, Player player, Object... data) { + behaviorTree.react(message, player, data); + } public void move(int x, int y) { move(x, y, baseSpeed); @@ -420,6 +437,14 @@ public int getMoveX() { public int getMoveY() { return moveY; } + + public JobType getJob() { + return job; + } + + public void setJob(JobType job) { + this.job = job; + } public long getLastTrackedAt() { return lastTrackedAt; diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/NpcData.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/NpcData.java index 1d173303..fdf5866b 100644 --- a/gameserver/src/main/java/brainwine/gameserver/entity/npc/NpcData.java +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/NpcData.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import brainwine.gameserver.entity.EntityConfig; +import brainwine.gameserver.entity.npc.job.JobType; /** * Storage data for persistent non-player characters. @@ -16,6 +17,7 @@ public class NpcData { private String name; private int x; private int y; + private JobType job; @JsonCreator public NpcData(@JsonProperty(value = "type", required = true) EntityConfig type) { @@ -27,6 +29,7 @@ public NpcData(Npc npc) { this.name = npc.getName(); this.x = npc.getBlockX(); this.y = npc.getBlockY(); + this.job = npc.getJob(); } public EntityConfig getType() { @@ -44,4 +47,8 @@ public int getX() { public int getY() { return y; } + + public JobType getJob() { + return job; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/Behavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/Behavior.java index 21756c16..a0b6419f 100644 --- a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/Behavior.java +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/Behavior.java @@ -11,16 +11,22 @@ import brainwine.gameserver.entity.npc.behavior.composed.CrawlerBehavior; import brainwine.gameserver.entity.npc.behavior.composed.DiggerBehavior; import brainwine.gameserver.entity.npc.behavior.composed.FlyerBehavior; +import brainwine.gameserver.entity.npc.behavior.composed.HomingFlyerBehavior; +import brainwine.gameserver.entity.npc.behavior.composed.QuesterBehavior; import brainwine.gameserver.entity.npc.behavior.composed.WalkerBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.ChatterBehavior; import brainwine.gameserver.entity.npc.behavior.parts.ClimbBehavior; import brainwine.gameserver.entity.npc.behavior.parts.ConveyorBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.DialoguerBehavior; import brainwine.gameserver.entity.npc.behavior.parts.DigBehavior; import brainwine.gameserver.entity.npc.behavior.parts.EruptionAttackBehavior; import brainwine.gameserver.entity.npc.behavior.parts.FallBehavior; import brainwine.gameserver.entity.npc.behavior.parts.FlyBehavior; import brainwine.gameserver.entity.npc.behavior.parts.FlyTowardBehavior; import brainwine.gameserver.entity.npc.behavior.parts.FollowBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.HomingBehavior; import brainwine.gameserver.entity.npc.behavior.parts.IdleBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.PetBehavior; import brainwine.gameserver.entity.npc.behavior.parts.RandomlyTargetBehavior; import brainwine.gameserver.entity.npc.behavior.parts.ReporterBehavior; import brainwine.gameserver.entity.npc.behavior.parts.ShielderBehavior; @@ -28,6 +34,7 @@ import brainwine.gameserver.entity.npc.behavior.parts.TurnBehavior; import brainwine.gameserver.entity.npc.behavior.parts.UnblockBehavior; import brainwine.gameserver.entity.npc.behavior.parts.WalkBehavior; +import brainwine.gameserver.player.Player; /** * Heavily based on Deepworld's original "rubyhave" (ha ha very punny) behavior system. @@ -41,7 +48,9 @@ @Type(name = "walker", value = WalkerBehavior.class), @Type(name = "crawler", value = CrawlerBehavior.class), @Type(name = "flyer", value = FlyerBehavior.class), + @Type(name = "homing_flyer", value = HomingFlyerBehavior.class), @Type(name = "digger", value = DiggerBehavior.class), + @Type(name = "quester", value = QuesterBehavior.class), // Parts @Type(name = "idle", value = IdleBehavior.class), @Type(name = "walk", value = WalkBehavior.class), @@ -53,12 +62,16 @@ @Type(name = "dig", value = DigBehavior.class), @Type(name = "fly", value = FlyBehavior.class), @Type(name = "fly_toward", value = FlyTowardBehavior.class), + @Type(name = "homing", value = HomingBehavior.class), @Type(name = "shielder", value = ShielderBehavior.class), @Type(name = "spawn_attack", value = SpawnAttackBehavior.class), @Type(name = "eruption_attack", value = EruptionAttackBehavior.class), @Type(name = "randomly_target", value = RandomlyTargetBehavior.class), @Type(name = "reporter", value = ReporterBehavior.class), - @Type(name = "unblock", value = UnblockBehavior.class) + @Type(name = "unblock", value = UnblockBehavior.class), + @Type(name = "pet", value = PetBehavior.class), + @Type(name = "chatter", value = ChatterBehavior.class), + @Type(name = "dialoguer", value = DialoguerBehavior.class) }) @JsonIgnoreProperties(ignoreUnknown = true) public abstract class Behavior { @@ -70,8 +83,17 @@ public Behavior(Npc entity) { } public abstract boolean behave(); + + public final void react(BehaviorMessage message, Object... data) { + react(message, null, data); + } + + public void react(BehaviorMessage message, Player player, Object... data) { + // Override + } public boolean canBehave() { return true; } + } diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/BehaviorMessage.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/BehaviorMessage.java new file mode 100644 index 00000000..33760578 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/BehaviorMessage.java @@ -0,0 +1,12 @@ +package brainwine.gameserver.entity.npc.behavior; + +/** + * External events that can influence NPC behavior. + */ +public enum BehaviorMessage { + + ANGER, + DAMAGE, + DEATH, + INTERACT +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SelectorBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SelectorBehavior.java index 85bbbb64..b058fbfa 100644 --- a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SelectorBehavior.java +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SelectorBehavior.java @@ -3,6 +3,7 @@ import java.util.Map; import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.player.Player; public class SelectorBehavior extends CompositeBehavior { @@ -24,4 +25,12 @@ public boolean behave() { return false; } + + @Override + public void react(BehaviorMessage message, Player player, Object... data) { + for(Behavior child : children) { + child.react(message, player, data); + } + } + } diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SequenceBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SequenceBehavior.java index 0999021a..a587953d 100644 --- a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SequenceBehavior.java +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/SequenceBehavior.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; +import brainwine.gameserver.player.Player; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -63,4 +64,12 @@ public boolean behave() { return true; } + + @Override + public void react(BehaviorMessage message, Player player, Object... data) { + for(Behavior child : children) { + child.react(message, player, data); + } + } + } diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/HomingFlyerBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/HomingFlyerBehavior.java new file mode 100644 index 00000000..e9fd3733 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/HomingFlyerBehavior.java @@ -0,0 +1,33 @@ +package brainwine.gameserver.entity.npc.behavior.composed; + +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.behavior.SelectorBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.FlyBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.FlyTowardBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.HomingBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.IdleBehavior; +import brainwine.gameserver.util.MapHelper; +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.Map; + +public class HomingFlyerBehavior extends SelectorBehavior { + + @JsonCreator + private HomingFlyerBehavior(@JacksonInject Npc entity, + Map config) { + super(entity, config); + } + + @Override + public void addChildren(Map config) { + if(config.containsKey("idle")) { + addChild(IdleBehavior.class, MapHelper.getMap(config, "idle")); + } + + addChild(HomingBehavior.class, config); + addChild(FlyTowardBehavior.class, config); + addChild(FlyBehavior.class, config); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/QuesterBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/QuesterBehavior.java new file mode 100644 index 00000000..afc0763e --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/composed/QuesterBehavior.java @@ -0,0 +1,35 @@ +package brainwine.gameserver.entity.npc.behavior.composed; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonCreator; + +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.behavior.SelectorBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.ChatterBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.DialoguerBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.FallBehavior; +import brainwine.gameserver.entity.npc.behavior.parts.IdleBehavior; + +public class QuesterBehavior extends SelectorBehavior { + + @JsonCreator + private QuesterBehavior(@JacksonInject Npc entity, + Map config) { + super(entity, config); + } + + public QuesterBehavior(Npc entity) { + super(entity); + } + + @Override + public void addChildren(Map config) { + addChild(FallBehavior.class, config); + addChild(DialoguerBehavior.class, config); + addChild(ChatterBehavior.class, config); + addChild(IdleBehavior.class, config); + addChild(WalkerBehavior.class, config); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ChatterBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ChatterBehavior.java new file mode 100644 index 00000000..8dae0662 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/ChatterBehavior.java @@ -0,0 +1,60 @@ +package brainwine.gameserver.entity.npc.behavior.parts; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonCreator; + +import brainwine.gameserver.Fake; +import brainwine.gameserver.entity.FacingDirection; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.behavior.Behavior; +import brainwine.gameserver.player.Player; + +public class ChatterBehavior extends Behavior { + + public static final long BEHAVIOR_COOLDOWN = 6000; + public static final long CHAT_COOLDOWN = 60000; + private final Map recentChats = new HashMap<>(); + private String nextMessage; + private long nextMessageAt; + private long lastChattedAt; + + @JsonCreator + public ChatterBehavior(@JacksonInject Npc entity) { + super(entity); + } + + @Override + public boolean behave() { + long now = System.currentTimeMillis(); + + // Emote the next message if it is ready + if(nextMessage != null && now >= nextMessageAt) { + entity.emote(nextMessage); + nextMessage = null; + } + + // Stop for a little while if the entity has chatted recently + if(now < lastChattedAt + BEHAVIOR_COOLDOWN) { + return true; + } + + recentChats.values().removeIf(x -> now > x + CHAT_COOLDOWN); // Clear expired entries + Player player = entity.getZone().getRandomPlayerInRange(entity.getX(), entity.getY(), 3); + + // Fail if no player is nearby or entity has chatted with target player recently + if(player == null || recentChats.containsKey(player)) { + return false; + } + + nextMessage = Fake.get(Fake.Type.SALUTATION); + nextMessageAt = now + 1000; // Give the entity some time to stop moving + lastChattedAt = now; + recentChats.put(player, lastChattedAt); + entity.setDirection(entity.getX() > player.getX() ? FacingDirection.WEST : FacingDirection.EAST); // Face entity towards the player + entity.setAnimation(0); + return true; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/DialoguerBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/DialoguerBehavior.java new file mode 100644 index 00000000..992ed71d --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/DialoguerBehavior.java @@ -0,0 +1,159 @@ +package brainwine.gameserver.entity.npc.behavior.parts; + +import java.util.List; +import java.util.Map; + +import brainwine.gameserver.dialog.DialogType; +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.core.JsonProcessingException; + +import brainwine.gameserver.Fake; +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.behavior.Behavior; +import brainwine.gameserver.entity.npc.behavior.BehaviorMessage; +import brainwine.gameserver.entity.npc.job.DialoguerJob; +import brainwine.gameserver.entity.npc.job.JobType; +import brainwine.gameserver.entity.npc.job.jobs.Crafter; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.EntityChangeMessage; +import brainwine.gameserver.util.MapHelper; +import brainwine.shared.JsonHelper; + +public class DialoguerBehavior extends Behavior { + + private static final long NEXT_DIALOGUE_OVERALL_MS = 10_000; + + private long mostRecentDialogueAt = System.currentTimeMillis() - 24 * 60 * 60 * 1000; + + @JsonCreator + public DialoguerBehavior(@JacksonInject Npc entity) { + super(entity); + } + + @Override + public boolean behave() { + if (System.currentTimeMillis() < mostRecentDialogueAt + NEXT_DIALOGUE_OVERALL_MS) { + entity.setAnimation(0); + return true; + } else { + return false; + } + } + + @Override + public void react(BehaviorMessage message, Player player, Object... data) { + mostRecentDialogueAt = System.currentTimeMillis(); + + switch(message) { + case INTERACT: + if(data.length <= 1) { + if(entity.getJob() != null) { + entity.getJob().dialogue(entity, player); + return; + } + + if(player.isAdmin()) { + DialoguerJob.CONFIGURATION_ONLY.dialogue(entity, player); + } else { + // Respond with a random bogus message for now + + String[] responses = { + "Error: Job module not found.", + "I am not quite ready for that yet.", + "Sorry, please try again later.", + "Query returned error 404.", + "Critical error.", + "Does not compute." + }; + + String response = responses[(int)(Math.random() * responses.length)]; + entity.emote(response); + } + } else { + switch((String) data[0]) { + case "item": + int itemId = (int) data[1]; + Item item = Item.get(itemId); + + if (item == null) { + return; + } + + if (item.hasUse(ItemUseType.MEMORY)) { + loadMemory(player, item); + } else { + if(entity.getJob() != null) { + entity.getJob().get().acceptItem(entity, player, item); + } + } + + return; + default: + return; + } + } + break; + default: + break; + } + } + + public void loadMemory(Player player, Item item) { + if(entity.getJob() == null) { + if(player.getZone().isBlockProtected(entity.getBlockX(), entity.getBlockY(), player)) { + String message = MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.protected"); + player.showDialog(DialogHelper.messageDialog(message)); + } else { + Integer memoryType = (Integer)item.getUse(ItemUseType.MEMORY); + Map dialogDesc = (Map)MapHelper.getList(GameConfiguration.getBaseConfig(), "dialogs.android.load_memory").get(memoryType); + try { + Dialog dialog = JsonHelper.readValue(dialogDesc, Dialog.class); + + player.showDialog(dialog, ans -> { + if(ans.length >= 1 && "cancel".equals(ans[0])) { + return; + } + + if(ans.length >= 1) { + String entityName = (String)ans[0]; + + // remove the memory unit from the player's inventory + player.getInventory().removeItem(item, true); + + // set entity parameters + entity.setName(entityName); + + if(entityName.toLowerCase().startsWith("victoria")) { + entity.setJob(JobType.FAMILY_NAME); + } else if(entityName.toLowerCase().startsWith("giovanni")) { + entity.setJob(JobType.CRAFTER); + } else if(entityName.toLowerCase().startsWith("bert")) { + entity.setJob(JobType.TRADER); + } else { + entity.setJob(JobType.QUESTER); + } + + // notify the player + player.notify(String.format("Android has been reconfigured as %s!", entity.getName())); + + // update entity name on client side + entity.getZone().sendMessage(new EntityChangeMessage(entity.getId(), MapHelper.map("n", entity.getName()))); + } + }); + } catch (JsonProcessingException e) { + e.printStackTrace(); + player.showDialog(DialogHelper.messageDialog("Could not load the memory load dialog.")); + } + } + } else { + List options = MapHelper.getList(GameConfiguration.getBaseConfig(), "dialogs.android.cannot_load_memory"); + player.showDialog(DialogHelper.messageDialog(Fake.pickFromList(options)).setType(DialogType.ANDROID)); + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/HomingBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/HomingBehavior.java new file mode 100644 index 00000000..0490261f --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/HomingBehavior.java @@ -0,0 +1,56 @@ +package brainwine.gameserver.entity.npc.behavior.parts; + +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.util.MathUtils; +import brainwine.gameserver.util.Vector2i; +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class HomingBehavior extends FlyBehavior { + @JsonProperty + private float radius = 30.0f; + @JsonProperty + private float chance = 0.2f; + + private boolean triggered; + private long lastChecked; + + public HomingBehavior(@JacksonInject Npc entity) { + super(entity); + } + + @Override + public boolean canBehave() { + if(entity.getGuardBlock() == null) return false; + if(MathUtils.inRange(entity.getX(), entity.getY(), entity.getGuardBlock().getX(), entity.getGuardBlock().getY(), radius)) { + triggered = false; + } else { + if(lastChecked + 1000 < System.currentTimeMillis() && Math.random() < chance) { + triggered = true; + } + lastChecked = System.currentTimeMillis(); + } + + return triggered; + } + + @Override + protected float getSpeedMultiplier() { + return 1.25F; + } + + @Override + protected Vector2i getTargetPoint() { + if(entity.getGuardBlock() == null) return new Vector2i((int)entity.getX(), (int)entity.getY()); + float homeX = entity.getGuardBlock().getX(); + float homeY = entity.getGuardBlock().getY(); + double D = MathUtils.distance(homeX, homeY, entity.getX(), entity.getY()); + double d = MathUtils.lerp(10.0, 30.0, Math.random()); + double dx = d * (homeX - entity.getX()) / D; + double dy = d * (homeY - entity.getY()) / D; + return new Vector2i( + (int)Math.round(entity.getX() + dx), + (int)Math.round(entity.getY() + dy) + ); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/PetBehavior.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/PetBehavior.java new file mode 100644 index 00000000..be0db882 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/behavior/parts/PetBehavior.java @@ -0,0 +1,45 @@ +package brainwine.gameserver.entity.npc.behavior.parts; + +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonCreator; + +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.behavior.Behavior; +import brainwine.gameserver.entity.npc.behavior.BehaviorMessage; +import brainwine.gameserver.player.Player; + +public class PetBehavior extends Behavior { + + public static final long PET_COOLDOWN = 2000; + private long lastPettedAt; + + @JsonCreator + public PetBehavior(@JacksonInject Npc entity) { + super(entity); + } + + @Override + public boolean behave() { + return true; + } + + @Override + public void react(BehaviorMessage message, Player player, Object... data) { + switch(message) { + case INTERACT: + long now = System.currentTimeMillis(); + + // Do nothing if entity was petted recently + if(now < lastPettedAt + PET_COOLDOWN) { + return; + } + + entity.emote("*terrapus noises*"); + entity.spawnEffect("terrapus purr"); + lastPettedAt = now; + break; + default: + break; + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/DialoguerJob.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/DialoguerJob.java new file mode 100644 index 00000000..0bb15dbc --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/DialoguerJob.java @@ -0,0 +1,138 @@ +package brainwine.gameserver.entity.npc.job; + +import brainwine.gameserver.Fake; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.QuestEvents; +import brainwine.gameserver.server.messages.EntityChangeMessage; +import brainwine.gameserver.util.MapHelper; + +import java.util.ArrayList; +import java.util.List; + +public abstract class DialoguerJob extends Job { + protected String choice; + + /**Return a list of DialogSection that contains the content specific to this job. The list may be empty. + * + * @param me the job haver + * @param player the entity interacting with the job haver + * @return a dialog section to be added after the greeting + */ + public abstract List getMainDialogSection(Npc me, Player player); + /**Handle the responses to the initial dialog. + * + * The implementation should first check if ans[0] reflects the choice that relates to the job, + * e. g. "joke" in case of Joker, + * Afterwards it can take world-changing action or send the interactor more dialogs + * + * @param me the job haver + * @param player the entity interacting with the job haver + * @param ans the array of answers filled into the dialog. If no inputs have been added this will be of length 1 and contain "cancel" etc. + * @return + */ + public abstract boolean handleDialogAnswers(Npc me, Player player, Object[] ans); + + /**This instance skips the main dialog section since only configuration is wanted. + * This is only useful if the dialoguing player is admin. + */ + public static DialoguerJob CONFIGURATION_ONLY = new DialoguerJob() { + @Override + public List getMainDialogSection(Npc me, Player player) { + return new ArrayList<>(); + } + + @Override + public boolean handleDialogAnswers(Npc me, Player player, Object[] ans) { + return false; + } + }; + + public boolean dialogue(Npc me, Player player) { + DialogSection title = new DialogSection().setTitle(String.format("%s says:", me.getName())); + DialogSection salutation = new DialogSection().setText(Fake.get(Fake.Type.SALUTATION)); + List mainDialog = getMainDialogSection(me, player); + + Dialog dialog = new Dialog().setType(DialogType.ANDROID) + .addSection(title) + .addSection(salutation); + + if (mainDialog != null) { + for(DialogSection section : mainDialog) { + dialog = dialog.addSection(section); + } + } + + if (player.isAdmin()) { + dialog = dialog.addSection( + new DialogSection() + .setText("Can I configure you?") + .setChoice("configure") + ); + } + + if (!player.isV3()) { + dialog = dialog.addSection(new DialogSection().setText(" ")); + } + + player.showDialog(dialog, ans -> { + if (ans.length >= 1 && "cancel".equals(ans[0])) return; + if (handleConfiguration(me, player, ans)) return; + this.handleDialogAnswers(me, player, ans); + } + ); + + QuestEvents.handleInteract(player, me); + + return true; + } + + private boolean handleConfiguration(Npc me, Player player, Object[] ans) { + if (ans.length >= 1 && "configure".equals(ans[0])) { + if(!player.isAdmin()) return true; + + Dialog dialog = DialogHelper.getDialog("android.configure"); + + player.showDialog(dialog, resp -> { + if("cancel".equals(resp[0])) return; + + String name = (String)resp[0]; + String job = (String)resp[1]; + + boolean validated = true; + + JobType jobType = null; + + if("null".equals(job)) { + jobType = null; + } else if(!job.matches("^\\s*$")) { + jobType = JobType.fromString(job); + if(jobType == null) { + player.showDialog(DialogHelper.messageDialog(String.format("\"%s\" is not a valid job type.", job))); + validated = false; + } + } + + if(validated) { + me.setName(name); + + if(job == null || !job.matches("^\\s*$")) { + me.setJob(jobType); + } + + player.notify(String.format("Cool, I'm now %s the %s!", me.getName(), me.getJob())); + me.getZone().sendMessage(new EntityChangeMessage(me.getId(), MapHelper.map("n", me.getName()))); + } + }); + + return true; + } + + return false; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/Job.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/Job.java new file mode 100644 index 00000000..49b1234f --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/Job.java @@ -0,0 +1,12 @@ +package brainwine.gameserver.entity.npc.job; + +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; + +public abstract class Job { + + public abstract boolean dialogue(Npc me, Player player); + + public void acceptItem(Npc me, Player player, Item item) {} +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/JobType.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/JobType.java new file mode 100644 index 00000000..37bbce39 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/JobType.java @@ -0,0 +1,42 @@ +package brainwine.gameserver.entity.npc.job; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; + +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.job.jobs.*; +import brainwine.gameserver.player.Player; + +public enum JobType { + @JsonEnumDefaultValue + JOKER(new Joker()), + CRAFTER(new Crafter()), + QUESTER(new Quester()), + TRADER(new Trader()), + FAMILY_NAME(new FamilyName()), + ANDROID_DIALOG(new AndroidDialog()), + ; + + private Job job; + + JobType(Job job) { + this.job = job; + } + + public static JobType fromString(String j) { + if (j == null) return null; + try { + return JobType.valueOf(j.trim().toUpperCase()); + } catch(IllegalArgumentException e) { + return null; + } + } + + public boolean dialogue(Npc me, Player player) { + return this.job.dialogue(me, player); + } + + public Job get() { + return job; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/AndroidDialog.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/AndroidDialog.java new file mode 100644 index 00000000..cef7606b --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/AndroidDialog.java @@ -0,0 +1,60 @@ +package brainwine.gameserver.entity.npc.job.jobs; + +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.job.DialoguerJob; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.PlayerQuests; +import brainwine.gameserver.quest.Quest; +import brainwine.gameserver.quest.QuestAction; +import brainwine.gameserver.quest.QuestProgress; +import brainwine.gameserver.quest.QuestTask; + +import java.util.Arrays; +import java.util.List; + +public class AndroidDialog extends DialoguerJob { + + @Override + public List getMainDialogSection(Npc me, Player player) { + for(QuestProgress progress : player.getQuestProgresses().values()) { + if(progress.isComplete()) continue; + Quest quest = progress.getQuest(player); + if(quest == null) return null; + + // Find quest task that requires talking to this android + int taskIndex = -1; + for(int i = 0; i < quest.getTasks().size(); i++) { + QuestTask task = quest.getTasks().get(i); + if(task.getEvents() != null) for(List event : task.getEvents()) { + if(event.size() >= 3 + && "interact".equals(event.get(0)) + && "name".equals(event.get(1)) + && me.getName() != null + && me.getName().equals(event.get(2)) + ) { + taskIndex = i; + break; + } + } + } + if(taskIndex < 0) continue; + + QuestTask task = quest.getTasks().get(taskIndex); + + // Perform the INTERACT action, only doing the mutations that give the player + // an advantage if this is the first interaction after the task is first received. + boolean preventMutations = task.checkComplete(player, progress.getTaskProgress(taskIndex)); + + // TODO: This is hacked in. + return Arrays.asList(PlayerQuests.performAction(player, quest, QuestAction.Type.INTERACT, preventMutations)); + } + + return Arrays.asList(new DialogSection().setText("I don't know what to say.")); + } + + @Override + public boolean handleDialogAnswers(Npc me, Player player, Object[] ans) { + return true; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Crafter.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Crafter.java new file mode 100644 index 00000000..fc4e8e05 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Crafter.java @@ -0,0 +1,104 @@ +package brainwine.gameserver.entity.npc.job.jobs; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.job.DialoguerJob; +import brainwine.gameserver.item.CraftingRequirement; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.LazyItemGetter; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; + +public class Crafter extends DialoguerJob { + + @Override + public List getMainDialogSection(Npc me, Player player) { + return Arrays.asList(new DialogSection() + .setText(MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.craft")) + .setChoice("craft")); + } + + @Override + public boolean handleDialogAnswers(Npc me, Player player, Object[] ans) { + if (ans.length >= 1 && "craft".equals(ans[0])) { + player.showDialog( + DialogHelper.messageDialog( + me.getName(), + MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.craft_response") + ).setType(DialogType.ANDROID) + ); + } + + return true; + } + + public void acceptItem(Npc me, Player player, Item item) { + // I can't craft this + if(item.getCraft() == null || "android".equals(item.getCraft().getCrafter())) { + player.showDialog(DialogHelper.messageDialog(MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.cannot_craft")).setType(DialogType.ANDROID)); + } + + // Item is not craftable by an android + if (item.getCraft() == null) { + player.showDialog(DialogHelper.messageDialog("Sorry, that item doesn't have any crafting options.").setType(DialogType.ANDROID)); + return; + } + + Dialog dialog = new Dialog().setType(DialogType.ANDROID); + dialog.addSection(new DialogSection().setText(String.format("I can use your %s to craft something if you have the supplies.", item.getTitle()))); + + for(String itemId : item.getCraft().getOptions().keySet()) { + Item optionItem = Item.get(itemId); + + if(optionItem != null) { + dialog.addSection(new DialogSection().setText(optionItem.getTitle()).setChoice(itemId)); + } else { + dialog.addSection(new DialogSection().setText(String.format("Unknown item: %s", itemId))); + } + } + + dialog.addSection(new DialogSection().setText("Never mind.").setChoice("cancel")); + + player.showDialog(dialog, ans -> continueCraftDialog(player, item, ans)); + + return; + } + + public void continueCraftDialog(Player player, Item item, Object[] ans) { + if (ans.length >= 1 && "cancel".equals(ans[0])) { + return; + } + + Item craftItem = Item.get((String)ans[0]); + + if (craftItem == null) return; + + List requirements = new ArrayList<>(item.getCraft().getOptions().get((String)ans[0])); + // add the dropped item as requirement so we check for it/remove it in the for loops + requirements.add(new CraftingRequirement(new LazyItemGetter(item.getId()), 1)); + + for(CraftingRequirement requirement : requirements) { + if(!player.getInventory().hasItem(requirement.getItem(), requirement.getQuantity())) { + player.showDialog(DialogHelper.messageDialog(String.format("Oops, you need more %s for me to craft that!", requirement.getItem().getTitle())).setType(DialogType.ANDROID)); + return; + } + } + + // all good, commit with crafting + + for(CraftingRequirement requirement : requirements) { + player.getInventory().removeItem(requirement.getItem(), requirement.getQuantity(), true); + } + + player.getInventory().addItem(craftItem, 1, true); + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/FamilyName.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/FamilyName.java new file mode 100644 index 00000000..0e7e21bd --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/FamilyName.java @@ -0,0 +1,28 @@ +package brainwine.gameserver.entity.npc.job.jobs; + +import brainwine.gameserver.Naming; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.job.DialoguerJob; +import brainwine.gameserver.player.Player; + +import java.util.Arrays; +import java.util.List; + +public class FamilyName extends DialoguerJob { + + @Override + public List getMainDialogSection(Npc me, Player player) { + String familyName = Naming.ENTITY_LAST_NAMES[(int)(Math.random() * Naming.ENTITY_LAST_NAMES.length)]; + if(player != null) player.setFamilyName(familyName); + return Arrays.asList(new DialogSection().setText("Ah, another survivor. I can see you're still a little dazed from that whole apocalypse business... " + + String.format("you've forgotten your Family Name, yes? I can help. I can see it in your eyes. You're a %s! A more than worthy ancestry. ", familyName) + + "What you do with that Name is up to you. Perhaps you will bring glory to your house. I wish you luck!")); + } + + @Override + public boolean handleDialogAnswers(Npc me, Player player, Object[] ans) { + return true; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Joker.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Joker.java new file mode 100644 index 00000000..902553e6 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Joker.java @@ -0,0 +1,24 @@ +package brainwine.gameserver.entity.npc.job.jobs; + +import brainwine.gameserver.Fake; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.job.DialoguerJob; +import brainwine.gameserver.player.Player; + +import java.util.Arrays; +import java.util.List; + +public class Joker extends DialoguerJob { + + @Override + public List getMainDialogSection(Npc me, Player player) { + return Arrays.asList(new DialogSection().setText(Fake.get(Fake.Type.JOKE))); + } + + @Override + public boolean handleDialogAnswers(Npc me, Player other, Object[] ans) { + return true; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Quester.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Quester.java new file mode 100644 index 00000000..383d2242 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Quester.java @@ -0,0 +1,205 @@ +package brainwine.gameserver.entity.npc.job.jobs; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.job.DialoguerJob; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.*; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.util.MathUtils; +import brainwine.gameserver.util.Pair; + +public class Quester extends DialoguerJob { + @Override + public List getMainDialogSection(Npc me, Player player) { + return Arrays.asList(new DialogSection().setText(MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.quest")).setChoice("offers")); + } + + @Override + public boolean handleDialogAnswers(Npc me, Player player, Object[] ans) { + return dialogueOfferQuests(me, player); + } + + private Pair getQuestCategoryAndLevel(Npc me) { + if (me.getName() == null) { + return null; + } + + String[] parts = me.getName().split(" "); + + Map nameToCategoryTitle = MapHelper.getMap(GameConfiguration.getBaseConfig(), "quests.sources"); + + if (parts.length == 1) { + return new Pair<>(nameToCategoryTitle.get(parts[0]), 1); + } else { + return new Pair<>(nameToCategoryTitle.get(parts[0]), MathUtils.clamp(parts[1].length(), 1, 3)); + } + + } + + public boolean dialogueOfferQuests(Npc me, Player player) { + HardcodedQuest hardcodedQuest = Quests.getHardcodedQuests().get(me.getName()); + if (hardcodedQuest != null) { + return dialogueHardcodedQuest(me, player, hardcodedQuest); + } else { + return dialogueOtherQuest(me, player); + } + } + + public boolean dialogueHardcodedQuest(Npc me, Player player, HardcodedQuest hardcodedQuest) { + String questId = hardcodedQuest.getQuestId(); + + QuestProgress currentProgress = player.getQuestProgresses().get(questId); + + if (currentProgress == null) { + dialogueConfirmBeginQuest(me, player, questId); + } else { + dialogueCheckProgress(me, player, questId); + } + return true; + } + + public boolean dialogueOtherQuest(Npc me, Player player) { + Map config = GameConfiguration.getBaseConfig(); + Pair categoryAndLevel = getQuestCategoryAndLevel(me); + + if (categoryAndLevel != null) { + String category = categoryAndLevel.getFirst(); + int level = categoryAndLevel.getLast(); + + if (category != null) { + QuestProgress currentProgress = Quests.getIncompleteQuestProgressInCategory(player, category); + + if (currentProgress == null) { + long ongoingQuestCount = player.getQuestProgresses().values().stream().filter(p -> !p.isComplete()).count(); + + if(ongoingQuestCount >= 20) { + player.showDialog(DialogHelper.messageDialog("Too Many Quests", "Sorry, but you already have 20 or more ongoing quests. Either finish or cancel some before I can offer you more. You may use the /quests command to cancel incomplete quests.").setType(DialogType.ANDROID)); + } + // Offer a set of quests that the player hasn't had before + final int count = 5; + Set excludedQuests = new HashSet<>(player.getQuestProgresses().keySet()); + + // If the quester is Newton and there are other uncompleted beginner quests don't offer the "Fancy Another Quest" quest. + if("Survive and Thrive".equals(category) && excludedQuests.stream().filter(k -> k.startsWith("survival_") && !k.startsWith("survival_random")).count() < Quests.questMaps.get("Survive and Thrive").size() - 1) { + excludedQuests.add("survival_quest"); + } + + List quests = Quests.getRandomQuestsFromCategory(me, category, excludedQuests, count); + + if(quests.size() < count) { + String categoryPrefix = Quests.titleToPrefix.get(category); + List randomQuests = RandomQuests.generateRandomPlayerQuests(player, RandomQuestDomain.fromCategoryTitle(category), count - quests.size()); + for(Quest quest : randomQuests) { + String id = Integer.toString((0x1000_000 + (int)Math.floor(Math.random() * 0xEFFF_FFF)), 16); + quest.setId(categoryPrefix + "_random_" + id); + quest.setGroup(category); + + if(quest.getReward().getXp() == 0) { + quest.setReward(new QuestReward().setXp(Math.max(20, 5 * quest.getReward().getCrowns()))); + } else { + quest.setReward(new QuestReward().setXp(Math.max(20, quest.getReward().getXp()))); + } + + if(quest.getTasks() == null) quest.setTasks(new ArrayList<>()); + quest.getTasks().add(new QuestTask() + .setDescription("Return to the android") + .setEvents(Arrays.asList(Arrays.asList("return"))) + ); + } + quests.addAll(randomQuests); + } + + PlayerQuestDialog.offerQuests(player, quests, quest -> beginQuest(me, player, quest)); + return true; + } else { + // Follow up on the previous quest + return dialogueCheckProgress(me, player, currentProgress.getQuestId()); + } + } + } + + player.showDialog(DialogHelper.messageDialog("No Quest Offers", MapHelper.getString(config, "dialogs.android.no_quest")).setType(DialogType.ANDROID)); + return true; + } + + public boolean dialogueConfirmBeginQuest(Npc me, Player player, String questId) { + Quest quest = Quests.get(questId); + + if (quest == null) { + return true; + } + + PlayerQuestDialog.offerSingleQuest(player, quest, myQuest -> beginQuest(me, player, myQuest)); + + return true; + } + + public void beginQuest(Npc me, Player player, Quest quest) { + PlayerQuests.beginQuest(player, quest); + + player.showDialog(PlayerQuestDialog.beginQuestDialogGet(player, quest)); + } + + public boolean dialogueCheckProgress(Npc me, Player player, String questId) { + Quest quest = Quests.get(player, questId); + + PlayerQuests.handleQuestFinalReturn(player, quest); + + if(PlayerQuests.canFinishQuest(player, quest, true)) { + player.showDialog(DialogHelper.messageDialog(quest.getStory().getComplete() == null + ? "You have successfully completed your quest!" + : quest.getStory().getComplete() + .replace("$family_name", player.getFamilyName() == null ? "unknown" : player.getFamilyName()) + ).setType(DialogType.ANDROID)); + + PlayerQuests.finishQuest(player, quest); + + return true; + } else { + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Cannot Finish Quest Yet"); + + dialog.addSection(new DialogSection().setText(quest.getStory().getIncomplete())); + + QuestProgress progress = player.getQuestProgresses().get(questId); + String cannotCancelReason = progress.getCannotCancelReason(player); + if(cannotCancelReason == null) { + List implications = progress.getRevertImplicationsMessages(player); + if(implications.isEmpty()) { + dialog.addSection(new DialogSection().setText("You can give up on it if you want to.")); + } else for(String implication : implications) { + dialog.addSection(new DialogSection().setText(implication)); + } + + if(player.isV3()) { + dialog.addSection(new DialogSection().setText("Cancel Quest").setChoice("cancelquest")); + } else { + dialog.addSection(new DialogSection().setText("Cancel Quest").setTextColor("ff0000").setChoice("cancelquest")); + } + } else { + dialog.addSection(new DialogSection().setText("You cannot cancel this quest yet. " + cannotCancelReason)); + } + + player.showDialog(dialog, ans -> { + if(ans.length > 0 && "cancelquest".equals(ans[0])) { + PlayerQuests.cancelQuest(player, questId, player.isGodMode()); + } + }); + + return true; + } + + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Trader.java b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Trader.java new file mode 100644 index 00000000..b23440fd --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/entity/npc/job/jobs/Trader.java @@ -0,0 +1,238 @@ +package brainwine.gameserver.entity.npc.job.jobs; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.androidshop.AndroidShop; +import brainwine.gameserver.androidshop.AndroidShopSession; +import brainwine.gameserver.scrapmarket.ScrapMarket; +import brainwine.gameserver.scrapmarket.ScrapMarketOfferSession; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.entity.npc.job.DialoguerJob; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.Skill; +import brainwine.gameserver.player.TradeSession; +import brainwine.gameserver.scrapmarket.ScrapMarketSession; +import brainwine.gameserver.util.MapHelper; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +public class Trader extends DialoguerJob { + Map> offers = new HashMap<>(); + @Override + public List getMainDialogSection(Npc me, Player player) { + List sections = new ArrayList<>(Arrays.asList( + new DialogSection() + .setText(MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.buy")) + .setChoice("buy"), + new DialogSection() + .setText(MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.sell")) + .setChoice("sell") + )); + + if(player.getTotalSkillLevel(Skill.BARTER) >= ScrapMarket.MIN_BARTER_LEVEL) { + sections.add(new DialogSection() + .setText(MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.scrap_market")) + .setChoice("scrap_market")); + } else { + sections.add(new DialogSection().setText(String.format( + "You must be at least barter level %d to access the Scrap Market.", + ScrapMarket.MIN_BARTER_LEVEL + ))); + } + + return sections; + } + + @Override + public boolean handleDialogAnswers(Npc me, Player player, Object[] ans) { + if (ans.length >= 1 && "sell".equals(ans[0])) { + player.showDialog( + DialogHelper.messageDialog( + me.getName(), + MapHelper.getString(GameConfiguration.getBaseConfig(), "dialogs.android.sell_response") + ).setType(DialogType.ANDROID) + ); + } + + if (ans.length >= 1 && "buy".equals(ans[0])) { + new AndroidShopSession(AndroidShop.getInstance(), me, player).showNextDialog(); + } + + if (ans.length >= 1 && "scrap_market".equals(ans[0])) { + new ScrapMarketSession(ScrapMarket.getInstance(), me, player).showNextDialog(); + } + + return true; + } + + private String getItemTitle(Item item) { + return item.getTitle() != null ? item.getTitle() : item.getId(); + } + + private void validateOffer(Player player, Map offer) { + Map result = new HashMap<>(); + for(Map.Entry entry : offer.entrySet()) { + result.put(entry.getKey(), Math.min(player.getInventory().getQuantity(entry.getKey()), entry.getValue())); + } + offer.clear(); + offer.putAll(result); + } + + private int calculatePayback(Player player, Map offer) { + int payback = 0; + for(Map.Entry entry : offer.entrySet()) { + payback += Math.max(0, entry.getValue() * AndroidShop.getInstance().getAdjustments() + .getAdjustedBuyPrice(player, entry.getKey().getShillingsPrice()) + ); + } + return payback; + } + + private Dialog acceptItemLinkToScrapMarket(Dialog dialog) { + dialog.addSection(new DialogSection().setText("If you'd like, you can list this on the scrap market in hopes of getting a better deal.")); + DialogSection section = new DialogSection().setText("Offer This on the Scrap Market").setChoice("scrap_market"); + dialog.addSection(section); + return dialog; + } + + private boolean acceptItemHandleScrapMarket(Npc me, Player player, Item item, Object[] ans) { + if(ans.length > 0 && "scrap_market".equals(ans[0])) { + new ScrapMarketOfferSession(ScrapMarket.getInstance(), player, item).showNextDialog(); + return true; + } + + return false; + } + + @Override + public void acceptItem(Npc me, Player player, Item item) { + String itemTitle = getItemTitle(item); + String itemTitlePlural = itemTitle + (itemTitle.toLowerCase().endsWith("s") ? "es" : "s"); + int playerHas = player.getInventory().getQuantity(item); + int barterSkill = player.getTotalSkillLevel(Skill.BARTER); + int maxPrice = AndroidShop.getInstance().getAdjustments().getMaxPrice(player); + + Consumer scrapMarketOnlyHandler = ans -> acceptItemHandleScrapMarket(me, player, item, ans); + + if(item.getShillingsPrice() > maxPrice) { + player.showDialog(acceptItemLinkToScrapMarket(DialogHelper + .messageDialog("Low Barter Skill", "Sorry, I don't think we can make a deal on this item right now. Work on your negotiating skills and come back later.") + .setType(DialogType.ANDROID) + ), scrapMarketOnlyHandler); + return; + } + + if(barterSkill < item.getBarterLevel()) { + player.showDialog(acceptItemLinkToScrapMarket(DialogHelper + .messageDialog("Low Barter Skill", "Sorry, but I don't trust in the quality of your " + (playerHas == 1 ? itemTitle : itemTitlePlural) + ". Improve on your barter skills and come back.") + .addSection(new DialogSection().setText("You need at least barter level " + item.getBarterLevel() + ".")) + .setType(DialogType.ANDROID) + ), scrapMarketOnlyHandler); + return; + } + + final Dialog dialog = new Dialog().setType(DialogType.ANDROID); + if(item.getBarterMessage() != null) { + dialog.addSection(new DialogSection().setText(item.getBarterMessage())); + } + + String header; + if(item.getShillingsPrice() <= -2) { + header = "Sorry, there's nothing I can do with this item right now."; + } else if(item.getShillingsPrice() == -1) { + header = "Sorry but I don't know enough about this item to make an offer on it. I can take them from you to free up some space if you'd like."; + } else if(item.getShillingsPrice() == 0) { + header = "I'm not interested in your " + (playerHas == 1 ? itemTitle : itemTitlePlural) + " right now, but I can take them so you free up some space."; + } else { + int price = AndroidShop.getInstance().getAdjustments().getAdjustedBuyPrice(player, item.getShillingsPrice()); + header = "I buy " + itemTitlePlural + " for " + price + " shilling" + (price == 1 ? "" : "s") + " each."; + } + + dialog.addSection(new DialogSection().setText(header)); + + // For -2 and lower it doesn't allow trading at all. + if(item.getShillingsPrice() < -1) { + player.showDialog(dialog, scrapMarketOnlyHandler); + return; + } + + dialog.addSection(TradeSession.Dialogs.createQuantitySelector(player, item).setText(item.getShillingsPrice() > 0 ? "How many are you selling?" : "How many are you giving?")); + + if(barterSkill < 10) { + dialog.addSection(new DialogSection().setText("When you reach barter level 10, you will also be able to list this on the Scrap Market and possibly get a better offer there.")); + } else { + acceptItemLinkToScrapMarket(dialog); + } + + player.showDialog(dialog, ans -> { + if(acceptItemHandleScrapMarket(me, player, item, ans)) return; + if(ans.length == 0) return; + + if(!(ans[0] instanceof String && "cancel".equals(ans[0]))) { + int quantity = 0; + try { + quantity = Integer.parseInt(ans[0].toString()); + } catch(NumberFormatException e) { + player.notify("There has been an error processing your input."); + return; + } + + Map offer = offers.computeIfAbsent(player, p -> new HashMap<>()); + offer.put(item, quantity); + // I tried to make implementing multi item trading easier later on. + completeOrder(me, player); + } + }); + } + + public void completeOrder(Npc me, Player player) { + Map offer = offers.computeIfAbsent(player, p -> new HashMap<>()); + validateOffer(player, offer); + Item shillings = ItemRegistry.getItem("accessories/shillings"); + if(offer.isEmpty()) { + player.showDialog(DialogHelper.messageDialog("No Offer", "Sorry, you haven't offered any items yet").setType(DialogType.ANDROID)); + } else { + validateOffer(player, offer); + int payback = calculatePayback(player, offer); + DialogSection itemsSection = new DialogSection(); + for(Map.Entry entry : offer.entrySet()) { + Item item = entry.getKey(); + int quantity = entry.getValue(); + itemsSection.addItem(new DialogListItem().setItem(item.getCode()).setText(getItemTitle(item) + " x " + quantity)); + } + + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("My Offer").setActions("yesno"); + if(payback > 0) { + dialog.addSection(itemsSection.setTitle("For your")); + dialog.addSection(new DialogSection().setTitle("I pay").addItem(new DialogListItem().setItem(shillings.getCode()).setText(payback + (payback == 1 ? " Shilling" : " Shillings")))); + } else { + dialog.addSection(itemsSection.setText("I will be taking your")); + } + dialog.addSection(new DialogSection().setText("Do you accept?")); + + player.showDialog(dialog, ans -> { + if(!(ans.length == 0 || "cancel".equals(ans[0]))) { + validateOffer(player, offer); + int finalPayback = calculatePayback(player, offer); + for(Map.Entry entry : offer.entrySet()) { + player.getInventory().removeItem(entry.getKey(), entry.getValue(), true); + } + player.getInventory().addItem(shillings, finalPayback, true); + me.emote("Good trade."); + } + }); + } + offers.remove(player); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/Action.java b/gameserver/src/main/java/brainwine/gameserver/item/Action.java index 543ecee4..b553f809 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/Action.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/Action.java @@ -6,12 +6,15 @@ import brainwine.gameserver.item.consumables.Consumable; import brainwine.gameserver.item.consumables.ConvertConsumable; import brainwine.gameserver.item.consumables.HealConsumable; +import brainwine.gameserver.item.consumables.LockWorldConsumable; +import brainwine.gameserver.item.consumables.LootConsumable; import brainwine.gameserver.item.consumables.NameChangeConsumable; import brainwine.gameserver.item.consumables.RefillConsumable; import brainwine.gameserver.item.consumables.SkillConsumable; import brainwine.gameserver.item.consumables.SkillResetConsumable; import brainwine.gameserver.item.consumables.StealthConsumable; import brainwine.gameserver.item.consumables.TeleportConsumable; +import brainwine.gameserver.item.consumables.UnlockWorldConsumable; /** * Action types for items. @@ -23,14 +26,20 @@ public enum Action { CONVERT(new ConvertConsumable()), DIG, + SCRUB, HEAL(new HealConsumable()), + LOCK_WORLD(new LockWorldConsumable()), + LOOT(new LootConsumable()), NAME_CHANGE(new NameChangeConsumable()), REFILL(new RefillConsumable()), + REVIVE, + SHIELD, SKILL(new SkillConsumable()), SKILL_RESET(new SkillResetConsumable()), SMASH, STEALTH(new StealthConsumable()), TELEPORT(new TeleportConsumable()), + UNLOCK_WORLD(new UnlockWorldConsumable()), @JsonEnumDefaultValue NONE; diff --git a/gameserver/src/main/java/brainwine/gameserver/item/Craft.java b/gameserver/src/main/java/brainwine/gameserver/item/Craft.java new file mode 100644 index 00000000..eabad5ed --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/Craft.java @@ -0,0 +1,43 @@ +package brainwine.gameserver.item; + +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class Craft { + @JsonProperty("crafter") + private String crafter; + + private Map> options; + + @JsonCreator + private Craft(Map inp) { + crafter = (String)inp.get("crafter"); + Map> map = (Map>)inp.get("options"); + + options = new HashMap>(); + + for(Map.Entry> entry : map.entrySet()) { + List requirements = entry.getValue().entrySet().stream() + .map(e -> new CraftingRequirement(new LazyItemGetter(e.getKey()), e.getValue())) + .collect(Collectors.toList()); + + options.put(entry.getKey(), requirements); + } + } + + public String getCrafter() { + return crafter; + } + + public Map> getOptions() { + return options; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/CraftingRequirement.java b/gameserver/src/main/java/brainwine/gameserver/item/CraftingRequirement.java index 47d8214d..652e3b3f 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/CraftingRequirement.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/CraftingRequirement.java @@ -7,7 +7,7 @@ public class CraftingRequirement { private final LazyItemGetter item; private final int quantity; - private CraftingRequirement(LazyItemGetter item, int quantity) { + public CraftingRequirement(LazyItemGetter item, int quantity) { this.item = item; this.quantity = quantity; } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/Item.java b/gameserver/src/main/java/brainwine/gameserver/item/Item.java index af9233e0..0933c1e9 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/Item.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/Item.java @@ -2,14 +2,22 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; +import brainwine.gameserver.GameServer; +import brainwine.gameserver.command.CommandAccessLevel; +import brainwine.gameserver.item.usetypeconfig.ItemUseTypeConfig; +import brainwine.gameserver.player.AppearanceSlot; +import brainwine.gameserver.player.NotificationType; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.JsonValue; import brainwine.gameserver.dialog.DialogType; @@ -36,6 +44,13 @@ public class Item { @JsonProperty("title") private String title; + + @JsonProperty("title_color") + private String titleColor; + + @JsonProperty("hint") + @JsonAlias("hintt") + private String hint = "Little is known about this item..."; @JsonProperty("rotation") private String rotation; @@ -57,6 +72,9 @@ public class Item { @JsonProperty("mod") private ModType mod = ModType.NONE; + + @JsonProperty("mod_max") + private int maxMod = 0; @JsonProperty("meta") private MetaType meta = MetaType.NONE; @@ -96,6 +114,12 @@ public class Item { @JsonProperty("power") private float power; + + @JsonProperty("firing_duration") + private float firingDuration; + + @JsonProperty("firing_interval") + private float firingInterval; @JsonProperty("toughness") private float toughness; @@ -105,12 +129,21 @@ public class Item { @JsonProperty("diggable") private boolean diggable; + + @JsonProperty("scrubbable") + private boolean scrubbable; @JsonProperty("wardrobe") private boolean clothing; @JsonProperty("consumable") private boolean consumable; + + @JsonProperty("locked") + private boolean locked; + + @JsonProperty("locked_loot") + private LinkedHashMap selectiveLockedLoot; @JsonProperty("placeover") private boolean placeover; @@ -123,6 +156,9 @@ public class Item { @JsonProperty("field_place") private boolean fieldPlace; + + @JsonProperty("place_transform") + private Map placeTransform; @JsonProperty("base") private boolean base; @@ -159,6 +195,9 @@ public class Item { @JsonProperty("mod_inventory") private Pair modInventoryItem; + + @JsonProperty("craft") + private Craft craft = null; @JsonProperty("crafting quantity") private int craftingQuantity = 1; @@ -192,6 +231,9 @@ public class Item { @JsonProperty("damage") private Pair damageInfo; + + @JsonProperty("attack_bonus") + private Map skillAttackBonus = new HashMap<>(); @JsonProperty("timer") private Pair timer; @@ -213,15 +255,38 @@ public class Item { @JsonProperty("crafting_helpers") private List craftingHelpers = new ArrayList<>(); - - @JsonProperty("use") - private Map useConfigs = new HashMap<>(); + + private Map useConfigs = new HashMap<>(); @JsonProperty("convert") private Map conversions = new HashMap<>(); + + @JsonProperty("grind") + private Map grind = new HashMap<>(); + + @JsonProperty("smelt") + private Map smelt = new HashMap<>(); + + @JsonProperty("strip") + private Map strip = new HashMap<>(); @JsonProperty("spawn_entity") private WeightedMap entitySpawns = new WeightedMap<>(); + + @JsonProperty("spawn_entity_quantity") + private Pair entitySpawnQuantity = new Pair(1, 1); + + @JsonProperty("spawn_entity_for") + private CommandAccessLevel entitySpawnAccessLevel = CommandAccessLevel.EVERYONE; + + @JsonProperty("shillings_price") + private int shillingsPrice = -1; + + @JsonProperty("barter_level") + private int barterLevel = 1; + + @JsonProperty("barter_message") + private String barterMessage = null; @JsonCreator private Item(@JsonProperty(value = "id", required = true) String id, @@ -229,6 +294,20 @@ private Item(@JsonProperty(value = "id", required = true) String id, this.id = id; this.code = code; } + + @JsonSetter("use") + public void setUseConfigs(Map uses) { + useConfigs = new HashMap<>(); + for(Map.Entry use : uses.entrySet()) { + try { + useConfigs.put(use.getKey(), use.getKey().parseConfig(use.getValue())); + } catch(Exception e) { + GameServer.getInstance().notify("Failed to parse " + use.getKey() + " use type for item " + id, NotificationType.SYSTEM); + e.printStackTrace(); + useConfigs.put(use.getKey(), use.getKey().getDefaultConfig()); + } + } + } @JsonCreator public static Item get(String id) { @@ -289,6 +368,18 @@ public String getCategory() { public String getTitle() { return title; } + + public String getTitleColor() { + return titleColor; + } + + public String getFancyTitle() { + return titleColor == null ? title : "" + title + ""; + } + + public String getHint() { + return hint; + } public boolean isMirrorable() { return rotation != null && rotation.equalsIgnoreCase("mirror"); @@ -315,7 +406,7 @@ public Action getAction() { } public boolean isPlacable() { - return layer == Layer.BACK || layer == Layer.FRONT; + return layer == Layer.BASE || layer == Layer.BACK || layer == Layer.FRONT; } public Layer getLayer() { @@ -329,6 +420,10 @@ public boolean hasMod() { public ModType getMod() { return mod; } + + public int getMaxMod() { + return maxMod; + } public boolean hasMeta() { return meta != MetaType.NONE; @@ -425,6 +520,14 @@ public int getSpawnSpacing() { public float getPower() { return power; } + + public float getFiringDuration() { + return firingDuration; + } + + public float getFiringInterval() { + return firingInterval; + } public float getToughness() { return toughness; @@ -437,7 +540,11 @@ public boolean isEarthy() { public boolean isDiggable() { return diggable; } - + + public boolean isScrubbable() { + return scrubbable; + } + public boolean isClothing() { return clothing; } @@ -445,6 +552,14 @@ public boolean isClothing() { public boolean isConsumable() { return consumable; } + + public boolean isLocked() { + return locked; + } + + public LinkedHashMap getSelectiveLockedLoot() { + return selectiveLockedLoot; + } public boolean isBase() { return base; @@ -465,7 +580,11 @@ public boolean hasCustomPlace() { public boolean canPlaceInField() { return fieldPlace; } - + + public Map getPlaceTransform() { + return placeTransform; + } + public boolean isWhole() { return whole; } @@ -577,6 +696,10 @@ public boolean hasMiningBonus() { public MiningBonus getMiningBonus() { return miningBonus; } + + public Craft getCraft() { + return craft; + } public int getCraftingQuantity() { return craftingQuantity; @@ -589,6 +712,10 @@ public DamageType getDamageType() { public float getDamage() { return isWeapon() ? damageInfo.getLast() : 0; } + + public Map getSkillAttackBonus() { + return skillAttackBonus; + } public boolean hasTimer() { return timer != null; @@ -651,18 +778,39 @@ public boolean hasUse(ItemUseType... types) { return false; } + + public T getStructuredUse(ItemUseType type) { + if(hasUse(type)) { + return (T)useConfigs.get(type); + } else { + return null; + } + } public Object getUse(ItemUseType type) { - return useConfigs.get(type); + ItemUseTypeConfig structuredConfig = useConfigs.get(type); + return structuredConfig != null ? structuredConfig.getConfig() : null; } - public Map getUses() { + public Map getUses() { return useConfigs; } public Map getConversions() { return conversions.entrySet().stream().collect(Collectors.toMap(entry -> entry.getKey().get(), entry -> entry.getValue().get())); } + + public Map getGrind() { + return grind.entrySet().stream().collect(Collectors.toMap(entry -> entry.getKey().get(), Map.Entry::getValue)); + } + + public Map getSmelt() { + return smelt.entrySet().stream().collect(Collectors.toMap(entry -> entry.getKey().get(), Map.Entry::getValue)); + } + + public Map getStrip() { + return strip.entrySet().stream().collect(Collectors.toMap(entry -> entry.getKey().get(), Map.Entry::getValue)); + } public boolean hasEntitySpawns() { return !entitySpawns.isEmpty(); @@ -671,4 +819,24 @@ public boolean hasEntitySpawns() { public WeightedMap getEntitySpawns() { return entitySpawns; } + + public Pair getEntitySpawnQuantity() { + return entitySpawnQuantity; + } + + public CommandAccessLevel getEntitySpawnAccessLevel() { + return entitySpawnAccessLevel; + } + + public int getShillingsPrice() { + return shillingsPrice; + } + + public int getBarterLevel() { + return barterLevel; + } + + public String getBarterMessage() { + return barterMessage; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java b/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java index 9ee01630..e0880c3a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java @@ -12,12 +12,15 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import brainwine.gameserver.player.AppearanceSlot; + public class ItemRegistry { private static final Logger logger = LogManager.getLogger(); private static final Map items = new HashMap<>(); private static final Map itemsByCode = new HashMap<>(); private static final Map> itemsByCategory = new HashMap<>(); + private static final Map pilesByItem = new HashMap<>(); private static final List hiddenItems = new ArrayList<>(); // TODO maybe just move the registry stuff here @@ -51,18 +54,45 @@ public static boolean registerItem(Item item) { categorizedItems.add(item); if(item.isHidden()) { - // TODO v3 has a hard limit of 20 hidden items (see Inventory#maxLocationSlots in the game client) - if(hiddenItems.size() == 20) { - logger.warn(SERVER_MARKER, "Upper hidden item limit has been reached. Certain hidden accessories might not work properly!"); + while(hiddenItems.size() < 9) { + hiddenItems.add("air"); + } + if(item.getCategory().equals("prosthetics") && item.hasAppearanceSlot()) { + // Just putting them into hardcoded slots seems to work well + int a = 6; + int b = 0; + if(item.getId().contains("onyx")) a = 0; + else if(item.getId().contains("diamond")) a = 3; + if(item.getAppearanceSlot() == AppearanceSlot.FACIAL_GEAR) b = 2; + else if(item.getAppearanceSlot() == AppearanceSlot.TOPS_OVERLAY) b = 1; + hiddenItems.set(a + b, item.getId()); + } else { + // TODO v3 has a hard limit of 20 hidden items (see Inventory#maxLocationSlots in the game client) + if(hiddenItems.size() == 20) { + logger.warn(SERVER_MARKER, "Upper hidden item limit has been reached. Certain hidden accessories might not work properly!"); + } + + hiddenItems.add(item.getId()); } - - hiddenItems.add(item.getId()); } items.put(id, item); itemsByCode.put(code, item); return true; } + + public static void registerItemRelationships() { + for(Item item : items.values()) { + // Record back relationship for piles + // TODO maybe there is a better configuration option for this. + if(item.hasUse(ItemUseType.PILE)) { + Item inventoryItem = item.getInventoryItem(); + if(inventoryItem != item) { + pilesByItem.put(inventoryItem, item); + } + } + } + } public static Item getItem(String id) { return items.getOrDefault(id, Item.AIR); @@ -79,6 +109,10 @@ public static Collection getItems() { public static List getItemsByCategory(String category) { return Collections.unmodifiableList(itemsByCategory.getOrDefault(category, Collections.emptyList())); } + + public static Item getPile(Item inventory) { + return pilesByItem.getOrDefault(inventory, Item.AIR); + } public static int getHiddenItemIndex(Item item) { return hiddenItems.indexOf(item.getId()); diff --git a/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java b/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java index 2bbe7f76..55fb797e 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/ItemUseType.java @@ -1,27 +1,38 @@ package brainwine.gameserver.item; +import brainwine.gameserver.item.usetypeconfig.*; +import brainwine.shared.JsonHelper; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import brainwine.gameserver.item.interactions.BatteryInteraction; import brainwine.gameserver.item.interactions.BurstInteraction; import brainwine.gameserver.item.interactions.ChangeInteraction; import brainwine.gameserver.item.interactions.ComposterInteraction; import brainwine.gameserver.item.interactions.ContainerInteraction; +import brainwine.gameserver.item.interactions.ConvertItemInteraction; import brainwine.gameserver.item.interactions.DialogInteraction; import brainwine.gameserver.item.interactions.ExpiatorInteraction; +import brainwine.gameserver.item.interactions.FertilizerInteraction; import brainwine.gameserver.item.interactions.GeckInteraction; import brainwine.gameserver.item.interactions.ItemInteraction; import brainwine.gameserver.item.interactions.LandmarkInteraction; import brainwine.gameserver.item.interactions.MinigameInteraction; import brainwine.gameserver.item.interactions.NoteInteraction; +import brainwine.gameserver.item.interactions.PileInteraction; +import brainwine.gameserver.item.interactions.QuipperInteraction; import brainwine.gameserver.item.interactions.RecyclerInteraction; import brainwine.gameserver.item.interactions.SpawnInteraction; import brainwine.gameserver.item.interactions.SpawnTeleportInteraction; +import brainwine.gameserver.item.interactions.SummoningCircleInteraction; import brainwine.gameserver.item.interactions.SwitchInteraction; import brainwine.gameserver.item.interactions.TargetTeleportInteraction; import brainwine.gameserver.item.interactions.TeleportInteraction; import brainwine.gameserver.item.interactions.TransmitInteraction; import brainwine.gameserver.item.interactions.WarmthInteraction; +import brainwine.gameserver.item.interactions.WorldMachineInteraction; +import brainwine.gameserver.item.interactions.XpSignInteraction; +import com.fasterxml.jackson.core.JsonProcessingException; /** * Much like with {@link Action}, block interactions depend on their use type. @@ -29,31 +40,47 @@ public enum ItemUseType { AFTERBURNER, + BATTERY(new BatteryInteraction(), BatteryConfig.class), BREATH, + BUILDING_EXTENSION, BURST(new BurstInteraction()), COMPOSTER(new ComposterInteraction()), CONTAINER(new ContainerInteraction()), CREATE_DIALOG(new DialogInteraction(true)), DESTROY, DIALOG(new DialogInteraction(false)), + DOWSING, EXPIATOR(new ExpiatorInteraction()), + EXTENDED_STEAMABLE(ExtendedSteamableConfig.class), GECK(new GeckInteraction()), GUARD, CHANGE(new ChangeInteraction()), + FERTILIZER(new FertilizerInteraction()), FIELDABLE, FLY, + GRINDER(new ConvertItemInteraction("Mill", "grind", Item::getGrind)), + HAZMAT, LANDMARK(new LandmarkInteraction()), + MEMORY, MINIGAME(new MinigameInteraction()), MOVE, MULTI, NOTE(new NoteInteraction()), PET, + PILE(new PileInteraction()), PLENTY, PROTECTED, PUBLIC, + REVENANT_DISH, + QUIPPER(new QuipperInteraction()), RECYCLER(new RecyclerInteraction()), + SMELTER(new ConvertItemInteraction("Forge", "smelt", Item::getSmelt)), + SUMMONING_CIRCLE(new SummoningCircleInteraction()), SPAWN(new SpawnInteraction()), SPAWN_TELEPORT(new SpawnTeleportInteraction()), + STEAM_SOURCE(SteamSourceConfig.class), + STRIPPER(new ConvertItemInteraction("Lathe", "strip", Item::getStrip)), + SUPPRESS_BOMB, SWITCH(new SwitchInteraction()), SWITCHED, TARGET_TELEPORT(new TargetTeleportInteraction()), @@ -62,24 +89,36 @@ public enum ItemUseType { TRANSMIT(new TransmitInteraction()), TRANSMITTED, WARMTH(new WarmthInteraction()), + WORLD_MACHINE(new WorldMachineInteraction()), + XP_SIGN(new XpSignInteraction()), ZONE_TELEPORT, @JsonEnumDefaultValue UNKNOWN; private final ItemInteraction interaction; + private final Class configType; - private ItemUseType(ItemInteraction interaction) { + private ItemUseType(ItemInteraction interaction, Class configType) { this.interaction = interaction; + this.configType = configType; + } + + private ItemUseType(Class configType) { + this(null, configType); + } + + private ItemUseType(ItemInteraction interaction) { + this(interaction, ItemUseTypeConfig.class); } private ItemUseType() { - this(null); + this(null, ItemUseTypeConfig.class); } @JsonCreator public static ItemUseType fromId(String id) { - String formatted = id.toUpperCase().replace(" ", "_"); + String formatted = id.toUpperCase().replace(" ", "_").replace("-", "_"); for(ItemUseType value : values()) { if(value.toString().equals(formatted)) { @@ -93,4 +132,38 @@ public static ItemUseType fromId(String id) { public ItemInteraction getInteraction() { return interaction; } + + public ItemUseTypeConfig parseConfig(Object use) throws JsonProcessingException { + if(configType.equals(ItemUseTypeConfig.class)) { + // Playing it safe here + return new ItemUseTypeConfig().setConfig(use); + } else { + // Handle the case where the use is set to true + Properties[] props = configType.getAnnotationsByType(Properties.class); + if(props.length == 0 || props[0].allowsDefault()) { + try { + // Some config types might want to parse the Boolean + ItemUseTypeConfig result = JsonHelper.readValue(use, configType).setConfig(use); + return result; + } catch(JsonProcessingException e) { + if(use instanceof Boolean) { + return getDefaultConfig(); + } else { + throw e; + } + } + } + + // Just try to parse and throw any exceptions + return JsonHelper.readValue(use, configType).setConfig(use); + } + } + + public ItemUseTypeConfig getDefaultConfig() { + try { + return configType.getConstructor().newInstance(); + } catch(Exception e) { + throw new RuntimeException("Fatal error in getting the default config for item use type " + this, e); + } + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/LazyItemGetter.java b/gameserver/src/main/java/brainwine/gameserver/item/LazyItemGetter.java index 6d75bb8f..40b069e2 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/LazyItemGetter.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/LazyItemGetter.java @@ -1,7 +1,14 @@ package brainwine.gameserver.item; import brainwine.gameserver.util.LazyGetter; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; + +@JsonSerialize(using = LazyItemGetter.Serializer.class) public class LazyItemGetter extends LazyGetter { public LazyItemGetter(String in) { @@ -12,4 +19,11 @@ public LazyItemGetter(String in) { public Item load() { return ItemRegistry.getItem(in); } + + public static class Serializer extends JsonSerializer { + @Override + public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(((LazyItemGetter)value).get().getId()); + } + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/MiningBonus.java b/gameserver/src/main/java/brainwine/gameserver/item/MiningBonus.java index 65d59a85..f0395086 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/MiningBonus.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/MiningBonus.java @@ -12,7 +12,9 @@ public class MiningBonus { private double chance; private Skill skill; private ItemGroup tool; - private LazyItemGetter item; + private ItemUseType accessory; + private int mod = -1; + private String item; private boolean doubleLoot; private String notification; @@ -30,9 +32,27 @@ public Skill getSkill() { public ItemGroup getTool() { return tool; } - - public Item getItem() { - return item == null ? Item.AIR : item.get(); + + public ItemUseType getAccessory() { + return accessory; + } + + public int getMod() { + return mod; + } + + public String getItem() { + return item; + } + + public Item computeItem(Item minedItem) { + if(this.item == null) { + return Item.AIR; + } + if(this.item.startsWith("-") && minedItem != null) { + return ItemRegistry.getItem(minedItem.getId() + this.item); + } + return ItemRegistry.getItem(this.item); } @JsonProperty("double") diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/LockWorldConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/LockWorldConsumable.java new file mode 100644 index 00000000..b3116a21 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/LockWorldConsumable.java @@ -0,0 +1,82 @@ +package brainwine.gameserver.item.consumables; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.InventoryMessage; +import brainwine.gameserver.server.messages.NotificationMessage; +import brainwine.gameserver.zone.Zone; +import brainwine.gameserver.zone.ZoneManager; + +public class LockWorldConsumable implements Consumable { + public static final int SMALL_WORLD_CROWN_REWARD = 25; + public static final int MEDIUM_WORLD_CROWN_REWARD = 50; + public static final int LARGE_WORLD_CROWN_REWARD = 100; + public static final int SMALL_MEDIUM_THRESHOLD = 1000 * 500; + public static final int MEDIUM_LARGE_THRESHOLD = 2000 * 1000; + + @Override + public void consume(Item item, Player player, Object details) { + Zone zone = player.getZone(); + if(zone == null) return; + + if(!player.isGodMode()) { + if(!player.getInventory().hasItem(item)) { + fail(player, item, null); + return; + } + + if(!zone.isOwner(player)) { + fail(player, item, "Sorry, you do not own this world."); + return; + } + } + + if(zone.getRules().isDeleted()) { + fail(player, item, "This world is already deleted."); + return; + } + + int crownReward = getCrownReward(zone); + + player.showDialog(new Dialog() + .addSection(new DialogSection().setText("You have chosen to delete this world in exchange of " + crownReward + " crowns.")) + .addSection(new DialogSection().setText("You will permanently lose access to this world. Are you sure you want to continue?")), + ans -> { + if(ans.length == 0) confirm(player, item, zone); + } + ); + } + + public void fail(Player player, Item item, String message) { + if(message != null) player.notify(message); + player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item))); + } + + public void confirm(Player player, Item item, Zone zone) { + int crownReward = getCrownReward(zone); + + if(!player.isGodMode()) { + if(!player.getInventory().hasItem(item)) { + fail(player, item, String.format("Sorry, you don't have any %ss.", item.getTitle())); + return; + } + + player.getInventory().removeItem(item, true); + player.addCrowns(crownReward); + } + + ZoneManager.markZoneForDeletion(zone, player); + + player.sendDelayedMessage(new NotificationMessage("This world is being deleted. Thank you for helping us free server storage. You are getting " + crownReward + "crowns as a reward.", NotificationType.POPUP), 3000); + } + + private int getCrownReward(Zone zone) { + int blockSize = zone.getWidth() * zone.getHeight(); + if(blockSize >= MEDIUM_LARGE_THRESHOLD) return LARGE_WORLD_CROWN_REWARD; + if(blockSize >= SMALL_MEDIUM_THRESHOLD) return MEDIUM_WORLD_CROWN_REWARD; + else return SMALL_WORLD_CROWN_REWARD; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/LootConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/LootConsumable.java new file mode 100644 index 00000000..fcab3d07 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/LootConsumable.java @@ -0,0 +1,109 @@ +package brainwine.gameserver.item.consumables; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.LazyItemGetter; +import brainwine.gameserver.loot.Loot; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.InventoryMessage; + +public class LootConsumable implements Consumable { + private LazyItemGetter defaultKey = new LazyItemGetter("consumables/lockboxkey"); + + @Override + public void consume(Item item, Player player, Object details) { + Item keyToUse = getKeyItemToUse(player, item); + if(item.isLocked() && keyToUse.isAir()) { + fail(player, item, "You need a key to unlock this " + item.getTitle() + "!"); + return; + } + + player.showDialog(new Dialog() + .setTitle("Opening " + item.getTitle()) + .addSection(new DialogSection().setText("Would you like to open this " + item.getTitle() + ( + item.isLocked() + ? " using a " + keyToUse.getTitle() + "?" + : "?" + ))) + , ans -> { + if(ans.length == 0) { + confirm(item, player); + } else { + fail(player, item, null); + } + } + ); + } + + private void confirm(Item item, Player player) { + if(!player.isGodMode() && !player.getInventory().hasItem(item)) { + fail(player, item, String.format("Sorry, you don't have any %ss.", item.getTitle())); + return; + } + + Item keyToUse = getKeyItemToUse(player, item); + String[] lootCategories = getLootCategories(item, keyToUse); + + if(lootCategories.length == 0) { + fail(player, item, "You need a key to unlock this " + item.getTitle() + "!"); + return; + } + + Loot loot = GameServer.getInstance().getLootManager().getRandomLoot(player, lootCategories); + if(loot == null) { + fail(player, item, "Couldn't find any loot for you."); + return; + } + + player.awardLoot(loot); + player.getInventory().removeItem(item, true); + if(!keyToUse.isAir()) player.getInventory().removeItem(keyToUse, true); + } + + private void fail(Player player, Item item, String message) { + if(message != null) { + player.notify(message); + } + player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item))); + } + + private Item getKeyItemToUse(Player player, Item lockbox) { + if(!lockbox.isLocked()) return Item.AIR; + + if(lockbox.getSelectiveLockedLoot() != null) { + for(String keyId : lockbox.getSelectiveLockedLoot().keySet()) { + Item key = ItemRegistry.getItem(keyId); + if(!key.isAir() && player.getInventory().hasItem(key)) { + return key; + } + } + + if(player.isGodMode() && !lockbox.getSelectiveLockedLoot().isEmpty()) { + return ItemRegistry.getItem(lockbox.getSelectiveLockedLoot().keySet().iterator().next()); + } + } + + if(player.isGodMode() || player.getInventory().hasItem(defaultKey.get())) { + return defaultKey.get(); + } + + return Item.AIR; + } + + private String[] getLootCategories(Item lockbox, Item key) { + if(!lockbox.isLocked() && lockbox.getSelectiveLockedLoot() == null) return lockbox.getLootCategories(); + + if(key.isAir()) return new String[0]; + + if(lockbox.getSelectiveLockedLoot() != null) { + return lockbox.getSelectiveLockedLoot().getOrDefault(key.getId(), new String[0]); + } + + if(!key.isAir()) return lockbox.getLootCategories(); + + return new String[0]; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillResetConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillResetConsumable.java index 00e9b2e8..292072f8 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillResetConsumable.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/SkillResetConsumable.java @@ -1,13 +1,10 @@ package brainwine.gameserver.item.consumables; -import java.util.Map.Entry; - import brainwine.gameserver.dialog.Dialog; import brainwine.gameserver.dialog.DialogHelper; import brainwine.gameserver.dialog.DialogSection; import brainwine.gameserver.item.Item; import brainwine.gameserver.player.Player; -import brainwine.gameserver.player.Skill; import brainwine.gameserver.server.messages.InventoryMessage; /** @@ -42,25 +39,9 @@ public void consume(Item item, Player player, Object details) { player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item))); return; } - - int pointsToRefund = 0; - - // Reset skill levels and calculate point refund total - for(Entry entry : player.getSkills().entrySet()) { - Skill skill = entry.getKey(); - int level = entry.getValue(); - - // Skip if skill hasn't been upgraded at all - if(level <= 1) { - continue; - } - - pointsToRefund += level - 1; - player.setSkillLevel(skill, 1); // Reset skill level - } - + + player.resetAllSkills(); player.getInventory().removeItem(item, true); // Remove the consumable - player.setSkillPoints(player.getSkillPoints() + pointsToRefund); // Refund skill points player.showDialog(DialogHelper.getDialog("skill_reset")); }); } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/StealthConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/StealthConsumable.java index aa3a0ab7..66d03d24 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/consumables/StealthConsumable.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/StealthConsumable.java @@ -2,6 +2,7 @@ import brainwine.gameserver.item.Item; import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.InventoryMessage; /** * Consumable handler for stealth cloaks @@ -10,7 +11,14 @@ public class StealthConsumable implements Consumable { @Override public void consume(Item item, Player player, Object details) { - player.getInventory().removeItem(item); + if("prosthetics".equals(item.getCategory()) && player.isMomentaryAccessoryOnCooldown(item)) { + player.notify(String.format("You can't use your %s yet!", item.getTitle())); + player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item))); + return; + } + if("consumables".equals(item.getCategory())) { + player.getInventory().removeItem(item); + } player.setStealth(true); float seconds = item.getPower(); diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/TeleportConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/TeleportConsumable.java index 038b9897..4ba4ab33 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/consumables/TeleportConsumable.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/TeleportConsumable.java @@ -42,7 +42,7 @@ public void consume(Item item, Player player, Object details) { return; } - player.getInventory().removeItem(item); + if(item.isConsumable()) player.getInventory().removeItem(item); player.teleport(x, y); } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/consumables/UnlockWorldConsumable.java b/gameserver/src/main/java/brainwine/gameserver/item/consumables/UnlockWorldConsumable.java new file mode 100644 index 00000000..e39930e0 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/consumables/UnlockWorldConsumable.java @@ -0,0 +1,75 @@ +package brainwine.gameserver.item.consumables; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.InventoryMessage; +import brainwine.gameserver.zone.Biome; +import brainwine.gameserver.zone.ZoneRules; +import brainwine.gameserver.zone.gen.ZoneGenerator; + +import java.time.temporal.ChronoUnit; + +public class UnlockWorldConsumable implements Consumable { + private static final String actionKey = "unlockWorld"; + @Override + public void consume(Item item, Player player, Object details) { + if (!player.isGodMode() && player.isActionOnCooldown(actionKey, 5L, ChronoUnit.MINUTES)) { + fail(player, item, "You can only use an " + item.getTitle() + " every 5 minutes."); + return; + } + + player.showDialog(new Dialog() + .setTitle("Using " + item.getTitle()) + .addSection(new DialogSection().setText("Would you like to use this " + item.getTitle() + "?")) + , ans -> { + if(ans.length == 0) { + confirm(item, player); + } else { + fail(player, item, null); + } + } + ); + } + + private void confirm(Item item, Player player) { + if(!player.getInventory().hasItem(item)) { + fail(player, item, String.format("Sorry, you don't have any %ss.", item.getTitle())); + return; + } + + player.recordActionTime(actionKey); + + Biome biome = Biome.getRandomBiome(); + int width = biome == Biome.DEEP ? 1200 : 2000; + int height = biome == Biome.DEEP ? 1000 : 600; + int seed = (int)(Math.random() * Integer.MAX_VALUE); + + ZoneGenerator generator = ZoneGenerator.getZoneGenerator(biome); + + player.getInventory().removeItem(item, true); + player.notify("Your zone is being generated. It should be ready soon!"); + generator.generateZoneAsync(biome, width, height, seed, zone -> { + if(zone == null) { + player.getInventory().addItem(item); + fail(player, item, "An unexpected error occurred while generating your zone. Your " + item.getTitle() + "is returned."); + } else { + zone.setOwner(player); + zone.setPrivate(true); + zone.setProtected(true); + zone.setRules(ZoneRules.getPrivateDefaults()); + GameServer.getInstance().getZoneManager().addZone(zone); + player.notify(String.format("Your zone '%s' is ready for exploration!", zone.getName())); + } + }); + } + + private void fail(Player player, Item item, String message) { + if(message != null) { + player.notify(message); + } + player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item))); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/BatteryInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/BatteryInteraction.java new file mode 100644 index 00000000..3ac539bd --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/BatteryInteraction.java @@ -0,0 +1,60 @@ +package brainwine.gameserver.item.interactions; + +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.item.usetypeconfig.BatteryConfig; +import brainwine.gameserver.player.Inventory; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; + +import java.util.HashMap; +import java.util.Map; + +public class BatteryInteraction implements ItemInteraction { + public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { + if(!entity.isPlayer()) return; + Player player = (Player)entity; + + Inventory inventory = player.getInventory(); + Item battery = ItemRegistry.getItem("accessories/battery"); + + // Check if player has the required items + if(!inventory.hasItem(battery)) { + player.notify("You don't have any batteries."); + return; + } + + Player owner = metaBlock != null ? metaBlock.getOwner() : null; + Map metaData = metaBlock != null ? new HashMap<>(metaBlock.getMetadata()) : new HashMap<>(); + + long addition = (long)(1000L * battery.getPower()); + long capacity = 5L * addition; + if(config instanceof Map) { + capacity = (long)(1000L * item.getStructuredUse(ItemUseType.BATTERY).getCapacity()); + } + + long currentTime = System.currentTimeMillis(); + long finalTime = MapHelper.getLong(metaData, "f", currentTime); + + long leftover = Math.max(0, finalTime - currentTime); + if(leftover + addition > capacity) { + player.notify("The tank is near capacity. Try installing the battery again after it depletes a bit."); + return; + } + + inventory.removeItem(battery, true); + metaData.put("f", currentTime + leftover + addition); + if(item.hasUse(ItemUseType.STEAM_SOURCE)) { + zone.getSteamManager().setSteamSourcePowered(x, y, true, owner); + } else { + zone.updateBlock(x, y, layer, item, 1, owner); + } + zone.setMetaBlock(x, y, item, owner, metaData); + zone.spawnEffect(x + 2.0F, y, "area steam", 10); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/BurstInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/BurstInteraction.java index 46eccdea..ec0f9d9b 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/BurstInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/BurstInteraction.java @@ -5,10 +5,13 @@ import brainwine.gameserver.entity.Entity; import brainwine.gameserver.item.DamageType; import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.item.Layer; import brainwine.gameserver.player.Player; import brainwine.gameserver.player.Skill; import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.util.MathUtils; import brainwine.gameserver.zone.Block; import brainwine.gameserver.zone.MetaBlock; import brainwine.gameserver.zone.Zone; @@ -55,6 +58,25 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i float range = MapHelper.getFloat(configMap, "range"); float damage = MapHelper.getFloat(configMap, "damage"); boolean destructive = MapHelper.getBoolean(configMap, "destructive"); + + // Mine suppression + if(effect != null && effect.startsWith("bomb")) { + for(MetaBlock suppressor : zone.getMetaBlocksWithUse(ItemUseType.SUPPRESS_BOMB)) { + if( + // The suppressor is close enough. + MathUtils.distance(suppressor.getX(), suppressor.getY(), x, y) <= suppressor.getItem().getPower() + // The suppressor is powered. + && zone.getBlock(suppressor.getX(), suppressor.getY()).getFrontMod() > 0 + ) { + Item replacement = ItemRegistry.getItem(item.getId() + "-inert"); + if(!replacement.isAir()) { + zone.updateBlock(x, y, layer, replacement); + } + + return; + } + } + } // Create explosion and destroy block zone.explode(x, y, range, null, destructive, damage, damageType, effect); diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/ContainerInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ContainerInteraction.java index cda7e526..3a6afbd8 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/ContainerInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ContainerInteraction.java @@ -39,7 +39,7 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i // Check if container is protected if(item.hasUse(ItemUseType.FIELDABLE) && (zone.isBlockProtected(x, y, player) || (dungeonId != null && zone.isDungeonIntact(dungeonId)))) { - player.notify("This container is secured by protectors in the area."); + player.notify(zone.getDungeonType(dungeonId).getContainerProtectedMessage()); return; } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/ConvertItemInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ConvertItemInteraction.java new file mode 100644 index 00000000..77244f7c --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ConvertItemInteraction.java @@ -0,0 +1,99 @@ +package brainwine.gameserver.item.interactions; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.TradeSession; +import brainwine.gameserver.server.messages.InventoryMessage; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; + +import java.util.Collections; +import java.util.function.Function; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; + +public class ConvertItemInteraction implements ItemInteraction { + private String machineName = "Converter"; + private String actionName = "convert"; + private Function> getConversion; + + public ConvertItemInteraction(String machineName, String actionName, Function> getConversion) { + this.machineName = machineName; + this.actionName = actionName; + this.getConversion = getConversion; + } + + @Override + public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { + if(data == null || data.length == 0) return; + int itemCode = -1; + // { code } + if(data.length == 1 && data[0] instanceof Number) itemCode = (int)data[0]; + // { "item", code } + else if(data.length >= 2 && data[1] instanceof Number) itemCode = (int)data[1]; + if(itemCode < 0) return; + + if(!(entity instanceof Player)) return; + + Item droppedItem = ItemRegistry.getItem(itemCode); + Player player = (Player) entity; + + // Check if the machine is receiving steam + if(item.usesSteam() || item.hasUse(ItemUseType.EXTENDED_STEAMABLE)) { + if(!zone.isBlockPowered(x, y)) { + player.notify("You need to supply the machine with steam first."); + return; + } + } + + // Check if the item can be converted + if(getConversion.apply(droppedItem).isEmpty()) { + player.showDialog(DialogHelper.messageDialog( + StringUtils.capitalize("Cannot " + actionName), + "Sorry but you can't " + actionName + " " + droppedItem.getTitle() + ".")); + return; + } + + // Check if player has any of the dropped item + if(!player.getInventory().hasItem(droppedItem)) { + player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(droppedItem))); + return; + } + + Dialog dialog = new Dialog().setTitle(this.machineName).addSection(new DialogSection().addItem(new DialogListItem().setItem(droppedItem.getCode()).setText(droppedItem.getTitle()))).addSection(TradeSession.Dialogs.createQuantitySelector(player.getInventory().getQuantity(droppedItem)).setTitle("How many would you like to " + actionName + "?")).setActions(actionName); + + player.showDialog(dialog, ans -> { + if(ans.length > 0 && !"cancel".equals(ans[0])) { + try { + int quantity = Integer.parseInt(ans[0].toString()); + confirm(player, item, droppedItem, quantity); + } catch(NumberFormatException ignored) { + } + } + }); + } + + public void confirm(Player player, Item item, Item droppedItem, int quantity) { + if(getConversion.apply(droppedItem).isEmpty()) { + return; + } + + if(player.getInventory().hasItem(droppedItem, quantity)) { + player.getInventory().removeItem(droppedItem, quantity, true); + for(Map.Entry entry : getConversion.apply(droppedItem).entrySet()) { + player.getInventory().addItem(entry.getKey(), quantity * entry.getValue(), true); + } + } else { + player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(droppedItem))); + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/DialogInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/DialogInteraction.java index 9741c06f..d2388fb6 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/DialogInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/DialogInteraction.java @@ -2,9 +2,12 @@ import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import brainwine.gameserver.GameServer; +import brainwine.gameserver.chat.PlayerProfanity; import brainwine.gameserver.entity.Entity; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.Layer; @@ -62,6 +65,7 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i } if(sections != null && data.length == sections.size()) { + Map sanitizedSegments = new LinkedHashMap<>(); for(int i = 0; i < sections.size(); i++) { Map section = sections.get(i); String key = MapHelper.getString(section, "input.key"); @@ -84,6 +88,10 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i if(max > 0 && text.length() > max) { text = text.substring(0, max); } + + if(!player.isGodMode() && shouldFilterValue(item, key)) { + sanitizedSegments.put(key, text); + } metadata.put(key, text); break; @@ -112,6 +120,12 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i } } } + + boolean anythingFiltered = GameServer.getInstance().getProfanityManager().filterAll(sanitizedSegments); + if(anythingFiltered) { + PlayerProfanity.punish(player); + metadata.putAll(sanitizedSegments); + } } // Set configured flag @@ -122,4 +136,18 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i // Update meta block zone.setMetaBlock(x, y, item, player, metadata); } + + public boolean shouldFilterValue(Item item, String sectionKey) { + if(sectionKey != null) { + if(item.getId().startsWith("mechanical")) { + return sectionKey.equalsIgnoreCase("m"); + } + + if(item.getId().startsWith("signs")) { + return sectionKey.equalsIgnoreCase("msg") || sectionKey.toLowerCase().matches("^[tT]\\d*$"); + } + } + + return false; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/ExpiatorInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ExpiatorInteraction.java index 5fb928ae..dcb1a445 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/ExpiatorInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/ExpiatorInteraction.java @@ -5,6 +5,9 @@ import java.util.stream.Collectors; import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.item.Layer; import brainwine.gameserver.player.NotificationType; import brainwine.gameserver.player.Player; @@ -33,9 +36,16 @@ public void interact(Zone zone, Player player, int x, int y) { player.notify("No ghosts in range."); return; } - - List protectors = zone.getMetaBlocksWithItem("hell/dish"); - Collections.shuffle(protectors); + + // This allows the server operator to choose what infernal protectors do. + Item hellDish = ItemRegistry.getItem("hell/dish"); + List protectors; + if(hellDish.hasUse(ItemUseType.REVENANT_DISH)) { + protectors = Collections.emptyList(); + } else { + protectors = zone.getMetaBlocksWithItem(hellDish); + Collections.shuffle(protectors); + } // Expiate nearby ghosts for(Entity ghost : ghosts) { diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/FertilizerInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/FertilizerInteraction.java new file mode 100644 index 00000000..22de9d02 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/FertilizerInteraction.java @@ -0,0 +1,14 @@ +package brainwine.gameserver.item.interactions; + +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; + +public class FertilizerInteraction implements ItemInteraction { + @Override + public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { + zone.getGrowthManager().fertilize(x, y); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/PileInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/PileInteraction.java new file mode 100644 index 00000000..b3dc75c4 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/PileInteraction.java @@ -0,0 +1,38 @@ +package brainwine.gameserver.item.interactions; + +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Block; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; + +public class PileInteraction implements ItemInteraction { + + @Override + public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { + if(!entity.isPlayer()) return; + Player player = (Player)entity; + int pile = (int)config; + + if(!player.getInventory().hasItem(item.getInventoryItem(), pile)) { + fail(player, "You don't have enough of this item to pile any more."); + return; + } + + Block block = zone.getBlock(x, y); + + if(block.getMod(layer) >= item.getMaxMod()) { + fail(player, "This pile has gotten too big."); + return; + } + + player.getInventory().removeItem(item.getInventoryItem(), pile, true); + zone.updateBlockMod(x, y, layer, block.getFrontMod() + 1); + } + + private void fail(Player player, String message) { + player.notify(message); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/QuipperInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/QuipperInteraction.java new file mode 100644 index 00000000..de5c1034 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/QuipperInteraction.java @@ -0,0 +1,33 @@ +package brainwine.gameserver.item.interactions; + +import brainwine.gameserver.Fake; +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.server.messages.EffectMessage; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; + +import java.time.temporal.ChronoUnit; +import java.util.NoSuchElementException; + +public class QuipperInteraction implements ItemInteraction { + private static final String actionName = "quipper"; + + @Override + public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { + if(!(config instanceof String)) return; + + if(zone.isActionOnCooldown(actionName, 500, ChronoUnit.MILLIS)) return; + + String message; + try { + message = Fake.get((String)config); + } catch(NoSuchElementException e) { + message = "I don't know what to say."; + } + + zone.sendMessage(new EffectMessage(x + 0.5f, y - 0.5f, "emote", message)); + zone.recordActionTime(actionName); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/SummoningCircleInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SummoningCircleInteraction.java new file mode 100644 index 00000000..4b5c5950 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SummoningCircleInteraction.java @@ -0,0 +1,157 @@ +package brainwine.gameserver.item.interactions; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.input.DialogSelectInput; +import brainwine.gameserver.dialog.input.DialogTextIndexInput; +import brainwine.gameserver.dialog.input.DialogTextInput; +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.util.PickRandom; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; +import brainwine.gameserver.zone.dynamics.EvokerInvasion; +import brainwine.gameserver.zone.dynamics.SummonedInvasion; +import brainwine.gameserver.zone.dynamics.ZoneDynamic; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class SummoningCircleInteraction implements ItemInteraction { + static final List words = Arrays.asList("lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt in culpa qui officia deserunt mollit anim id est laborum".split(" ")); + @Override + public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { + if(!entity.isPlayer()) return; + Player player = (Player)entity; + + long configCooldown = 0; + boolean configUseSpell = false; + + if(config instanceof Map) { + configCooldown = MapHelper.getLong((Map) config, "cooldown", 0L); + configUseSpell = MapHelper.getBoolean((Map)config, "spell", false); + } + + long cooldown = configCooldown; + boolean useSpell = !player.isGodMode() && configUseSpell; + + if(cooldown > 0 && !player.isGodMode()) { + Deque dynamics = zone.getDynamicsManager().getOngoingDynamics(SummonedInvasion.class); + + int count = dynamics.size(); + int circles = zone.getMetaBlocksWithUse(ItemUseType.SUMMONING_CIRCLE).size(); + long lastUsed = dynamics.isEmpty() ? 0L : dynamics.peekFirst().getStartTime(); + + if(count >= circles && lastUsed + cooldown >= System.currentTimeMillis()) { + player.notify("This item is on cooldown. Either place more summoning circles or wait."); + return; + } + } + + String spell = useSpell ? String.join(" ", PickRandom.sampleWithoutReplacement(words, 3 + (int) (8 * Math.random()))) : ""; + + Dialog dialog = new Dialog().addSection(new DialogSection().setTitle("Summoning")); + if(useSpell) { + dialog + .addSection(new DialogSection().setTitle("Type out the following spell correctly.")) + .addSection(new DialogSection().setText(spell)) + .addSection(new DialogSection().setInput(new DialogTextInput().setMaxLength(spell.length() + 5).setKey("spell"))); + } + + dialog.addSection(new DialogSection().setInput(new DialogSelectInput().setOptions("Revenants", "Revenant Lord").setKey("difficulty"))); + + dialog.addSection(new DialogSection().setText("Which players should be cursed?")); + for(Player other : zone.getPlayers()) { + dialog.addSection(new DialogSection() + .setText(other.getName()) + .setInput(new DialogTextIndexInput() + .setOptions("No", "Yes") + .setKey("player." + other.getName()) + .setValue(player == other ? 0 : 1) + ) + ); + } + + player.showDialog(dialog, ans -> { + boolean accepted = false; + String option = "Revenants"; + String inputtedSpell = ""; + List players = new ArrayList<>(); + + if(ans.length == 0 || "cancel".equals(ans[0])) { + return; + } + + int counter = 0; + for(DialogSection section : dialog.getSections()) { + if(counter >= ans.length) { + player.notify("Invalid input!"); + return; + } + + if(section.getInput() != null) { + if("difficulty".equals(section.getInput().getKey())) { + if(ans[counter] instanceof String) option = (String)ans[counter]; + counter++; + continue; + } + + if("spell".equals(section.getInput().getKey())) { + if(ans[counter] instanceof String) inputtedSpell = (String)ans[counter]; + counter++; + continue; + } + + if(section.getInput().getKey() != null && section.getInput().getKey().startsWith("player.")) { + if(Objects.equals(1, ans[counter])) { + String username = section.getInput().getKey().substring("player.".length()); + Player other = zone.getPlayer(username); + if(other != null) { + players.add(other); + } + } + counter++; + } + } + } + + if(players.isEmpty()) { + player.notify("OK, not cursing anyone."); + return; + } + + if(useSpell) { + if(spell.equalsIgnoreCase(inputtedSpell)) { + accepted = true; + } + } else { + accepted = true; + } + + int numWaves = 2 + (int) (4 * Math.random()); + int difficulty = 3; + if("Revenant Lord".equals(option)) { + difficulty = 5; + numWaves = 1; + } + + if(accepted) { + player.notify("Summoning " + option); + zone.getDynamicsManager().beginDynamic(new SummonedInvasion(zone, new ArrayList<>(players), difficulty, numWaves)); + } else { + player.notify("Too bad, you casted the wrong spell!"); + zone.getDynamicsManager().beginDynamic( + new EvokerInvasion(zone, player, zone.getMassSpawnerConfiguration().getDifficulty(), numWaves) + ); + } + }); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/SwitchInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SwitchInteraction.java index a2eb16fd..5adb2eb7 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/SwitchInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/SwitchInteraction.java @@ -5,6 +5,8 @@ import java.util.List; import java.util.Map; +import brainwine.gameserver.anticheat.AnticheatManager; +import brainwine.gameserver.anticheat.ExploderFarm; import org.apache.commons.text.WordUtils; import brainwine.gameserver.entity.Entity; @@ -23,7 +25,6 @@ * Interaction handler for switches */ public class SwitchInteraction implements ItemInteraction { - @Override public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { @@ -161,7 +162,30 @@ private void switchExploder(Zone zone, Entity entity, MetaBlock metaBlock) { // Create explosion DamageType damageType = type.equalsIgnoreCase("electric") ? DamageType.ENERGY : DamageType.fromName(type); String effect = String.format("bomb-%s", type.toLowerCase()); + + // Farm mitigation + ExploderFarm exploderFarm = AnticheatManager.getConfig().getExploderFarm(); + if(exploderFarm.isEnabled()) { + zone.setXpMultiplier(exploderFarm.getXpFactor()); + zone.setEntityShouldDrop(countExploderDrops(metaBlock, exploderFarm.getLootCounterMax())); + } + zone.explode(x, y, 6, entity, false, 6, damageType, effect); + zone.setEntityShouldDrop(true); + zone.setXpMultiplier(1.0); + } + + /** Prevents the explosion from giving loot unless it is every few interactions. */ + private boolean countExploderDrops(MetaBlock metaBlock, int every) { + int current = metaBlock.getIntProperty("d"); + boolean result = current + 1 >= every; + if(result) { + metaBlock.setProperty("d", 0); + } else { + metaBlock.setProperty("d", current + 1); + } + + return result; } private void switchSign(Zone zone, Entity entity, MetaBlock metaBlock, MetaBlock switchMeta) { @@ -184,7 +208,7 @@ private void switchSign(Zone zone, Entity entity, MetaBlock metaBlock, MetaBlock } } - // Update sign text + // Update sign text String separator = "\n"; String[] keys = {"t1", "t2", "t3", "t4"}; String[] segments = WordUtils.wrap(message, 20, separator, true).split(separator, 4); diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java index a8fb199a..65df027c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java @@ -11,6 +11,8 @@ import brainwine.gameserver.zone.MetaBlock; import brainwine.gameserver.zone.Zone; +import java.util.List; + public class TargetTeleportInteraction implements ItemInteraction { @Override @@ -75,12 +77,16 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i player.notify("That area hasn't been explored yet."); return; } + + List protectors = targetZone.getMetaBlocks( + m -> m.hasOwner() + && m.getItem().getId().startsWith("mechanical/dish")); // Check area protection - if(!player.isGodMode() && targetZone.isBlockProtected(targetX, targetY, player)) { + if(!player.isGodMode() && targetZone.isBlockProtectedByField(targetX, targetY, player, false, protectors)) { Player owner = metaBlock.getOwner(); int setting = metaBlock.getIntProperty("pt"); - boolean ownerCanEdit = !targetZone.isBlockProtected(targetX, targetY, owner); + boolean ownerCanEdit = !targetZone.isBlockProtectedByField(targetX, targetY, owner, false, protectors); // Check protection entry setting if(owner == null || !ownerCanEdit || setting == 0 || (setting == 1 && !owner.isFollowing(player))) { diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/WorldMachineInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/WorldMachineInteraction.java new file mode 100644 index 00000000..eab30cff --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/WorldMachineInteraction.java @@ -0,0 +1,114 @@ +package brainwine.gameserver.item.interactions; + +import brainwine.gameserver.command.CommandAccessLevel; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.WorldMachineConfiguration; +import brainwine.gameserver.zone.Zone; + +public class WorldMachineInteraction implements ItemInteraction { + + @Override + public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { + Object itemUse = item.getUse(ItemUseType.WORLD_MACHINE); + if(!(entity instanceof Player) || !(itemUse instanceof String)) return; + + Player player = (Player) entity; + + if(!zone.isOwner(player) && item.hasUse(ItemUseType.PUBLIC)) { + if(canInteractPublicly(player, zone, item, x, y)) { + switch((String)itemUse) { + case "holograph": + zone.getHolographConfiguration().interactPublicly(player, zone, item); + break; + } + } + return; + } + + if(!canInteract((Player) entity, zone, x, y)) return; + + player.showDialog(DialogHelper.getDialog("world_machines." + itemUse + ".menu"), ans -> { + if(ans.length == 0 || !(ans[0] instanceof String)) return; + + WorldMachineConfiguration machine = null; + switch((String) itemUse) { + case "spawner": + machine = zone.getMassSpawnerConfiguration(); + break; + case "teleport": + machine = zone.getMassTeleporterConfiguration(); + break; + case "weather": + machine = zone.getWeatherMachineConfiguration(); + break; + case "holograph": + machine = zone.getHolographConfiguration(); + break; + } + if(machine == null) return; + + switch ((String) ans[0]) { + case "configure": + machine.configure(player, zone, item, x, y); + break; + case "move": + move(player, zone, metaBlock); + break; + case "dismantle": + dismantle(player, zone, x, y); + break; + default: + machine.handleCommand(player, zone, item, (String)ans[0]); + break; + } + }); + } + + public boolean canInteract(Player player, Zone zone, int x, int y) { + if(!player.isGodMode() && !zone.isOwner(player)) { + player.notify("Sorry, you do not own this world."); + return false; + } + + if(zone.getBlock(x, y).getFrontMod() == 0) { + player.notify("You need to supply the machine with steam first."); + return false; + } + + return true; + } + + public boolean canInteractPublicly(Player player, Zone zone, Item item, int x, int y) { + Object itemUse = item.getUse(ItemUseType.WORLD_MACHINE); + if(!(itemUse instanceof String)) return false; + + // Only world machines with public use can be used publicly + if(!item.hasUse(ItemUseType.PUBLIC)) return false; + + if(player.getZone() != zone) return false; + + if(zone.getBlock(x, y).getFrontMod() == 0) { + player.notify("You need to supply the machine with steam first."); + return false; + } + + return true; + } + + public void move(Player player, Zone zone, MetaBlock metaBlock) { + player.setTransmittableBlock(metaBlock); + player.notify("Place a beacon to move the machine. The machine's lower left corner will replace the beacon."); + } + + public void dismantle(Player player, Zone zone, int x, int y) { + if(canInteract(player, zone, x, y)) { + player.notify("World machines can not yet be dismantled, but this feature is coming soon!"); + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/XpSignInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/XpSignInteraction.java new file mode 100644 index 00000000..b56d97f4 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/XpSignInteraction.java @@ -0,0 +1,41 @@ +package brainwine.gameserver.item.interactions; + +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.zone.MetaBlock; +import brainwine.gameserver.zone.Zone; + +import java.util.HashMap; +import java.util.Map; + +public class XpSignInteraction implements ItemInteraction { + + @Override + public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item item, int mod, MetaBlock metaBlock, Object config, Object[] data) { + if(!entity.isPlayer()) return; + + // Do nothing if data is invalid + if(data != null) { + return; + } + + Player player = (Player)entity; + + Map v = MapHelper.getMap(metaBlock.getMetadata(), "v"); + if(v != null && v.containsKey(player.getDocumentId())) { + player.notify("You have already gotten your XP from this."); + return; + } + + Map currentVotes = MapHelper.getMap(metaBlock.getMetadata(), "v", new HashMap<>()); + currentVotes.put(player.getDocumentId(), System.currentTimeMillis()); + metaBlock.setProperty("v", currentVotes); + metaBlock.setProperty("vc", config); + zone.sendBlockMetaUpdate(metaBlock); + + player.addExperience((int)config); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/BatteryConfig.java b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/BatteryConfig.java new file mode 100644 index 00000000..5b764c25 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/BatteryConfig.java @@ -0,0 +1,15 @@ +package brainwine.gameserver.item.usetypeconfig; + +@Properties +public class BatteryConfig extends ItemUseTypeConfig { + double capacity = 50.0; + + public double getCapacity() { + return capacity; + } + + @Override + public String toString() { + return "BatteryConfig{" + "capacity=" + capacity + '}'; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/ExtendedSteamableConfig.java b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/ExtendedSteamableConfig.java new file mode 100644 index 00000000..26d23722 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/ExtendedSteamableConfig.java @@ -0,0 +1,37 @@ +package brainwine.gameserver.item.usetypeconfig; + +import brainwine.gameserver.item.Item; +import brainwine.gameserver.util.Vector2i; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Arrays; +import java.util.List; + +@Properties(allowsDefault = false) +public class ExtendedSteamableConfig extends ItemUseTypeConfig { + @JsonProperty("off variant") + String offVariantId = Item.AIR.getId(); + + @JsonProperty("on variant") + String onVariantId = Item.AIR.getId(); + + @JsonProperty("inlets") + List inlets = Arrays.asList(new Vector2i(-1, 0)); + + public String getOffVariantId() { + return offVariantId; + } + + public String getOnVariantId() { + return onVariantId; + } + + public List getInlets() { + return inlets; + } + + @Override + public String toString() { + return "ExtendedSteamableConfig{" + "offVariantId='" + offVariantId + '\'' + ", onVariantId='" + onVariantId + '\'' + ", inlets=" + inlets + '}'; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/ItemUseTypeConfig.java b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/ItemUseTypeConfig.java new file mode 100644 index 00000000..6cda4aef --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/ItemUseTypeConfig.java @@ -0,0 +1,15 @@ +package brainwine.gameserver.item.usetypeconfig; + +@Properties +public class ItemUseTypeConfig { + protected Object config = null; + + public Object getConfig() { + return config; + } + + public ItemUseTypeConfig setConfig(Object config) { + this.config = config; + return this; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/Properties.java b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/Properties.java new file mode 100644 index 00000000..d823003a --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/Properties.java @@ -0,0 +1,5 @@ +package brainwine.gameserver.item.usetypeconfig; + +public @interface Properties { + boolean allowsDefault() default true; +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/SteamSourceConfig.java b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/SteamSourceConfig.java new file mode 100644 index 00000000..574a29b4 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/usetypeconfig/SteamSourceConfig.java @@ -0,0 +1,68 @@ +package brainwine.gameserver.item.usetypeconfig; + +import brainwine.gameserver.item.Item; +import brainwine.gameserver.util.Pair; +import brainwine.gameserver.util.Vector2i; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Arrays; +import java.util.List; + +@Properties(allowsDefault = false) +public class SteamSourceConfig extends ItemUseTypeConfig { + @JsonProperty("off variant") + String offVariantId = Item.AIR.getId(); + + @JsonProperty("on variant") + String onVariantId = Item.AIR.getId(); + + @JsonProperty("outlets") + List outlets; + + public SteamSourceConfig() { + this.outlets = Arrays.asList(new Outlet(new Vector2i(-1, 0), 3)); + } + + @JsonCreator + public SteamSourceConfig(List outlets) { + this.outlets = outlets; + } + + public String getOffVariantId() { + return offVariantId; + } + + public String getOnVariantId() { + return onVariantId; + } + + public List getOutlets() { + return outlets; + } + + public static class Outlet { + Vector2i position = new Vector2i(0, 0); + int direction = 0; + + public Outlet() {} + + public Outlet(Vector2i position, int direction) { + this.position = position; + this.direction = direction; + } + + @JsonCreator + public Outlet(Pair def) { + this(def.getFirst(), def.getLast()); + } + + public Vector2i getPosition() { + return position; + } + + public int getDirection() { + return direction; + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/loot/LootManager.java b/gameserver/src/main/java/brainwine/gameserver/loot/LootManager.java index 3e648a15..eb07726d 100644 --- a/gameserver/src/main/java/brainwine/gameserver/loot/LootManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/loot/LootManager.java @@ -67,7 +67,7 @@ public List getEligibleLoot(Biome biome, Set ignore, Collection categories.contains(entry.getKey())) .map(Entry::getValue) .flatMap(Collection::stream) - .filter(loot -> (loot.getBiome() == null || loot.getBiome() == biome) && !ignore.containsAll(loot.getItems().keySet())) + .filter(loot -> (loot.getBiome() == null || loot.getBiome() == biome) && (loot.getItems().isEmpty() || !ignore.containsAll(loot.getItems().keySet()))) .collect(Collectors.toList()); return eligibleLoot; } diff --git a/gameserver/src/main/java/brainwine/gameserver/order/Order.java b/gameserver/src/main/java/brainwine/gameserver/order/Order.java new file mode 100644 index 00000000..a9d14a7f --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/order/Order.java @@ -0,0 +1,84 @@ +package brainwine.gameserver.order; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class Order { + private static String[] tierNames = { "Iron", "Brass", "Sapphire", "Ruby", "Onyx", "Platinum" }; + @JsonIgnore + private String title; + @JsonProperty("induction_message") + private String inductionMessage; + @JsonProperty("advancement_message") + private String advancementMessage; + @JsonProperty + private String key; + @JsonProperty + private boolean hidden; + @JsonProperty + private List tiers; + + public void advance(Player player) { + final int initialLevel = player.getOrders().getOrDefault(key, 0); + int currentLevel = initialLevel; + + while(currentLevel < tiers.size()) { + int previousLevel = currentLevel; + currentLevel = advanceOnce(player, currentLevel); + if(previousLevel == currentLevel) break; + } + + if(currentLevel > initialLevel && !hidden) { + String dialogTitle, message, peerMessage; + if(initialLevel == 0) { + dialogTitle = OrderManager.getInductionTitle(); + message = inductionMessage; + peerMessage = player.getName() + " " + OrderManager.getPeerInductionMessage() + " " + title; + } else { + dialogTitle = OrderManager.getAdvancementTitle(); + message = advancementMessage; + peerMessage = player.getName() + " " + OrderManager.getPeerAdvancementMessage() + " " + title; + } + + player.showDialog(DialogHelper.messageDialog(dialogTitle, message)); + player.notifyPeers(peerMessage, NotificationType.SYSTEM); + GameServer.getInstance().getPusher().handlePlayerMessage(player, peerMessage); + } + } + + public int advanceOnce(Player player, int currentLevel) { + if (currentLevel == tiers.size()) return currentLevel; + OrderTier nextTier = tiers.get(currentLevel); + player.getOrders().put(key, nextTier.satisfies(player) ? currentLevel + 1 : currentLevel); + return player.getOrders().get(key); + } + + public DialogSection getStatDialogSection(Player player) { + int currentTier = player.getOrders().getOrDefault(key, 0); + String tierName = currentTier < 1 ? "" : tierNames[Math.min(currentTier - 1, tierNames.length - 1)] + " "; + DialogSection section = new DialogSection().setTitle(tierName + title); + if(currentTier < tiers.size()) { + tiers.get(currentTier).addDialogItems(player, section); + } + return section; + } + + public String getKey() { + return key; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/order/OrderManager.java b/gameserver/src/main/java/brainwine/gameserver/order/OrderManager.java new file mode 100644 index 00000000..5eeb4245 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/order/OrderManager.java @@ -0,0 +1,105 @@ +package brainwine.gameserver.order; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import static brainwine.shared.LogMarkers.SERVER_MARKER; + +public class OrderManager { + private static Map orders = new HashMap<>(); + private static Map ordersByTitle = new HashMap<>(); + private static Map statFormat = new HashMap<>(); + private static String inductionTitle = "You've been inducted into an Order!"; + private static String advancementTitle = "You've advanced in an Order!"; + private static String peerInductionMessage = "has been inducted into the"; + private static String peerAdvancementMessage = "has advanced within the"; + private static final Logger logger = LogManager.getLogger(); + + private OrderManager() {} + + public static void loadOrders() { + try { + Map ordersMap = new HashMap<>(MapHelper.getMap(GameConfiguration.getBaseConfig(), "orders")); + Map common = MapHelper.getMap(ordersMap, "all"); + + if (common != null) { + inductionTitle = MapHelper.getString(common, "induction_title", inductionTitle); + advancementTitle = MapHelper.getString(common, "advancement_title", advancementTitle); + peerInductionMessage = MapHelper.getString(common, "peer_induction_message", peerInductionMessage); + peerAdvancementMessage = MapHelper.getString(common, "peer_advancement_message", peerAdvancementMessage); + } + + ordersMap.remove("all"); + + Map stat = MapHelper.getMap(ordersMap, "stat"); + + if(stat != null) { + statFormat.putAll(stat); + } + + ordersMap.remove("stat"); + + ordersByTitle.putAll(JsonHelper.readValue(ordersMap, new TypeReference>() {})); + + for(Map.Entry orderPair : ordersByTitle.entrySet()) { + orderPair.getValue().setTitle(orderPair.getKey()); + orders.put(orderPair.getValue().getKey(), orderPair.getValue()); + } + } catch (IOException e) { + logger.error(SERVER_MARKER, "Failed to load orders", e); + } + } + + public static void advance(Player player) { + for(Order order : orders.values()) { + order.advance(player); + } + } + + public static Map getOrders() { + return Collections.unmodifiableMap(orders); + } + + public static Map getOrders(Player player) { + return player.getOrders().entrySet().stream() + .filter(e -> e.getValue() > 0 && orders.containsKey(e.getKey())) + .collect(Collectors.toMap(e -> orders.get(e.getKey()), Map.Entry::getValue)); + } + + public static String getOrderKeyFromTitle(String title) { + Order order = ordersByTitle.get(title); + + return order == null ? null : order.getKey(); + } + + public static String getStatFormat(String requirement) { + return statFormat.getOrDefault(requirement, "%d of " + requirement); + } + + public static String getInductionTitle() { + return inductionTitle; + } + + public static String getAdvancementTitle() { + return advancementTitle; + } + + public static String getPeerInductionMessage() { + return peerInductionMessage; + } + + public static String getPeerAdvancementMessage() { + return peerAdvancementMessage; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/order/OrderTier.java b/gameserver/src/main/java/brainwine/gameserver/order/OrderTier.java new file mode 100644 index 00000000..f7d204d3 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/order/OrderTier.java @@ -0,0 +1,97 @@ +package brainwine.gameserver.order; + +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.entity.EntityGroup; +import brainwine.gameserver.entity.EntityRegistry; +import brainwine.gameserver.player.Player; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class OrderTier { + @JsonProperty + private Map requirements = new HashMap<>(); + + private int getPlayerStat(Player player, String requirement) { + int compare = Integer.MAX_VALUE; + switch(requirement) { + case "level": + compare = player.getLevel(); + break; + case "progress/chunks explored": + compare = player.getStatistics().getAreasExplored(); + break; + case "progress/teleporters discovered": + compare = player.getStatistics().getTeleporterDiscoveries(); + break; + case "progress/creatures killed": + compare = player.getStatistics().getTotalKills(); + break; + case "players_killed": + // TODO: related to KillerAchievement + break; + case "progress/dungeons raided": + compare = player.getStatistics().getDungeonsRaided(); + break; + case "progress/automata killed": + compare = player.getStatistics().getKills(EntityGroup.AUTOMATA); + break; + case "progress/supernatural killed": + compare = player.getStatistics().getKills(EntityGroup.SUPERNATURAL); + break; + case "progress/inhibitors activated": + compare = player.getStatistics().getEvokersInhibited(); + break; + case "progress/brains killed": + compare = player.getStatistics().getKills(EntityGroup.BRAINS); + break; + case "brain_lords_killed": + compare = player.getStatistics().getKills(EntityRegistry.getEntityConfig("brains/large")); + break; + case "crowns_spent": + compare = player.getStatistics().getCrownsSpent(); + break; + } + + return compare; + } + + public boolean satisfies(Player player) { + for(String requirement : requirements.keySet()) { + if(getPlayerStat(player, requirement) < requirements.get(requirement)) { + return false; + } + } + + return true; + } + + private String joinWithAnd(List strings) { + if(strings.isEmpty()) return "nothing"; + if(strings.size() < 2) return strings.stream().collect(Collectors.joining(" and ")); + + return strings.stream().limit(strings.size() - 1).collect(Collectors.joining(", ")) + + " and " + strings.get(strings.size() - 1); + } + + public void addDialogItems(Player player, DialogSection section) { + List strings = new ArrayList<>(); + for(Map.Entry e : requirements.entrySet()) { + String requirement = e.getKey(); + int requiredProgress = e.getValue(); + int progress = getPlayerStat(player, requirement); + + if(progress >= requiredProgress) continue; + + String format = OrderManager.getStatFormat(requirement); + strings.add(format.replace("%d", Integer.toString(requiredProgress - progress))); + } + + section.setText(StringUtils.capitalize(joinWithAnd(strings)) + " until the next rank of your order!"); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/player/ChatType.java b/gameserver/src/main/java/brainwine/gameserver/player/ChatType.java index cf83f5b7..f5eef69e 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/ChatType.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/ChatType.java @@ -7,7 +7,8 @@ public enum ChatType { CHAT("c"), EMOTE("e"), SPEECH("s"), - THOUGHT("t"); + THOUGHT("t"), + PRIVATE("p"); private final String id; diff --git a/gameserver/src/main/java/brainwine/gameserver/player/Inventory.java b/gameserver/src/main/java/brainwine/gameserver/player/Inventory.java index 0f77b08c..51b3e14c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/Inventory.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/Inventory.java @@ -10,13 +10,17 @@ import java.util.Set; import java.util.stream.Collectors; +import brainwine.gameserver.GameServer; +import brainwine.gameserver.anticheat.AnticheatManager; +import brainwine.gameserver.item.Action; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.zone.ZoneActivity; import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; import brainwine.gameserver.item.InventoryType; import brainwine.gameserver.item.Item; -import brainwine.gameserver.item.ItemRegistry; import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.server.messages.EntityChangeMessage; import brainwine.gameserver.server.messages.InventoryMessage; @@ -61,6 +65,7 @@ protected void setPlayer(Player player) { public void moveItemToContainer(Item item, ContainerType type, int slot) { boolean accessoriesUpdated = false; + boolean exoskeletonUpdated = false; hotbar.removeItem(item); if(accessories.hasItem(item)) { @@ -75,13 +80,37 @@ public void moveItemToContainer(Item item, ContainerType type, int slot) { hotbar.moveItem(item, slot); break; case ACCESSORIES: + Item currentItem = accessories.getItem(slot); + Map appearanceUpdates = new HashMap<>(); + if("prosthetics".equals(currentItem.getCategory())) { + // Unequipping exoskeleton part + if(AnticheatManager.getConfig().getExoskeleton().getInventoryType() == InventoryType.ACCESSORY) { + Object setting = player.getAppearance().get(currentItem.getAppearanceSlot().getId()); + if(setting instanceof Integer && ItemRegistry.getItem((int)setting).equals(currentItem)) { + appearanceUpdates.put(currentItem.getAppearanceSlot().getId(), true); + } + } + } accessories.moveItem(item, slot); accessoriesUpdated = true; + if("prosthetics".equals(item.getCategory())) { + // Equipping new exoskeleton part + if(AnticheatManager.getConfig().getExoskeleton().getInventoryType() == InventoryType.ACCESSORY) { + Object setting = player.getAppearance().get(item.getAppearanceSlot().getId()); + if(setting == null || setting instanceof Boolean || setting.equals(0)) { + appearanceUpdates.put(item.getAppearanceSlot().getId(), setting != null && !setting.equals(0) ? setting : true); + } + } + } + if(!appearanceUpdates.isEmpty()) { + player.updateAppearance(appearanceUpdates); + } break; } if(accessoriesUpdated) { - player.sendMessageToPeers(new EntityChangeMessage(player.getId(), player.getStatusConfig())); + Map statusConfig = player.getStatusConfig(); + player.sendMessageToPeers(new EntityChangeMessage(player.getId(), statusConfig)); } } @@ -98,7 +127,16 @@ public void addItem(Item item, int quantity) { } public void addItem(Item item, int quantity, boolean sendMessage) { - setItem(item, getQuantity(item) + quantity, sendMessage); + int allowed = GameServer.getInstance() + .getZoneActivityManager() + .getPlayerInventoryLimits(player.getZone() != null ? player.getZone().getActivity() : ZoneActivity.NONE) + .getOrDefault(item.getId(), -1); + int currentQuantity = getQuantity(item); + int finalQuantity = currentQuantity + quantity; + if(allowed != -1 && !player.isGodMode()) { + finalQuantity = Math.max(currentQuantity, Math.min(finalQuantity, allowed)); + } + setItem(item, finalQuantity, sendMessage); } public void removeItem(Item item) { @@ -131,14 +169,26 @@ private void setItem(Item item, int quantity, boolean sendMessage) { // Unequip appearance item // TODO: potential nullptr if appearance value is null - if(slot != null && player.getAppearance().getOrDefault(slot.getId(), 0).equals(item.getCode())) { - player.updateAppearance(MapHelper.map(slot.getId(), 0)); + if(slot != null) { + Object oldAppearance = player.getAppearance().getOrDefault(slot.getId(), 0); + if(oldAppearance.equals(item.getCode())) { + if(item.getId().startsWith("prosthetics/")) { + player.updateAppearance(MapHelper.map(slot.getId(), true)); + } else { + player.updateAppearance(MapHelper.map(slot.getId(), 0)); + } + } } } else { // Equip appearance item (unless player already has it) - if(slot != null && !hasItem(item)) { + if(slot != null && !hasItem(item) && (!"prosthetics".equals(item.getCategory()) || AnticheatManager.getConfig().getExoskeleton().getInventoryType() == InventoryType.HIDDEN) && player.getAppearance().getOrDefault(slot.getId(), 0).equals(0)) { player.updateAppearance(MapHelper.map(slot.getId(), item.getCode())); } + + // Send wardrobe message with the new available colors if the player is newly obtaining a makeup kit + if(item.hasId("accessories/makeup") && !hasItem(item)) { + player.sendMessage(new WardrobeMessage(getClientWardrobe())); + } items.put(item, quantity); } @@ -177,6 +227,16 @@ public Item findAccessoryWithUse(ItemUseType use) { return Item.AIR; } + + public Item findAccessoryWithAction(Action action) { + for(Item item : accessories.getItems()) { + if(item.getAction() == action) { + return item; + } + } + + return Item.AIR; + } public Item findJetpack() { return findAccessoryWithUse(ItemUseType.FLY); @@ -192,9 +252,9 @@ public List getAccessories() { public List getAccessories(boolean includeHidden) { List items = new ArrayList<>(); - + for(Item item : accessories.getItems()) { - if(item.isAccessory()) { + if(item.isAccessory() || item.getId().startsWith("prosthetics/")) { items.add(item); } } @@ -217,6 +277,17 @@ public double getRegenBonus() { public Set getWardrobe() { return items.keySet().stream().filter(item -> item.isClothing() && hasItem(item)).collect(Collectors.toCollection(HashSet::new)); } + + public Set getClientWardrobe() { + Set result = getWardrobe(); + + if(hasItem(ItemRegistry.getItem("accessories/makeup"))) { + ItemRegistry.getItemsByCategory("skincolor").stream().collect(Collectors.toCollection(() -> result)); + ItemRegistry.getItemsByCategory("haircolor").stream().collect(Collectors.toCollection(() -> result)); + } + + return result; + } @JsonValue public Map getJsonValue() { @@ -229,8 +300,7 @@ public Map getJsonValue() { private void addItemLocation(Item item, List itemData) { int slot = -1; - - if(item.isHidden()) { + if(item.isHidden() && (!player.isV3() || (AnticheatManager.getConfig().getExoskeleton().getInventoryType() == InventoryType.HIDDEN || !"prosthetics".equals(item.getCategory())))) { itemData.add("z"); itemData.add(ItemRegistry.getHiddenItemIndex(item)); } else if((slot = hotbar.getSlot(item)) != -1) { diff --git a/gameserver/src/main/java/brainwine/gameserver/player/Player.java b/gameserver/src/main/java/brainwine/gameserver/player/Player.java index bd3359a7..cc026bc0 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/Player.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/Player.java @@ -4,6 +4,7 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -17,11 +18,12 @@ import java.util.function.Consumer; import java.util.stream.Collectors; +import brainwine.gameserver.androidshop.AndroidShopHistory; +import brainwine.gameserver.anticheat.AnticheatManager; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import com.fasterxml.jackson.annotation.JsonCreator; - import brainwine.gameserver.GameConfiguration; import brainwine.gameserver.GameServer; import brainwine.gameserver.Timer; @@ -31,6 +33,7 @@ import brainwine.gameserver.achievement.PositionAchievement; import brainwine.gameserver.command.CommandExecutor; import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; import brainwine.gameserver.dialog.DialogListItem; import brainwine.gameserver.dialog.DialogSection; import brainwine.gameserver.dialog.DialogType; @@ -38,7 +41,9 @@ import brainwine.gameserver.entity.EntityAttack; import brainwine.gameserver.entity.EntityStatus; import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Action; import brainwine.gameserver.item.DamageType; +import brainwine.gameserver.item.InventoryType; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.ItemRegistry; import brainwine.gameserver.item.ItemUseType; @@ -47,6 +52,12 @@ import brainwine.gameserver.item.Tradeability; import brainwine.gameserver.item.consumables.Consumable; import brainwine.gameserver.loot.Loot; +import brainwine.gameserver.order.OrderManager; +import brainwine.gameserver.quest.DailyQuests; +import brainwine.gameserver.quest.PlayerQuests; +import brainwine.gameserver.quest.Quest; +import brainwine.gameserver.quest.QuestEvents; +import brainwine.gameserver.quest.QuestProgress; import brainwine.gameserver.server.Message; import brainwine.gameserver.server.messages.AchievementMessage; import brainwine.gameserver.server.messages.AchievementProgressMessage; @@ -77,6 +88,7 @@ import brainwine.gameserver.server.pipeline.Connection; import brainwine.gameserver.util.MapHelper; import brainwine.gameserver.util.MathUtils; +import brainwine.gameserver.util.ValueWithExpiry; import brainwine.gameserver.util.Vector2i; import brainwine.gameserver.util.VersionUtils; import brainwine.gameserver.zone.Biome; @@ -86,6 +98,8 @@ import brainwine.gameserver.zone.Zone; import brainwine.gameserver.zone.ZoneManager; +import com.fasterxml.jackson.annotation.JsonCreator; + public class Player extends Entity implements CommandExecutor { public static final int RECENT_ZONE_LIMIT = 12; @@ -97,9 +111,9 @@ public class Player extends Entity implements CommandExecutor { public static final int HEARTBEAT_TIMEOUT = 30000; public static final int MAX_AUTH_TOKENS = 3; public static final int TRACKED_ENTITY_UPDATE_INTERVAL = 100; + public static final int REGEN_NO_DAMAGE_TIME = 5000; public static final float ENTITY_VISIBILITY_RANGE = 40; - public static final int BASE_REGEN_INTERVAL = 30000; - public static final float BASE_REGEN_AMOUNT = 1.0F / 3.0F; + public static final float BASE_REGEN_AMOUNT = 0.2F; private static final Logger logger = LogManager.getLogger(); private static int dialogDiscriminator; private final String documentId; @@ -123,10 +137,18 @@ public class Player extends Entity implements CommandExecutor { private Set followers; private Set lootCodes; private Set achievements; + private Map orders = new HashMap<>(); + private String displayedOrder = null; private Map ignoredHints; private Map skills; private Map> bumpedSkills; private Map appearance; + private Map questProgresses = new HashMap<>(); + private ValueWithExpiry> dailyQuest = ValueWithExpiry.getExpired(); + private Map androidQuests = new HashMap<>(); + private AndroidShopHistory androidShopHistory = new AndroidShopHistory(); + private String familyName = null; + private Map actionHistory = new HashMap<>(); private final Map settings = new HashMap<>(); private final Set activeChunks = new HashSet<>(); private final Map> dialogs = new HashMap<>(); @@ -135,6 +157,7 @@ public class Player extends Entity implements CommandExecutor { private String clientVersion; private TradeSession tradeSession; private Placement lastPlacement; + private MetaBlock transmittableBlock; private Item heldItem = Item.AIR; private double breath = 1.0; private double thirst; @@ -156,11 +179,16 @@ public class Player extends Entity implements CommandExecutor { private long lastHeartbeat; private long lastTrackedEntityUpdate; private long lastLandmarkVoteAt; - private long lastHealthRegenAt; + private long lastQuestTimeMessageAt; + private Set momentaryAccessoriesUsedSinceLogin = new HashSet<>(); + private Map lastMomentaryAccessoryUsedAt = new HashMap<>(); + private String blockReason; + private long blockedUntil; private Zone previousZone; private Zone nextZone; + private boolean inTutorial = false; private Connection connection; - + protected Player(String documentId, PlayerConfigFile config) { super(config.getCurrentZone()); this.documentId = documentId; @@ -173,6 +201,7 @@ protected Player(String documentId, PlayerConfigFile config) { this.skillPoints = config.getSkillPoints(); this.karma = config.getKarma(); this.crowns = config.getCrowns(); + this.displayedOrder = config.getDisplayedOrder(); this.inventory = config.getInventory(); this.statistics = config.getStatistics(); this.authTokens = config.getAuthTokens(); @@ -185,10 +214,17 @@ protected Player(String documentId, PlayerConfigFile config) { this.followers = config.getFollowers(); this.lootCodes = config.getLootCodes(); this.achievements = config.getAchievements(); + this.orders = config.getOrders(); this.ignoredHints = config.getIgnoredHints(); this.skills = config.getSkills(); this.bumpedSkills = config.getBumpedSkills(); this.appearance = config.getAppearance(); + this.questProgresses = config.getQuestProgresses(); + this.dailyQuest = config.getDailyQuest(); + this.androidQuests = config.getAndroidQuests(); + this.androidShopHistory = config.getAndroidShopHistory(); + this.familyName = config.getFamilyName(); + this.actionHistory = config.getActionHistory(); health = getMaxHealth(); inventory.setPlayer(this); statistics.setPlayer(this); @@ -233,17 +269,21 @@ public void tick(float deltaTime) { kick("Connection timed out."); } } - - // Regenerate health out of combat - if(!isDead() && health < getMaxHealth() && now >= Math.max(lastHealthRegenAt, lastDamagedAt) + BASE_REGEN_INTERVAL * inventory.getRegenBonus()) { - heal(BASE_REGEN_AMOUNT); - lastHealthRegenAt = now; + + // Regenerate health out of combat + if(!isDead()) { + float regenBonus = getInventory().findAccessoryWithAction(Action.REVIVE).isAir() ? 1f : 2f; + int regenNoDamageTime = (int)(REGEN_NO_DAMAGE_TIME / regenBonus); + if(now >= lastDamagedAt + regenNoDamageTime) { + heal(regenBonus * BASE_REGEN_AMOUNT * deltaTime); + } } if(!isDead()) { applyBreath(deltaTime); applyThirst(deltaTime); applyFreeze(deltaTime); + OrderManager.advance(this); } // Try to timeout trade @@ -263,6 +303,15 @@ public void tick(float deltaTime) { sendMessage(new EntityPositionMessage(trackedEntities)); } } + + DailyQuests.tryIssueDailyQuest(this); + + long dailyQuestTimeLeft = getDailyQuest().getTimeUntilExpiry(System.currentTimeMillis()); + long dailyQuestRequiredInterval = dailyQuestTimeLeft >= 3600000 ? 600000 : 30000; + if(System.currentTimeMillis() >= lastQuestTimeMessageAt + dailyQuestRequiredInterval) { + lastQuestTimeMessageAt = System.currentTimeMillis(); + DailyQuests.sendDailyQuestTime(this, dailyQuestTimeLeft); + } } @Override @@ -307,7 +356,7 @@ public void setHealth(float health) { super.setHealth(health); sendMessage(new HealthMessage(health)); } - + @Override public void blockPositionChanged() { super.blockPositionChanged(); @@ -347,28 +396,28 @@ public void applyBreath(float deltaTime) { } } } - + public void applyThirst(float deltaTime) { long now = System.currentTimeMillis(); - + // Update thirst stat - if(isGodMode()) { + if(isGodMode() || !inventory.findAccessoryWithUse(ItemUseType.HAZMAT).isAir()) { thirst = 0.0; } else { double thirstPeriod = MathUtils.lerp(5.0, 10.0, (getTotalSkillLevel(Skill.SURVIVAL) - 1) / 6.0) * 60; int direction = zone.getBiome() == Biome.DESERT && !zone.isPurified() ? 1 : -1; thirst = MathUtils.clamp(thirst + (direction * deltaTime / thirstPeriod), 0.0, 1.0); } - + // Send message if it is time if(now > lastThirstMessage + 1000) { sendMessage(new StatMessage(PlayerStat.THIRST, (float)thirst)); lastThirstMessage = now; } - + if(thirst >= 1.0) { Item waterJar = ItemRegistry.getItem("containers/jar-water"); - + // Consume a jar of water if the player has any and reset thirst if(inventory.hasItem(waterJar)) { inventory.removeItem(waterJar, true); @@ -377,46 +426,54 @@ public void applyThirst(float deltaTime) { thirst = 0.0; return; } - + // Damage the player every 3 seconds instead if they have no water in their inventory - if(now > lastThirstDamageAt + 3000) { + if(now > lastThirstDamageAt + 3000 && health > 1.0) { attack(null, null, 0.25F, DamageType.FIRE, true); // Apply as true damage lastThirstDamageAt = now; } } } - + public void applyFreeze(float deltaTime) { long now = System.currentTimeMillis(); - + // Update freeze stat - if(isGodMode()) { + if(isGodMode() || !inventory.findAccessoryWithUse(ItemUseType.HAZMAT).isAir()) { cold = 0.0; } else { double freezePeriod = MathUtils.lerp(3.0, 10.0, (getTotalSkillLevel(Skill.SURVIVAL) - 1) / 6.0) * 60; int direction = zone.getBiome() == Biome.ARCTIC ? 1 : -2; // Warm back up twice as fast cold = MathUtils.clamp(cold + (direction * deltaTime / freezePeriod), 0.0, 1.0); } - + // Send message & perform damage tick if it is time if(now > lastFreezeMessage + 1000) { - if(cold >= 1.0) { + if(cold >= 1.0 && health > 1.0) { attack(null, null, 0.25F, DamageType.COLD, true); // Apply as true damage } - + sendMessage(new StatMessage(PlayerStat.FREEZE, (float)cold)); lastFreezeMessage = now; } } - + public void applyWarmth() { cold = 0.0; sendMessage(new StatMessage(PlayerStat.FREEZE, (float)cold)); } - + @Override public float getAttackMultiplier(EntityAttack attack) { - return isGodMode() ? 9999.0F : 1.0F; + if(isGodMode()) { + return 9999.0f; + } + Map skillAttackBonus = attack.getWeapon().getSkillAttackBonus(); + double totalAttackBonus = 1.0; + if(skillAttackBonus != null) for(Map.Entry bonus : skillAttackBonus.entrySet()) { + totalAttackBonus += (getTotalSkillLevel(bonus.getKey()) - 1) * bonus.getValue(); + } + return (float)totalAttackBonus; } @Override @@ -454,7 +511,7 @@ public Map getStatusConfig() { */ public void onZoneEntered() { boolean spawnEffect = false; - + // Find new spawn point if zone has changed if(zone != previousZone) { MetaBlock spawn = zone.getRandomSpawnBlock(); @@ -466,12 +523,12 @@ public void onZoneEntered() { spawnX = spawn.getX() + 1; spawnY = spawn.getY(); } - + x = spawnX; y = spawnY; spawnEffect = true; } - + // Handle custom spawn location if(zone != nextZone) { x = spawnX; @@ -483,7 +540,7 @@ public void onZoneEntered() { } customSpawn = false; - + // Rescue player if they're out of bounds somehow // blockX and blockY might not be assigned yet so we check the absolute position if(!zone.areCoordinatesInBounds((int)x, (int)y)) { @@ -499,32 +556,21 @@ public void onZoneEntered() { } } - // Add some default items if the player has none - if(inventory.isEmpty()) { - Item pickaxe = ItemRegistry.getItem("tools/pickaxe"); - Item pistol = ItemRegistry.getItem("tools/pistol"); - Item jetpack = ItemRegistry.getItem("accessories/jetpack"); - inventory.addItem(pickaxe); - inventory.addItem(pistol); - inventory.addItem(jetpack); - inventory.moveItemToContainer(pickaxe, ContainerType.HOTBAR, 0); - inventory.moveItemToContainer(pistol, ContainerType.HOTBAR, 1); - inventory.moveItemToContainer(jetpack, ContainerType.ACCESSORIES, 0); - } - ZoneManager zoneManager = GameServer.getInstance().getZoneManager(); PlayerManager playerManager = GameServer.getInstance().getPlayerManager(); - + // Issue an API token if the player doesn't have one if(apiToken == null) { playerManager.issueApiToken(this); } - + sendMessage(new ConfigurationMessage(id, getClientConfig(), GameConfiguration.getClientConfig(this), zone.getClientConfig(this))); sendMessage(new ZoneStatusMessage(zone.getStatusConfig(this))); zone.sendMachineStatus(this); sendMessage(new PlayerPositionMessage((int)x, (int)y)); sendMessage(new HealthMessage(health)); + // Configuration message doesn't cause the Unity client to update the appearance when changing zones + sendMessage(new EntityChangeMessage(getId(), getVisibleAppearance())); // Send skill data for(Skill skill : skills.keySet()) { @@ -532,7 +578,7 @@ public void onZoneEntered() { } sendMessage(new InventoryMessage(inventory)); - sendMessage(new WardrobeMessage(inventory.getWardrobe())); + sendMessage(new WardrobeMessage(inventory.getClientWardrobe())); sendMessage(new BlockMetaMessage(zone.getGlobalMetaBlocks())); // Send peer data @@ -553,6 +599,9 @@ public void onZoneEntered() { } } } + + // Update tutorial status + setInTutorial(zone.isTutorial()); // And finally, enter the zone! if(isV3()) { @@ -565,7 +614,7 @@ public void onZoneEntered() { if(spawnEffect) { zone.spawnEffect(x + 0.5F, y - 0.75F, "spawn", 20); } - + // Send social info sendMessage(new FollowMessage(followees.stream().map(playerManager::getPlayerById).filter(Objects::nonNull).collect(Collectors.toList()), 0)); sendMessage(new FollowMessage(followers.stream().map(playerManager::getPlayerById).filter(Objects::nonNull).collect(Collectors.toList()), 1)); @@ -577,6 +626,10 @@ public void onZoneEntered() { // Misc stuff updateAchievementProgress(JourneymanAchievement.class); checkRegistration(); + checkMaxLevel(); + PlayerQuests.deleteUnknownQuestProgress(this); + PlayerQuests.sendInitialPlayerQuestMessages(this); + QuestEvents.handleEnterZone(this, zone); recentZones.remove(zone.getDocumentId()); // Remove first in case the zone has already been visited recently recentZones.add(0, zone.getDocumentId()); // Add at top so we don't have to reverse the list for the zone searcher @@ -593,9 +646,10 @@ public void onZoneEntered() { public void onDisconnect() { lastHeartbeat = 0; lastPlacement = null; + transmittableBlock = null; clientVersion = null; previousZone = zone; - + if(zone != null) { zone.removeEntity(this); } @@ -621,6 +675,7 @@ public void onDisconnect() { } trackedEntities.clear(); + momentaryAccessoriesUsedSinceLogin.clear(); GameServer.getInstance().getPlayerManager().onPlayerDisconnect(this); connection.setPlayer(null); connection = null; @@ -688,15 +743,15 @@ public void showDialog(Dialog dialog) { public void showDialog(Dialog dialog, Consumer handler) { sendMessage(new DialogMessage(storeDialogHandler(handler), dialog)); } - + public void showDialog(Map dialog) { showDialog(dialog, null); } - + public void showDialog(Map dialog, Consumer handler) { sendMessage(new DialogMessage(storeDialogHandler(handler), dialog)); } - + private int storeDialogHandler(Consumer handler) { int id = handler == null ? 0 : ++dialogDiscriminator; @@ -785,17 +840,17 @@ public void respawn() { // Check minigame spawnpoint if(hasActiveMinigame()) { Vector2i spawnPoint = minigame.getSpawnPoint(this); - + if(spawnPoint != null) { respawn(spawnPoint.getX(), spawnPoint.getY()); return; } } - + // Respawn at default spawn point respawn(spawnX, spawnY); } - + public void respawn(int x, int y) { if(isDead()) { setHealth(getMaxHealth()); @@ -882,7 +937,7 @@ public void notifyPeers(Object message, NotificationType type) { public void notify(Object message) { notify(message, NotificationType.POPUP); } - + public void notifyProfile(String title, String description) { if(isV3()) { notify(String.format("%s\n%s", title, description)); @@ -890,7 +945,15 @@ public void notifyProfile(String title, String description) { notify(MapHelper.map(String.class, String.class, "title", title, "desc", description), NotificationType.PROFILE); } } - + + public MetaBlock getTransmittableBlock() { + return transmittableBlock; + } + + public void setTransmittableBlock(MetaBlock transmittableBlock) { + this.transmittableBlock = transmittableBlock; + } + public void setHeldItem(Item item) { heldItem = item; } @@ -904,19 +967,24 @@ public void tradeItem(Player recipient, Item item) { if(recipient == this) { return; } - + + if(zone != null && !zone.isMarket()) { + showDialog(DialogHelper.messageDialog("Trade at the Market!", "Trading is only allowed in Market worlds and private worlds. Ask the player to join you in a Market world.")); + return; + } + // Check if item is tradeable if(!isGodMode() && item.getTradeability() == Tradeability.FALSE) { notify("Sorry, you cannot trade this item."); return; } - + // Check if player is high enough level to trade this item if(!isGodMode() && item.getTradeability() == Tradeability.LEVELED && getLevel() < 20) { notify("You must be level 20+ to trade this item."); return; } - + // Cancel the current trade if the player is initiating a new trade if(isTrading() && !tradeSession.isParticipant(recipient)) { tradeSession.cancel(this); @@ -947,7 +1015,13 @@ public void trackPlacement(int x, int y, Item item) { if(item.getUses().isEmpty() || !zone.areCoordinatesInBounds(x, y)) { return; } - + + if(transmittableBlock != null && item.hasUse(ItemUseType.TRANSMITTED)) { + transmitBlock(item, transmittableBlock, x, y); + transmittableBlock = null; + return; + } + boolean linked = false; if(lastPlacement != null) { @@ -1024,13 +1098,42 @@ private boolean tryLinkTransmittedItem(int x, int y, Item item) { lastPlacement = null; return true; } - + + public void transmitBlock(Item transmissionTarget, MetaBlock transmittableBlock, int x, int y) { + if(!isGodMode() && !zone.isOwner(this)) { + notify("You can't transmit this block because you don't own this zone."); + inventory.addItem(transmissionTarget); + return; + } + + if(!zone.getBlock(transmittableBlock.getX(), transmittableBlock.getY()).getFrontItem().hasUse(ItemUseType.WORLD_MACHINE)) { + notify("You can't transmit this item at the location you have set it. Maybe it got moved or destroyed since then."); + inventory.addItem(transmissionTarget); + return; + } + + Item baseItem = zone.getBlock(x, y).getBaseItem(); + + Item item = transmittableBlock.getItem(); + zone.updateBlock(transmittableBlock.getX(), transmittableBlock.getY(), Layer.FRONT, Item.AIR); + + if(baseItem.hasId("base/pipe")) { + zone.updateBlock(x, y, Layer.FRONT, Item.AIR); + notify("The machine has been flushed!"); + } else { + zone.updateBlock(x, y, Layer.FRONT, item); + } + + } + public double getMiningRange() { - return 5 + getTotalSkillLevel(Skill.MINING) / 3.0; + double accessoryBonus = getInventory().findAccessoryWithUse(ItemUseType.BUILDING_EXTENSION).isAir() ? 1.0 : 2.0; + return accessoryBonus * (5 + getTotalSkillLevel(Skill.MINING) / 3.0); } public double getPlacementRange() { - return Math.ceil(MathUtils.lerp(5.0, 13.0, (double)getTotalSkillLevel(Skill.BUILDING) / MAX_SKILL_LEVEL)); + double accessoryBonus = getInventory().findAccessoryWithUse(ItemUseType.BUILDING_EXTENSION).isAir() ? 1.0 : 2.0; + return accessoryBonus * Math.ceil(MathUtils.lerp(5.0, 13.0, (double)getTotalSkillLevel(Skill.BUILDING) / MAX_SKILL_LEVEL)); } public int getMaxTargetableEntities() { @@ -1041,8 +1144,10 @@ public double getMiningBonusChance(MiningBonus bonus) { if(heldItem.getGroup() != bonus.getTool()) { return 0.0; } - - return bonus.getChance() * (getTotalSkillLevel(bonus.getSkill()) / (double)MAX_SKILL_LEVEL) * heldItem.getToolBonus(); + + double accessoryBonus = bonus.getAccessory() == null || getInventory().findAccessoryWithUse(bonus.getAccessory()).isAir() ? 1.0 : 2.0; + + return bonus.getChance() * getNormalizedSkill(bonus.getSkill()) * heldItem.getToolBonus() * accessoryBonus; } /** @@ -1075,11 +1180,11 @@ protected String getPassword() { protected void setApiToken(String apiToken) { this.apiToken = apiToken; } - + public String getApiToken() { return apiToken; } - + protected void clearAuthTokens() { authTokens.clear(); } @@ -1140,60 +1245,60 @@ public void followPlayer(Player player) { if(!followees.add(player.getDocumentId())) { return; // Do nothing if player is already following } - + player.addFollower(this); sendMessage(new FollowMessage(player, 0, true)); } - + public void unfollowPlayer(Player player) { if(!followees.remove(player.getDocumentId())) { return; // Do nothing if player is not following } - + player.removeFollower(this); sendMessage(new FollowMessage(player, 0, false)); } - + public boolean isFollowing(Player player) { return isFollowing(player.getDocumentId()); } - + public boolean isFollowing(String followee) { return followees.contains(followee); } - + public Set getFollowees() { return Collections.unmodifiableSet(followees); } - + private void addFollower(Player player) { followers.add(player.getDocumentId()); - + if(isOnline()) { sendMessage(new FollowMessage(player, 1, true)); } } - + private void removeFollower(Player player) { followers.remove(player.getDocumentId()); - + if(isOnline()) { sendMessage(new FollowMessage(player, 1, false)); } } - + public boolean hasFollower(Player player) { return hasFollower(player.getDocumentId()); } - + public boolean hasFollower(String follower) { return followers.contains(follower); } - + public Set getFollowers() { return Collections.unmodifiableSet(followers); } - + public void addLootCode(String lootCode) { lootCodes.add(lootCode); } @@ -1296,7 +1401,8 @@ public void addExperience(int amount) { public void addExperience(int amount, String message) { if(amount > 0) { - setExperience(experience + amount, message); + double zoneXpMultiplier = getZone() == null ? 1.0 : getZone().getXpMultiplier(); + setExperience((int) Math.round(experience + zoneXpMultiplier * amount), message); } } @@ -1305,17 +1411,27 @@ public void setExperience(int experience) { } public void setExperience(int experience, String message) { + int maxExperience = getExperienceForLevel(getMaxLevel()); + if(experience > maxExperience) { + experience = maxExperience; + } + + if(experience == this.experience) return; + int amount = experience - this.experience; int oldLevel = getLevel(); this.experience = experience; sendMessage(new XpMessage(amount, experience, message)); int newLevel = getLevel(); - + if(newLevel != oldLevel) { - skillPoints += Math.max(0, newLevel - oldLevel); sendDelayedMessage(new LevelMessage(newLevel), 5000); - sendDelayedMessage(new EffectMessage(0, 0, "levelup", 1), 5000); + } + + if(newLevel > oldLevel) { + skillPoints += newLevel - oldLevel; sendDelayedMessage(new StatMessage(PlayerStat.POINTS, skillPoints), 5000); + sendDelayedMessage(new EffectMessage(0, 0, "levelup", 1), 5000); notifyPeers(String.format("%s leveled up to level %s!", name, newLevel), NotificationType.SYSTEM); } } @@ -1350,6 +1466,27 @@ public int getMaxLevel() { public int getLevel() { return getLevelFromExperience(experience); } + + public void checkMaxLevel() { + Dialog dialog = DialogHelper.messageDialog("The maximum player level has changed since you last played."); + if(getExperience() > getExperienceForLevel(getMaxLevel())) { + setLevel(getMaxLevel()); + dialog.addSection(new DialogSection().setText(String.format("You have been moved down to level %d.", getMaxLevel()))); + } + + int totalBumps = getBumpedSkills().values().stream().mapToInt(List::size).sum(); + int totalPoints = getSkills().values().stream().mapToInt(Integer::intValue).sum() - getSkills().size() - totalBumps + getSkillPoints(); + int possiblePoints = getLevel() - 1; + + if(totalPoints > possiblePoints) { + resetAllSkills(); + dialog.addSection(new DialogSection().setText("All your skills have been reset because you had too many skill points.")); + } + + if(dialog.getSections().size() > 1) { + showDialog(dialog); + } + } public void setSkillPoints(int skillPoints) { this.skillPoints = skillPoints; @@ -1359,6 +1496,34 @@ public void setSkillPoints(int skillPoints) { public int getSkillPoints() { return skillPoints; } + + public void resetAllSkills() { + int pointsToRefund = 0; + + // Reset skill levels and calculate point refund total + for(Map.Entry entry : getSkills().entrySet()) { + Skill skill = entry.getKey(); + int level = entry.getValue(); + int leftover = 1; + + // Count skill bumps and don't reset those bumps + for(List bumpedSkills : getBumpedSkills().values()) { + if(bumpedSkills.contains(skill)) { + leftover++; + } + } + + // Skip if skill hasn't been upgraded at all + if(level <= leftover) { + continue; + } + + pointsToRefund += level - leftover; + setSkillLevel(skill, leftover); // Reset skill level + } + + setSkillPoints(Math.min(getLevel() - 1, getSkillPoints() + pointsToRefund)); // Refund skill points + } public void setKarma(int karma) { this.karma = karma; @@ -1460,9 +1625,11 @@ public void addAchievement(Achievement achievement) { int experience = achievement.getExperience(); String title = achievement.getTitle(); addExperience(experience); - sendMessage(new AchievementMessage(title, experience)); - notifyPeers(String.format("%s has earned the %s achievement.", name, title), NotificationType.SYSTEM); - + sendMessage(new AchievementMessage(title, experience)); + String message = String.format("%s has earned the %s achievement.", name, title); + notifyPeers(message, NotificationType.SYSTEM); + GameServer.getInstance().getPusher().handlePlayerMessage(this, message); + if(isV3()) { notify(title, NotificationType.ACHIEVEMENT); } @@ -1480,21 +1647,174 @@ public boolean hasAchievement(Achievement achievement) { public Set getAchievements() { return Collections.unmodifiableSet(achievements); } - + + public Map getOrders() { + return orders; + } + + public String getDisplayedOrder() { + return displayedOrder; + } + + /** Get value for the entity status "ni" field. Use {@code Player#getIconEmoji} when sending a playerIconDidChange EventMessage */ + public String getIcon() { + if(getDisplayedOrder() == null + || !OrderManager.getOrders().containsKey(getDisplayedOrder()) + || orders.getOrDefault(getDisplayedOrder(), 0) == 0) { + return null; + } + return String.format("orders/%s-%d", + getDisplayedOrder(), + getOrders().getOrDefault(getDisplayedOrder(), 0) + ); + } + + /** Icon for sending a playerIconDidChange EventMessage. Use {@code Player#getIcon} for the entity status "ni" field. */ + public String getIconEmoji() { + String icon = getIcon(); + return icon == null ? null : "emoji/" + icon; + } + + public void setDisplayedOrder(String displayedOrder) { + this.displayedOrder = displayedOrder; + } + public void randomizeAppearance() { appearance.putAll(Appearance.getRandomAppearance(this)); - zone.sendMessage(new EntityChangeMessage(id, appearance)); + zone.sendMessage(new EntityChangeMessage(id, getVisibleAppearance())); } public void updateAppearance(Map appearance) { this.appearance.putAll(appearance); - zone.sendMessage(new EntityChangeMessage(id, appearance)); + zone.sendMessage(new EntityChangeMessage(id, getVisibleAppearance())); + QuestEvents.handleAppearance(this, appearance); } - + public Map getAppearance() { return Collections.unmodifiableMap(appearance); } - + + private Item getCustomizedAppearanceSupersedeByMaterial(Item accessory, Item current) { + if(current == null || current.isAir()) return accessory; + else if(current.getId().contains("onyx")) return current; + else if(accessory.getId().contains("onyx")) return accessory; + else if(current.getId().contains("diamond")) return current; + return accessory; + } + + private static List customizableAppearanceSlots = Arrays.asList( + AppearanceSlot.FACIAL_GEAR, + AppearanceSlot.TOPS_OVERLAY, + AppearanceSlot.LEGS_OVERLAY + ); + + public Map getCustomizedAppearance() { + Map appearance = new HashMap<>(this.appearance); + + Item[] selected = new Item[customizableAppearanceSlots.size()]; + + for(Item accessory: getInventory().getAccessories(AnticheatManager.getConfig().getExoskeleton().getInventoryType() == InventoryType.HIDDEN)) { + if("prosthetics".equals(accessory.getCategory())) { + AppearanceSlot slot = accessory.getAppearanceSlot(); + + int index = customizableAppearanceSlots.indexOf(slot); + + if(index > -1) { + selected[index] = getCustomizedAppearanceSupersedeByMaterial(accessory, selected[index]); + } + } + } + + for(int i = 0; i < customizableAppearanceSlots.size(); i++) { + Object setting = getAppearance().get(customizableAppearanceSlots.get(i).getId()); + if(setting instanceof Boolean) { + if(selected[i] != null && (boolean)setting) { + appearance.put(customizableAppearanceSlots.get(i).getId(), selected[i].getCode()); + } else { + appearance.put(customizableAppearanceSlots.get(i).getId(), 0); + } + } + } + + appearance.put("to*", "ffff55"); // Top overlay color + appearance.put("fg*", "ffff55"); // Facial gear overlay color + + return appearance; + } + + public Map getVisibleAppearance() { + Map customizedAppearance = getCustomizedAppearance(); + Map visibleAppearance = zone.getHolographConfiguration().overrideAppearance(customizedAppearance); + + // v3 name icon implementation expects the name icon to be part of the appearance config + visibleAppearance.put("ni", getIcon()); + + return visibleAppearance; + } + + public long getMomentaryAccessoryLastUsedAt(Item item) { + return lastMomentaryAccessoryUsedAt.getOrDefault(item, 0L); + } + + public boolean isMomentaryAccessoryOnCooldown(Item item) { + if(item.getFiringDuration() <= 0f) return false; + long cooldownUntil = getMomentaryAccessoryLastUsedAt(item) + (long)((item.getFiringDuration() + item.getFiringInterval()) * 1000); + if(cooldownUntil - 2000 > System.currentTimeMillis()) { + return true; + } + lastMomentaryAccessoryUsedAt.put(item, System.currentTimeMillis()); + momentaryAccessoriesUsedSinceLogin.add(item); + return false; + } + + public Set getMomentaryAccessoriesUsedSinceLogin() { + return Collections.unmodifiableSet(momentaryAccessoriesUsedSinceLogin); + } + + public void blockUntil(long endTime, String reason) { + if(blockedUntil < endTime) { + blockReason = reason; + blockedUntil = endTime; + } + kick(reason, true); + } + + public long getBlockedUntil() { + return blockedUntil; + } + + public String getBlockReason() { + return blockReason; + } + + public Map getQuestProgresses() { + return questProgresses; + } + + public ValueWithExpiry> getDailyQuest() { + return dailyQuest; + } + + public Map getAndroidQuests() { + return androidQuests; + } + + public void setDailyQuest(ValueWithExpiry> dailyQuest) { + this.dailyQuest = dailyQuest; + } + + public AndroidShopHistory getAndroidShopHistory() { + return androidShopHistory; + } + + public String getFamilyName() { + return familyName; + } + + public void setFamilyName(String familyName) { + this.familyName = familyName; + } + public void setSkillLevel(Skill skill, int level) { skills.put(skill, level); sendMessage(new SkillMessage(skill, level)); @@ -1553,6 +1873,12 @@ public void consume(Item item) { public void consume(Item item, Object details) { Consumable consumable = item.getAction().getConsumable(); + + if(!isGodMode() && !inventory.hasItem(item)) { + sendMessage(new InventoryMessage(inventory.getClientConfig(item))); + notify(String.format("Sorry, you don't have any %ss.", item.getTitle())); + return; + } if(consumable == null) { sendMessage(new InventoryMessage(inventory.getClientConfig(item))); @@ -1570,11 +1896,11 @@ public void awardLoot(Loot loot) { public void awardLoot(Loot loot, DialogType dialogType) { awardLoot(loot, dialogType, "You received:"); } - + public void awardLoot(Loot loot, String title) { awardLoot(loot, DialogType.LOOT, title); } - + public void awardLoot(Loot loot, DialogType dialogType, String title) { Dialog dialog = new Dialog(); DialogSection section = new DialogSection(); @@ -1582,6 +1908,7 @@ public void awardLoot(Loot loot, DialogType dialogType, String title) { loot.getItems().forEach((item, quantity) -> { inventory.addItem(item, quantity, true); + QuestEvents.handleCollectItem(this, item, quantity); section.addItem(new DialogListItem() .setItem(item.getCode()) .setText(String.format("%s x %s", item.getTitle(), quantity))); @@ -1592,8 +1919,11 @@ public void awardLoot(Loot loot, DialogType dialogType, String title) { if(crowns > 0) { addCrowns(crowns); - - if(v3) { + + Item crownsIconItem = ItemRegistry.getItem("accessories/crowns"); + if(hasClientVersion("3.13.8") && !crownsIconItem.isAir()) { + section.addItem(new DialogListItem().setItem(crownsIconItem.getCode()).setText(String.format("%s shiny crowns!", crowns)).setSupportRichText(true)); + } else if(v3) { section.setText(String.format("%s shiny crowns!", crowns)); } else { section.setText(String.format("%s shiny crowns!", crowns)); @@ -1609,6 +1939,18 @@ public void awardLoot(Loot loot, DialogType dialogType, String title) { notify(dialog, NotificationType.REWARD); } } + + public void recordActionTime(String name) { + actionHistory.put(name.toLowerCase(), OffsetDateTime.now()); + } + + public boolean isActionOnCooldown(String name, long cooldown, TemporalUnit unit) { + return actionHistory.containsKey(name.toLowerCase()) && !OffsetDateTime.now().isAfter(actionHistory.get(name.toLowerCase()).plus(cooldown, unit)); + } + + public Map getActionHistory() { + return Collections.unmodifiableMap(actionHistory); + } public Inventory getInventory() { return inventory; @@ -1618,8 +1960,8 @@ public PlayerStatistics getStatistics() { return statistics; } - public void addActiveChunk(int index) { - activeChunks.add(index); + public boolean addActiveChunk(int index) { + return activeChunks.add(index); } public void removeActiveChunk(int index) { @@ -1702,6 +2044,14 @@ public boolean isTrackingEntity(Entity entity) { public List getTrackedEntities() { return trackedEntities; } + + public boolean isInTutorial() { + return inTutorial; + } + + public void setInTutorial(boolean inTutorial) { + this.inTutorial = inTutorial; + } public void setConnection(Connection connection) { if(isOnline()) { @@ -1726,10 +2076,8 @@ public boolean isOnline() { private Map getDetails() { Map details = new HashMap<>(); - details.putAll(appearance); + details.putAll(getVisibleAppearance()); details.put("u", inventory.findJetpack().getCode()); - details.put("to*", "ffff55"); // Top overlay color - details.put("fg*", "ffff55"); // Facial gear overlay color return details; } @@ -1756,6 +2104,7 @@ public Map getClientConfig() { config.put("deaths", statistics.getDeaths()); config.put("appearance", getDetails()); config.put("settings", settings); + config.put("ni", getIcon()); config.put("api_token", apiToken); return config; } diff --git a/gameserver/src/main/java/brainwine/gameserver/player/PlayerConfigFile.java b/gameserver/src/main/java/brainwine/gameserver/player/PlayerConfigFile.java index c85d2bba..e06742ba 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/PlayerConfigFile.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/PlayerConfigFile.java @@ -1,12 +1,12 @@ package brainwine.gameserver.player; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.time.OffsetDateTime; +import java.util.*; +import java.util.stream.Collectors; +import brainwine.gameserver.androidshop.AndroidShopHistory; +import brainwine.gameserver.quest.Quest; +import brainwine.gameserver.util.ValueWithExpiry; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSetter; @@ -14,6 +14,7 @@ import brainwine.gameserver.achievement.Achievement; import brainwine.gameserver.item.Item; +import brainwine.gameserver.quest.QuestProgress; import brainwine.gameserver.zone.Zone; @JsonIgnoreProperties(ignoreUnknown = true) @@ -29,6 +30,7 @@ public class PlayerConfigFile { private int skillPoints; private int karma; private int crowns; + private String displayedOrder = null; private Inventory inventory = new Inventory(); private PlayerStatistics statistics = new PlayerStatistics(); private List authTokens = new ArrayList<>(); @@ -41,10 +43,17 @@ public class PlayerConfigFile { private Set followers = new HashSet<>(); private Set lootCodes = new HashSet<>(); private Set achievements = new HashSet<>(); + private Map orders = new HashMap<>(); private Map ignoredHints = new HashMap<>(); private Map skills = new HashMap<>(); private Map> bumpedSkills = new HashMap<>(); private Map appearance = new HashMap<>(); + private Map questProgresses = new HashMap<>(); + private ValueWithExpiry> dailyQuest = ValueWithExpiry.getExpired(); + private Map androidQuests = new HashMap<>(); + private AndroidShopHistory androidShopHistory = new AndroidShopHistory(); + private String familyName = null; + private Map actionHistory = new HashMap<>(); public PlayerConfigFile(Player player) { this.name = player.getName(); @@ -69,14 +78,74 @@ public PlayerConfigFile(Player player) { this.followers = player.getFollowers(); this.lootCodes = player.getLootCodes(); this.achievements = player.getAchievements(); + this.orders = player.getOrders(); + this.displayedOrder = player.getDisplayedOrder(); this.ignoredHints = player.getIgnoredHints(); this.skills = player.getSkills(); this.bumpedSkills = player.getBumpedSkills(); this.appearance = player.getAppearance(); + this.questProgresses = player.getQuestProgresses(); + this.dailyQuest = player.getDailyQuest(); + this.androidQuests = player.getAndroidQuests(); + this.androidShopHistory = player.getAndroidShopHistory(); + this.familyName = player.getFamilyName(); + this.actionHistory = player.getActionHistory(); } - + + private static int transferSkill(Map skills, String from, String to, int max) { + int currentSrc = skills.getOrDefault(from, 1); + int points = currentSrc - 1; + int currentDest; + if(to != null) { + currentDest = skills.getOrDefault(to, 1); + } else { + currentDest = 0; + } + currentDest += points; + int freePoints = 0; + if(currentDest > max) { + freePoints += currentDest - max; + currentDest = max; + } + skills.remove(from); + if(to != null) { + skills.put(to, currentDest); + } + return freePoints; + } + + private static void transferBumpedSkill(Map> bumpedSkills, String from, String to) { + for(Collection set : bumpedSkills.values()) { + if (set.contains(from)) { + set.remove(from); + if(to != null) set.add(to); + } + } + } + @JsonCreator - private PlayerConfigFile() {} + private PlayerConfigFile( + @JsonSetter("skills") Map skillsMap, + @JsonSetter("bumped_skills") Map> bumpedSkillsMap, + @JsonSetter("skill_points") Integer currentSkillPointsObj + ) { + // Transfer some skills + int currentSkillPoints = currentSkillPointsObj != null ? currentSkillPointsObj : 0; + + currentSkillPoints += transferSkill(skillsMap, "science", "barter", Player.MAX_NATURAL_SKILL_LEVEL); + transferBumpedSkill(bumpedSkillsMap, "science", "barter"); + + currentSkillPoints += transferSkill(skillsMap, "automata", null, 0); + transferBumpedSkill(bumpedSkillsMap, "automata", null); + + this.skillPoints = currentSkillPoints; + for(Map.Entry entry : skillsMap.entrySet()) { + this.skills.put(Skill.fromId(entry.getKey()), entry.getValue()); + } + for(Map.Entry> entry : bumpedSkillsMap.entrySet()) { + this.bumpedSkills.put(entry.getKey(), entry.getValue().stream().map(Skill::fromId).collect(Collectors.toList())); + } + } @JsonSetter(nulls = Nulls.FAIL) public String getName() { @@ -148,7 +217,11 @@ public int getKarma() { public int getCrowns() { return crowns; } - + + public String getDisplayedOrder() { + return displayedOrder; + } + @JsonSetter(nulls = Nulls.SKIP) public Inventory getInventory() { return inventory; @@ -178,7 +251,12 @@ public Set getLootCodes() { public Set getAchievements() { return achievements; } - + + @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) + public Map getOrders() { + return orders; + } + @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) public Map getIgnoredHints() { return ignoredHints; @@ -198,4 +276,29 @@ public Map> getBumpedSkills() { public Map getAppearance() { return appearance; } + + @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) + public Map getQuestProgresses() { + return questProgresses; + } + + public ValueWithExpiry> getDailyQuest() { + return dailyQuest; + } + + public Map getAndroidQuests() { + return androidQuests; + } + + public AndroidShopHistory getAndroidShopHistory() { + return androidShopHistory; + } + + public String getFamilyName() { + return familyName; + } + + public Map getActionHistory() { + return actionHistory; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/player/PlayerManager.java b/gameserver/src/main/java/brainwine/gameserver/player/PlayerManager.java index 5f2eb7de..d0f938a4 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/PlayerManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/PlayerManager.java @@ -4,7 +4,6 @@ import java.io.File; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -12,6 +11,8 @@ import java.util.Map; import java.util.UUID; +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.util.MapHelper; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.mindrot.jbcrypt.BCrypt; @@ -21,9 +22,13 @@ import brainwine.shared.TokenGenerator; public class PlayerManager { - - // TODO check platforms as well - public static final List SUPPORTED_VERSIONS = Arrays.asList("1.13.3", "2.11.0.1", "2.11.1", "3.13.1"); + public static Map SUPPORTED_VERSIONS = MapHelper.map( + String.class, String.class, + "iPh|iPo|iPa", "2.11.0", + "Windows", "3.11.0", + "Unity", "3.11.0", + "default", "2.11.0" + ); private static final Logger logger = LogManager.getLogger(); private final Map playersById = new HashMap<>(); private final Map playersByName = new HashMap<>(); @@ -31,8 +36,18 @@ public class PlayerManager { private final List onlinePlayers = new ArrayList<>(); public PlayerManager() { + loadSupportedVersions(); loadPlayers(); } + + private void loadSupportedVersions() { + Map cfg = MapHelper.getMap(GameConfiguration.getBaseConfig(), "client_version"); + if(cfg != null) { + SUPPORTED_VERSIONS = cfg; + } else { + logger.warn(SERVER_MARKER, "Supported client versions are not configured."); + } + } private void loadPlayers() { logger.info(SERVER_MARKER, "Loading player data ..."); @@ -170,7 +185,7 @@ public void changePlayerName(Player player, String name) { public void onPlayerConnect(Player player) { onlinePlayers.add(player); - logger.info(SERVER_MARKER, "{} logged into zone {}", player.getName(), player.getZone().getName()); + logger.info(SERVER_MARKER, "{} logged into zone {} with IP address {}", player.getName(), player.getZone().getName(), player.getConnection().getIpAddress()); } public void onPlayerDisconnect(Player player) { diff --git a/gameserver/src/main/java/brainwine/gameserver/player/PlayerStatistics.java b/gameserver/src/main/java/brainwine/gameserver/player/PlayerStatistics.java index a667c3c5..07d7f753 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/PlayerStatistics.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/PlayerStatistics.java @@ -1,10 +1,16 @@ package brainwine.gameserver.player; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; +import java.util.stream.Collectors; +import brainwine.gameserver.entity.EntityGroup; +import brainwine.gameserver.item.ItemGroup; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.zone.DungeonType; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; import com.fasterxml.jackson.annotation.JsonCreator; @@ -16,6 +22,7 @@ import brainwine.gameserver.achievement.DiscoveryAchievement; import brainwine.gameserver.achievement.ExploringAchievement; import brainwine.gameserver.achievement.HuntingAchievement; +import brainwine.gameserver.achievement.InsurrectionAchievement; import brainwine.gameserver.achievement.LooterAchievement; import brainwine.gameserver.achievement.MiningAchievement; import brainwine.gameserver.achievement.RaiderAchievement; @@ -32,6 +39,8 @@ @JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE) @JsonIgnoreProperties(ignoreUnknown = true) public class PlayerStatistics { + + private static Collection teleporters; private Map itemsMined = new HashMap<>(); private Map itemsScavenged = new HashMap<>(); @@ -48,10 +57,12 @@ public class PlayerStatistics { private int mawsPlugged; private int undertakings; private int deliverances; + private int evokersInhibited; private int deaths; private int landmarksUpvoted; private int landmarkVotesReceived; - + private int crownsSpent; + @JsonIgnore private Player player; @@ -209,6 +220,22 @@ public int getDiscoveries(Item item) { public Map getDiscoveries() { return Collections.unmodifiableMap(discoveries); } + + public int getTeleporterDiscoveries() { + if(teleporters == null) { + teleporters = ItemRegistry.getItems().stream() + .filter(i -> i.getId().startsWith("mechanical") && i.getId().contains("teleporter")) + .collect(Collectors.toList()); + } + + int total = 0; + + for(Item item : teleporters) { + total += getDiscoveries(item); + } + + return total; + } public void trackKill(EntityConfig entity) { if(!kills.containsKey(entity)) { @@ -233,6 +260,14 @@ public int getTotalKills() { public int getKills(EntityConfig entity) { return kills.getOrDefault(entity, 0); } + + public int getKills(EntityGroup group) { + return player.getStatistics().getKills().entrySet().stream() + .filter(entry -> entry.getKey().getGroup() == group) + .map(Entry::getValue) + .reduce(Integer::sum) + .orElse(0); + } public Map getKills() { return Collections.unmodifiableMap(kills); @@ -344,9 +379,9 @@ public int getContainersLooted() { return containersLooted; } - public void trackDungeonRaided() { + public void trackDungeonRaided(DungeonType dungeonType) { dungeonsRaided++; - player.addExperience(100); + player.addExperience(dungeonType.getXpReward()); player.updateAchievementProgress(RaiderAchievement.class); } @@ -401,6 +436,15 @@ public void trackLandmarksUpvoted() { player.updateAchievementProgress(VotingAchievement.class); } + public int getEvokersInhibited() { + return evokersInhibited; + } + + public void trackEvokersInhibited(int count) { + evokersInhibited += count; + player.updateAchievementProgress(InsurrectionAchievement.class); + } + public int getLandmarkVotesReceived() { return landmarkVotesReceived; } @@ -433,4 +477,12 @@ public void setDeaths(int deaths) { public int getDeaths() { return deaths; } + + public int getCrownsSpent() { + return crownsSpent; + } + + public void trackCrownsSpent(int crowns) { + crownsSpent += crowns; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/player/Skill.java b/gameserver/src/main/java/brainwine/gameserver/player/Skill.java index c95e8e64..b525dd8f 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/Skill.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/Skill.java @@ -5,7 +5,7 @@ public enum Skill { AGILITY, - AUTOMATA, + BARTER, BUILDING, COMBAT, ENGINEERING, @@ -13,12 +13,11 @@ public enum Skill { LUCK, MINING, PERCEPTION, - SCIENCE, STAMINA, SURVIVAL; public static Skill[] getAdvancedSkills() { - return new Skill[] {AUTOMATA, COMBAT, ENGINEERING, HORTICULTURE, LUCK, SCIENCE, SURVIVAL}; + return new Skill[] {COMBAT, ENGINEERING, HORTICULTURE, LUCK, BARTER, SURVIVAL}; } public static Skill fromId(String id) { diff --git a/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java b/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java index aa7e8c4e..57e9bbb0 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/TradeSession.java @@ -155,7 +155,7 @@ private void onItemQuantitySelected(Player player, Item item, int quantity) { // Show offer status dialog if(player == initiator) { - player.showDialog(Dialogs.createInitiatorOfferStatusDialog(recipient, offers), input -> { + player.showDialog(Dialogs.createInitiatorOfferStatusDialog(recipient, offers, player.isV3()), input -> { // Validate input if(input.length != 1) { abort(); @@ -183,7 +183,7 @@ private void onItemQuantitySelected(Player player, Item item, int quantity) { } }); } else if(player == recipient) { - player.showDialog(Dialogs.createRecipientOfferStatusDialog(initiator, initiatorOffers, offers), input -> { + player.showDialog(Dialogs.createRecipientOfferStatusDialog(initiator, initiatorOffers, offers, player.isV3()), input -> { // Validate input if(input.length != 1) { abort(); @@ -242,8 +242,8 @@ private void onInitiatorGiveFreely() { }); // Show feedback - initiator.showDialog(Dialogs.createOfferDialog(String.format("You sent free goodies to %s!", recipient.getName()), "Sent:", initiatorOffers)); - recipient.showDialog(Dialogs.createOfferDialog(String.format("You received goodies from %s!", initiator.getName()), "Received:", initiatorOffers)); + initiator.showDialog(Dialogs.createOfferDialog(String.format("You sent free goodies to %s!", recipient.getName()), "Sent:", initiatorOffers, initiator.isV3())); + recipient.showDialog(Dialogs.createOfferDialog(String.format("You received goodies from %s!", initiator.getName()), "Received:", initiatorOffers, recipient.isV3())); } /** @@ -273,10 +273,10 @@ private void onInitiatorSendTradeRequest() { isRecipientAware = true; // Show feedback to initiator - initiator.showDialog(Dialogs.createOfferDialog("Your offer has been sent:", initiatorOffers)); + initiator.showDialog(Dialogs.createOfferDialog("Your offer has been sent:", initiatorOffers, initiator.isV3())); // Show trade request dialog to recipient - recipient.showDialog(Dialogs.createOfferDialog(String.format("%s wants to trade:", initiator.getName()), null, "Are you interested?", initiatorOffers).setActions("yesno"), input -> { + recipient.showDialog(Dialogs.createOfferDialog(String.format("%s wants to trade:", initiator.getName()), null, "Are you interested?", initiatorOffers, recipient.isV3()).setActions("yesno"), input -> { // Handle cancellation if(input.length == 1 && input[0].equals("cancel")) { cancel(recipient); @@ -313,7 +313,7 @@ private void onRecipientAcceptTradeRequest() { // Show feedback to recipient recipient.showDialog(Dialogs.createOfferDialog("You accepted a trade request for:", null, - String.format("Drag the item you'd like to trade to %s, then select the amount to offer.", initiator.getName()), initiatorOffers)); + String.format("Drag the item you'd like to trade to %s, then select the amount to offer.", initiator.getName()), initiatorOffers, recipient.isV3())); // Update timeout setTimeoutSeconds(20); @@ -338,7 +338,7 @@ private void onRecipientSubmitOffer() { state = State.INITIATOR_VIEWING_OFFER; // Show feedback to recipient - recipient.showDialog(Dialogs.createOfferDialog("Your offer has been sent:", recipientOffers)); + recipient.showDialog(Dialogs.createOfferDialog("Your offer has been sent:", recipientOffers, recipient.isV3())); // Show the recipient's offer to the initiator initiator.showDialog(Dialogs.createFinalOfferDialog(initiator, recipient, initiatorOffers, recipientOffers), input -> { @@ -384,8 +384,8 @@ private void complete() { }); // Show trade completion dialog - initiator.showDialog(Dialogs.createOfferDialog(String.format("You traded with %s.", recipient.getName()), "Received:", recipientOffers)); - recipient.showDialog(Dialogs.createOfferDialog(String.format("You traded with %s.", initiator.getName()), "Received:", initiatorOffers)); + initiator.showDialog(Dialogs.createOfferDialog(String.format("You traded with %s.", recipient.getName()), "Received:", recipientOffers, initiator.isV3())); + recipient.showDialog(Dialogs.createOfferDialog(String.format("You traded with %s.", initiator.getName()), "Received:", initiatorOffers, recipient.isV3())); } /** @@ -548,29 +548,43 @@ private boolean isTradeCurrent(Player player) { /** * Helper class for creating trading-related dialogs. */ - private static class Dialogs { + public static class Dialogs { public static final List ITEM_QUANTITY_OPTIONS = Arrays.asList("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "15", "20", "25", "30", "40", "50", "75", "100", "200", "500", "1000", "5000", "25000", "100000"); - - public static Dialog createQuantitySelectorDialog(Player offerer, Player target, Item item) { + + public static DialogSection createQuantitySelector(int maxQuantity) { + return createQuantitySelector(maxQuantity, 1); + } + + public static DialogSection createQuantitySelector(int maxQuantity, int unit) { // Get quantity options that are available to the player List quantityOptions = ITEM_QUANTITY_OPTIONS.stream() - .filter(quantity -> offerer.getInventory().hasItem(item, Integer.parseInt(quantity))) + .filter(quantity -> Integer.parseInt(quantity) * unit <= maxQuantity) + .map(quantity -> Integer.toString(Integer.parseInt(quantity) * unit)) .collect(Collectors.toList()); - + + return new DialogSection() + .setInput(new DialogSelectInput() + .setOptions(quantityOptions) + .setKey("quantity")); + } + + public static DialogSection createQuantitySelector(Player offerer, Item item) { + return createQuantitySelector(offerer.getInventory().getQuantity(item)); + } + + public static Dialog createQuantitySelectorDialog(Player offerer, Player target, Item item) { return new Dialog() .addSection(new DialogSection() .setTitle(String.format("Trade with %s", target.getName()))) - .addSection(new DialogSection() - .setText(String.format("Quantity of %s to trade:", item.getTitle())) - .setInput(new DialogSelectInput() - .setOptions(quantityOptions) - .setKey("quantity"))); + .addSection(createQuantitySelector(offerer, item) + .setText(String.format("Quantity of %s to trade:", offerer.isV3() ? item.getFancyTitle() : item.getTitle())) + ); } - public static Dialog createInitiatorOfferStatusDialog(Player recipient, Map offers) { - Dialog dialog = createOfferDialog("Your current offer:", offers); + public static Dialog createInitiatorOfferStatusDialog(Player recipient, Map offers, boolean isV3) { + Dialog dialog = createOfferDialog("Your current offer:", offers, isV3); // Add multi-item trading hint if the item limit hasn't been reached yet if(offers.size() < TradeSession.ITEM_LIMIT) { @@ -587,9 +601,9 @@ public static Dialog createInitiatorOfferStatusDialog(Player recipient, Map initiatorOffers, Map recipientOffers) { - Dialog dialog = createOfferDialog(String.format("%s's offer:", initiator.getName()), initiatorOffers).setActions("Cancel", "Submit offer"); - dialog.addSection(createOfferSection(recipientOffers).setTitle("Your current offer:")); + public static Dialog createRecipientOfferStatusDialog(Player initiator, Map initiatorOffers, Map recipientOffers, boolean isV3) { + Dialog dialog = createOfferDialog(String.format("%s's offer:", initiator.getName()), initiatorOffers, isV3).setActions("Cancel", "Submit offer"); + dialog.addSection(createOfferSection(recipientOffers, isV3).setTitle("Your current offer:")); // Add multi-item trading hint if the item limit hasn't been reached yet if(recipientOffers.size() < TradeSession.ITEM_LIMIT) { @@ -602,21 +616,21 @@ public static Dialog createRecipientOfferStatusDialog(Player initiator, Map initiatorOffers, Map recipientOffers) { - return createOfferDialog(String.format("%s has offered:", recipient.getName()), recipientOffers).setActions("yesno") - .addSection(createOfferSection(initiatorOffers).setText("For your:")) + return createOfferDialog(String.format("%s has offered:", recipient.getName()), recipientOffers, initiator.isV3()).setActions("yesno") + .addSection(createOfferSection(initiatorOffers, initiator.isV3()).setText("For your:")) .addSection(new DialogSection().setText("Do you accept this trade?")); } - public static Dialog createOfferDialog(String title, Map offer) { - return createOfferDialog(title, null, offer); + public static Dialog createOfferDialog(String title, Map offer, boolean isV3) { + return createOfferDialog(title, null, offer, isV3); } - public static Dialog createOfferDialog(String title, String text, Map offer) { - return createOfferDialog(title, text, null, offer); + public static Dialog createOfferDialog(String title, String text, Map offer, boolean isV3) { + return createOfferDialog(title, text, null, offer, isV3); } - public static Dialog createOfferDialog(String title, String text, String footer, Map offer) { - Dialog dialog = new Dialog().addSection(createOfferSection(offer).setTitle(title).setText(text)); + public static Dialog createOfferDialog(String title, String text, String footer, Map offer, boolean isV3) { + Dialog dialog = new Dialog().addSection(createOfferSection(offer, isV3).setTitle(title).setText(text)); if(footer != null) { dialog.addSection(new DialogSection().setText(footer)); @@ -625,12 +639,13 @@ public static Dialog createOfferDialog(String title, String text, String footer, return dialog; } - private static DialogSection createOfferSection(Map offers) { + private static DialogSection createOfferSection(Map offers, boolean isV3) { DialogSection section = new DialogSection(); offers.forEach((item, quantity) -> { section.addItem(new DialogListItem().setItem(item.getCode()) .setImage(String.format("inventory/%s", item.getId())) - .setText(String.format("%s x %s", item.getTitle(), quantity))); + .setText(String.format("%s x %s", isV3 ? item.getFancyTitle() : item.getTitle(), quantity)) + .setSupportRichText(true)); }); return section; } diff --git a/gameserver/src/main/java/brainwine/gameserver/prefab/Prefab.java b/gameserver/src/main/java/brainwine/gameserver/prefab/Prefab.java index 18536bf3..6efcc818 100644 --- a/gameserver/src/main/java/brainwine/gameserver/prefab/Prefab.java +++ b/gameserver/src/main/java/brainwine/gameserver/prefab/Prefab.java @@ -18,6 +18,7 @@ public class Prefab { private boolean loot; private boolean decay; private boolean mirrorable; + private int sinking; private int width; private int height; private Block[] blocks; @@ -32,6 +33,7 @@ protected Prefab(String name, PrefabConfigFile config, PrefabBlocksFile blockDat loot = config.hasLoot(); decay = config.hasDecay(); mirrorable = config.isMirrorable(); + sinking = config.getSinking(); replacements = config.getReplacements(); correspondingReplacements = config.getCorrespondingReplacements(); } @@ -72,6 +74,10 @@ public boolean hasDecay() { public boolean isMirrorable() { return mirrorable; } + + public int getSinking() { + return sinking; + } public int getWidth() { return width; diff --git a/gameserver/src/main/java/brainwine/gameserver/prefab/PrefabConfigFile.java b/gameserver/src/main/java/brainwine/gameserver/prefab/PrefabConfigFile.java index 29068555..9f971226 100644 --- a/gameserver/src/main/java/brainwine/gameserver/prefab/PrefabConfigFile.java +++ b/gameserver/src/main/java/brainwine/gameserver/prefab/PrefabConfigFile.java @@ -36,6 +36,9 @@ public class PrefabConfigFile { @JsonProperty("metadata") private Map> metadata = new HashMap<>(); + + @JsonProperty("sinking") + private int sinking; @JsonCreator private PrefabConfigFile() {} @@ -49,6 +52,7 @@ protected PrefabConfigFile(Prefab prefab) { replacements = prefab.getReplacements(); correspondingReplacements = prefab.getCorrespondingReplacements(); metadata = prefab.getMetadata(); + sinking = prefab.getSinking(); } public boolean isDungeon() { @@ -70,6 +74,10 @@ public boolean hasDecay() { public boolean isMirrorable() { return mirrorable; } + + public int getSinking() { + return sinking; + } public Map> getMetadata() { return metadata; diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/DailyQuests.java b/gameserver/src/main/java/brainwine/gameserver/quest/DailyQuests.java new file mode 100644 index 00000000..83795657 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/DailyQuests.java @@ -0,0 +1,72 @@ +package brainwine.gameserver.quest; + +import java.util.List; + +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.ValueWithExpiry; + +public class DailyQuests { + public static void tryIssueDailyQuest(Player player) { + ValueWithExpiry> currentV = player.getDailyQuest(); + + if(currentV == null || currentV.isExpired()) { + List newQuests = RandomQuests.generateRandomPlayerQuests(player, RandomQuestDomain.DAILY, RandomQuests.getConfiguration().dailyQuestCount); + + for(int i = 1; i <= newQuests.size(); i++) { + Quest newQuest = newQuests.get(i - 1); + if(newQuest == null) return; + newQuest.setId("daily_player_quest_" + i); + newQuest.setTitle("Daily Quest #" + (newQuests.size() - i + 1)); + newQuest.setGroup("Daily Quests"); + } + + if(currentV.getValue() != null) { + for(Quest current : currentV.getValue()) { + String oldQuestId = current.getId(); + PlayerQuests.cancelQuest(player, oldQuestId, false); + + player.getQuestProgresses().remove(oldQuestId); + PlayerQuests.sendPlayerCancelQuestMessage(player, current); + } + } + + player.setDailyQuest(new ValueWithExpiry<>(newQuests, RandomQuests.getConfiguration().dailyQuestInterval)); + for(Quest newQuest : newQuests) { + PlayerQuests.beginQuest(player, newQuest); + } + + if(newQuests.size() == 1) { + player.notify("You have a new daily quest! Check your quests menu in the top left to see what you need to do!"); + } else { + player.notify("You have new daily quests! Check your quests menu in the top left to see what you need to do!"); + } + } + } + + public static void sendDailyQuestTime(Player player, long timeLeft) { + ValueWithExpiry> currentV = player.getDailyQuest(); + + String timeString; + if(timeLeft >= 3600000) { + long val = (timeLeft / 3600000L); + timeString = val == 1 ? val + " hour" : val + " hours"; + } else { + long val = (timeLeft / 60000L); + timeString = val == 1 ? val + " minute" : val + " minutes"; + } + + if(currentV != null && currentV.getValue() != null) { + for(Quest quest : currentV.getValue()) { + QuestProgress progress = player.getQuestProgresses().get(quest.getId()); + if(progress == null) continue; + + String oldDescription = quest.getDescription(); + quest.setDescription("(expires in " + timeString + ") " + oldDescription); + + PlayerQuests.sendPlayerQuestMessage(player, progress); + + quest.setDescription(oldDescription); + } + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/HardcodedQuest.java b/gameserver/src/main/java/brainwine/gameserver/quest/HardcodedQuest.java new file mode 100644 index 00000000..1f4c9984 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/HardcodedQuest.java @@ -0,0 +1,12 @@ +package brainwine.gameserver.quest; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class HardcodedQuest { + @JsonProperty("quest") + private String questId; + + public String getQuestId() { + return questId; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/PlayerQuestDialog.java b/gameserver/src/main/java/brainwine/gameserver/quest/PlayerQuestDialog.java new file mode 100644 index 00000000..68c5830a --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/PlayerQuestDialog.java @@ -0,0 +1,126 @@ +package brainwine.gameserver.quest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.player.Player; + +public class PlayerQuestDialog { + private PlayerQuestDialog() {} + + public static Dialog questOffersDialogGet(List quests) { + Dialog result = new Dialog().setTitle("My Quest Offers").setType(DialogType.ANDROID); + + result.addSection(new DialogSection().setText("Here are my quest offers for you:")); + for(Quest quest : quests) { + DialogSection section = new DialogSection().setText(quest.getTitle()).setChoice(quest.getId()); + + result.addSection(section); + } + + result.setActions("Cancel"); + + return result; + } + + public static Quest questOffersDialogGetSelectedOffer(List quests, Object[] ans) { + if(ans.length == 0) return null; + if("cancel".equals(ans[0])) return null; + + else return quests.stream().filter(q -> Objects.equals(q.getId(), ans[0])).findFirst().orElse(null); + } + + public static Dialog confirmBeginQuestDialogGet(Quest quest) { + Dialog result = new Dialog().setTitle(quest.getTitle()).setType(DialogType.ANDROID); + + result.addSection(new DialogSection().setText(quest.getStory().getIntro())); + + result.setActions("Cancel", quest.getStory().getAccept()); + + return result; + } + + public static Dialog beginQuestDialogGet(Player player, Quest quest) { + return DialogHelper.messageDialog(player.isV3() ? quest.getStory().getBegin() : quest.getStory().getBeginMobile()).setType(DialogType.ANDROID); + } + + public static Dialog playerQuestsDialogGet(Player player, boolean privileged, boolean v3) { + Dialog result = new Dialog().setTitle("Your Quests"); + + List all = getPlayerQuestsSection(player, false, privileged, v3); + + for(DialogSection section : all) { + result.addSection(section); + } + + return result; + } + + public static void playerQuestsDialogHandle(Player player, boolean privileged, Object[] ans) { + if(ans.length > 0 && "cancel".equals(ans[0])) return; + + if(ans.length == 0 || !(ans[0] instanceof String)) return; + + String[] args = ((String) ans[0]).split("\\."); + + if("quest".equals(args[0])) { + if(args.length >= 3 && "cancel".equals(args[2])) { + PlayerQuests.cancelQuest(player, args[1], privileged); + } + } + } + + public static List getPlayerQuestsSection(Player player, boolean canFinishQuest, boolean privileged, boolean v3) { + List result = new ArrayList<>(); + List resultCompleted = new ArrayList<>(); + for(QuestProgress questProgress : player.getQuestProgresses().values()) { + if(!questProgress.isComplete()) { + result.addAll(questProgress.getDialogSection(player, v3)); + } else if(privileged) { + DialogSection cancelSection = new DialogSection() + .setChoice(String.format("quest.%s.%s", questProgress.getQuestId(), "cancel")) + .setText("Forget Completion of " + questProgress.getQuestId()); + + resultCompleted.add(cancelSection); + } + } + + // So that the completed quests appear in the end. + result.addAll(resultCompleted); + return result; + } + + /* DRIVER FUNCTIONS */ + + public static void offerQuests(Player player, List quests, Consumer onSelect) { + player.showDialog(questOffersDialogGet(quests), ans -> { + Quest quest = questOffersDialogGetSelectedOffer(quests, ans); + + if(quest != null) { + offerSingleQuest(player, quest, onSelect); + } + }); + } + + public static void offerSingleQuest(Player player, Quest quest, Consumer onSelect) { + player.showDialog(confirmBeginQuestDialogGet(quest), ans -> { + if(ans.length < 1 || "cancel".equals(ans[0])) return; + onSelect.accept(quest); + }); + } + + public static void showPlayerQuests(Player player) { + showPlayerQuests(player, player); + } + + public static void showPlayerQuests(Player admin, Player player) { + admin.showDialog(playerQuestsDialogGet(player, admin.isGodMode(), admin.isV3()), ans -> playerQuestsDialogHandle(player, admin.isGodMode(), ans)); + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/PlayerQuests.java b/gameserver/src/main/java/brainwine/gameserver/quest/PlayerQuests.java new file mode 100644 index 00000000..33ee1fa0 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/PlayerQuests.java @@ -0,0 +1,239 @@ +package brainwine.gameserver.quest; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.QuestMessage; + +import static brainwine.shared.LogMarkers.SERVER_MARKER; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class PlayerQuests { + private static final Logger logger = LogManager.getLogger(); + + private PlayerQuests() {} + + public static void deleteUnknownQuestProgress(Player player) { + if(player.getQuestProgresses() == null) return; + + for(String questId : new ArrayList<>(player.getQuestProgresses().keySet())) { + if(Quests.get(player, questId) == null) { + player.getQuestProgresses().remove(questId); + } + } + + } + + public static void beginQuest(Player player, Quest quest) { + if(player == null) return; + + if(quest == null) { + player.notify("Quest not found!"); + return; + } + + List progresses = new ArrayList<>(quest.getTasks().size()); + + for(int i = 0; i < quest.getTasks().size(); i++) { + progresses.add(0); + } + + QuestProgress progress = new QuestProgress(quest.getId(), progresses); + + player.getQuestProgresses().put(quest.getId(), progress); + + if(quest.getId().contains("random")) { + player.getAndroidQuests().put(quest.getId(), quest); + } + + player.notify("Quest has started! Use the /quests command to view your progress at any time."); + sendPlayerQuestMessage(player, progress); + performAction(player, quest, QuestAction.Type.BEGIN, false); + } + + public static boolean canFinishQuest(Player player, Quest quest, boolean needToReturn) { + if(player == null) return false; + + if(quest == null) { + player.notify("Quest not found!"); + return false; + } + + QuestProgress progress = player.getQuestProgresses().get(quest.getId()); + + if(progress == null) { + player.notify("Your quest progress is not found!"); + return false; + } + + for(int i = 0; i < quest.getTasks().size(); i++) { + int currentQuantity = player.getQuestProgresses().get(quest.getId()).getTaskProgress(i); + + QuestTask task = quest.getTasks().get(i); + + if(!needToReturn && task.isReturnTask()) continue; + + if(!task.checkComplete(player, currentQuantity) || !task.checkCollectInventory(player)) { + return false; + } + } + + return true; + } + + public static void cancelQuest(Player player, String questId) { + cancelQuest(player, questId, false); + } + + public static void cancelQuest(Player player, String questId, boolean privileged) { + QuestProgress progress = player.getQuestProgresses().get(questId); + + if(!privileged && (progress == null || progress.isComplete())) return; + + String reason = privileged ? null : progress.getCannotCancelReason(player); + if(reason != null) { + player.showDialog(DialogHelper.messageDialog("Cannot Cancel Quest", reason)); + return; + } + + if(!privileged && !progress.revertActions(player)) { + logger.warn("Could not revert some quest actions for player {}!", player.getName()); + } + + player.getQuestProgresses().remove(questId); + sendPlayerCancelQuestMessage(player, progress); + } + + public static void finishQuest(Player player, Quest quest) { + QuestProgress progress = player.getQuestProgresses().get(quest.getId()); + + if(progress.isComplete()) return; + + for(int i = 0; i < quest.getTasks().size(); i++) { + QuestTask task = quest.getTasks().get(i); + + progress.setTaskProgress(i, task.getQuantity()); + + if(task.getCollectInventory() != null) { + task.getCollectInventory().removeFromPlayer(player); + } + } + + for(int i = 0; i < quest.getTasks().size(); i++) { + progress.setTaskProgress(i, quest.getTasks().get(i).getQuantity()); + } + + quest.getReward().reward(player); + progress.markAsComplete(); + QuestEvents.handleCompleteQuest(player); + sendPlayerQuestMessage(player, progress); + + // get rid of the quest if it was randomly generated + if(quest.getId().contains("random")) { + player.getAndroidQuests().remove(quest.getId()); + player.getQuestProgresses().remove(quest.getId()); + } + + } + + public static DialogSection performAction(Player player, Quest quest, QuestAction.Type actionType, boolean preventMutations) { + if(quest.getActions() == null) return null; + + List actions = quest.getActions().get(actionType); + + if(actions == null) return null; + + DialogSection result = null; + for(QuestAction action : actions) { + DialogSection newSection = action.performAction(player, preventMutations); + if(newSection != null) result = newSection; + } + + return result; + } + + public static void handleQuestFinalReturn(Player player, Quest quest) { + // if can't finish even with the return task done, set the return task progress to the previous value + if(!canFinishQuest(player, quest, false)) { + return; + } + + QuestProgress progress = player.getQuestProgresses().get(quest.getId()); + if(progress == null) return; + + for(int i = 0; i < quest.getTasks().size(); i++) { + QuestTask task = quest.getTasks().get(i); + + if(task.isReturnTask()) { + progress.setTaskProgress(i, quest.getTasks().get(i).getQuantity()); + } + } + } + + public static void sendInitialPlayerQuestMessages(Player player) { + Map progresses = player.getQuestProgresses(); + + if(progresses == null) return; + + for(QuestProgress progress : progresses.values()) { + sendPlayerQuestMessage(player, progress); + } + + List dailyQuests = player.getDailyQuest() == null ? null : player.getDailyQuest().getValue(); + if(player.getDailyQuest() != null && !player.getDailyQuest().isExpired() && dailyQuests != null) { + DailyQuests.sendDailyQuestTime(player, player.getDailyQuest().getTimeUntilExpiry(System.currentTimeMillis())); + } + } + + public static void sendPlayerQuestMessage(Player player, QuestProgress progress) { + if(progress == null) return; + + Quest quest = Quests.get(player, progress.getQuestId()); + + if(quest == null) return; + + // TODO: detect mobile player properly + Map details = new HashMap<>(player.isV3() ? quest.getPcDetails() : quest.getMobileDetails()); + + List taskDescriptions = (List)details.get("tasks"); + for(int i = 0; i < taskDescriptions.size(); i++) { + QuestTask task = i < quest.getTasks().size() ? quest.getTasks().get(i) : null; + int wantedProgress = task.getQuantity(); + int currentProgress = progress.getTaskProgress(i); + if(wantedProgress > 1) { + taskDescriptions.set(i, String.format("%s, (Progress: %d/%d)", taskDescriptions.get(i), currentProgress, wantedProgress)); + } + } + + player.sendMessage(new QuestMessage(details, progress.getClientStatus(player))); + } + + public static void sendPlayerCancelQuestMessage(Player player, Quest current) { + if(player.getQuestProgresses() == null || current == null) return; + QuestProgress progress = player.getQuestProgresses().get(current.getId()); + if(progress != null) sendPlayerCancelQuestMessage(player, progress); + } + + public static void sendPlayerCancelQuestMessage(Player player, QuestProgress progress) { + if(progress == null) return; + Quest quest = progress.getQuest(player); + + Map pcDetails = new HashMap<>(); + pcDetails.put("id", progress.getQuestId()); + pcDetails.put("group", "Cancelled"); + pcDetails.put("title", quest == null || quest.getTitle() == null ? "Cancelled Quest" : quest.getTitle()); + pcDetails.put("xp", 0); + pcDetails.put("desc", quest == null || quest.getDescription() == null ? "This quest has been cancelled. Disconnect and rejoin to make it disappear." : quest.getDescription()); + + progress.getClientStatus(player); + player.sendMessage(new QuestMessage(pcDetails, progress.getClientStatus(player))); + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/Quest.java b/gameserver/src/main/java/brainwine/gameserver/quest/Quest.java new file mode 100644 index 00000000..42e97f06 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/Quest.java @@ -0,0 +1,168 @@ +package brainwine.gameserver.quest; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.util.MapHelper; + +@JsonIgnoreProperties("zones") +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Quest { + private String id; + + @JsonProperty("group") + private String group; + + @JsonProperty("title") + private String title; + + @JsonProperty("reward") + private QuestReward reward; + + @JsonProperty("story") + private QuestStory story; + + @JsonProperty("desc") + private String description; + + @JsonProperty(value = "desc_mobile", required = false) + private String descriptionMobile = null; + + @JsonProperty(value = "actions", required = false) + private Map> actions = new HashMap<>(); + + @JsonProperty("tasks") + private List tasks; + + public static Quest get(String questId) { + Quest quest = MapHelper.get(GameConfiguration.getBaseConfig(), questId, Quest.class); + + if(quest == null) return null; + + quest.setId(questId); + return quest; + } + + @JsonIgnore + public Map getPcDetails() { + Map pcDetails = new HashMap<>(); + + pcDetails.put("id", getId()); + pcDetails.put("group", getGroup()); + pcDetails.put("title", getTitle()); + pcDetails.put("xp", getReward().getXp()); + pcDetails.put("crowns", getReward().getCrowns()); + pcDetails.put("desc", getDescription()); + + List tasks = new ArrayList<>(); + + if(getTasks() != null) for(QuestTask task : getTasks()) { + tasks.add(task.getDescription()); + } + + pcDetails.put("tasks", tasks); + + return pcDetails; + } + + @JsonIgnore + public Map getMobileDetails() { + Map mobileDetails = getPcDetails(); + + if(getDescriptionMobile() != null) { + mobileDetails.put("desc", getDescriptionMobile()); + } + + return mobileDetails; + } + + public String getId() { + return id; + } + + public String getGroup() { + return group; + } + + public String getTitle() { + return title; + } + + public QuestReward getReward() { + return reward; + } + + public QuestStory getStory() { + return story; + } + + public String getDescription() { + return description; + } + + public String getDescriptionMobile() { + return descriptionMobile; + } + + public Map> getActions() { + return actions; + } + + public List getTasks() { + return tasks; + } + + public Quest setId(String id) { + this.id = id; + return this; + } + + public Quest setGroup(String group) { + this.group = group; + return this; + } + + public Quest setTitle(String title) { + this.title = title; + return this; + } + + public Quest setReward(QuestReward reward) { + this.reward = reward; + return this; + } + + public Quest setStory(QuestStory story) { + this.story = story; + return this; + } + + public Quest setDescription(String description) { + this.description = description; + return this; + } + + public Quest setDescriptionMobile(String descriptionMobile) { + this.descriptionMobile = descriptionMobile; + return this; + } + + public Quest setActions(Map> actions) { + this.actions = actions; + return this; + } + + public Quest setTasks(List tasks) { + this.tasks = tasks; + return this; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/QuestAction.java b/gameserver/src/main/java/brainwine/gameserver/quest/QuestAction.java new file mode 100644 index 00000000..ed8b15e3 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestAction.java @@ -0,0 +1,196 @@ +package brainwine.gameserver.quest; + +import java.lang.IllegalArgumentException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.item.ItemRegistry; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; + +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.EventMessage; +import brainwine.gameserver.server.messages.InventoryMessage; +import brainwine.shared.JsonHelper; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class QuestAction { + public enum Type { + INTERACT, + BEGIN, + DONE; + } + + public enum Actor { + PLAYER, + ANDROID; + } + + private Actor actor = Actor.PLAYER; + + private String method; + + private List params = new ArrayList<>(0); + + @JsonSetter("params") + public void setParams(Object params) { + if(params instanceof List) this.params = (List)params; + else this.params.add(params); + } + + public Actor getActor() { + return actor; + } + + public String getMethod() { + return method; + } + + public List getParams() { + return params; + } + + public String getCannotCancelReason(Player player) { + switch(getMethod()) { + case "gift_items!": + try { + String reason = null; + for(Object object : getParams()) { + Map items = JsonHelper.readValue(object, new TypeReference>() {}); + for(String k : items.keySet()) { + Item item = ItemRegistry.getItem(k); + if(item.isAir()) continue; + if(!player.getInventory().hasItem(item, items.get(k))) { + if(reason == null) { + reason = String.format("You need to give back my %d %s", items.get(k), item.getTitle()); + } else { + reason += String.format(", %d %s", items.get(k), item.getTitle()); + } + } + } + } + return reason != null ? reason + "." : null; + } catch(JsonProcessingException e) { + e.printStackTrace(); + return "Exception occurred while checking if you can cancel this quest"; + } + case "add_xp": + return "I have given you some XP."; + default: + return null; + } + } + + public String getRevertImplicationsMessage() { + switch(getMethod()) { + case "gift_items!": + try { + String reason = null; + for(Object object : getParams()) { + Map items = JsonHelper.readValue(object, new TypeReference>() {}); + for(String k : items.keySet()) { + Item item = ItemRegistry.getItem(k); + if(item.isAir()) continue; + if(reason == null) { + reason = String.format("You can cancel this quest but I'm going to have to take back my %d %s", items.get(k), item.getTitle()); + } else { + reason += String.format(", %d %s", items.get(k), item.getTitle()); + } + } + } + return reason != null ? reason + "." : null; + } catch(JsonProcessingException e) { + e.printStackTrace(); + return "I would tell you what will happen if you cancelled the quest but I encountered an error."; + } + default: + return null; + } + } + + public DialogSection performAction(Player player, boolean preventMutations) { + try{ + switch(getMethod()) { + case "gift_items!": + if(preventMutations) break; + for(Object object : getParams()) { + Map items = JsonHelper.readValue(object, new TypeReference>() {}); + for(String k : items.keySet()) { + Item item = ItemRegistry.getItem(k); + if(item.isAir()) continue; + player.getInventory().addItem(item, items.get(k)); + player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item))); + } + } + break; + case "event_message!": + if(params.size() < 2 || !(params.get(0) instanceof String)) { + throw new IllegalArgumentException(); + } + player.sendMessage(new EventMessage((String) params.get(0), params.get(1))); + break; + case "set_family_name!": + break; + case "show_android_dialog": + String body = "No info.", title = null; + if(params.size() >= 1) { + if(!(params.get(0) instanceof String)) throw new IllegalArgumentException(); + body = (String) params.get(0); + } + + return new DialogSection().setText(body); + case "add_xp": + if(preventMutations) break; + if(params.size() >= 1) { + int amount = JsonHelper.readValue(params.get(0), new TypeReference() {}); + player.addExperience(amount); + } + break; + default: + player.notify(String.format("Unknown quest action %d", getMethod())); + } + } catch(JsonProcessingException e) { + player.notify("Couldn't perform some actions for this quest due to JSON processing errors."); + } catch(IllegalArgumentException e) { + player.notify(String.format("Malformed quest action parameters for %d.", getMethod())); + } + return null; + } + + public boolean revertAction(Player player) { + switch(getMethod()) { + case "gift_items!": + try { + boolean success = true; + for(Object object : getParams()) { + Map items = JsonHelper.readValue(object, new TypeReference>() {}); + for(String k : items.keySet()) { + Item item = ItemRegistry.getItem(k); + if(item.isAir()) continue; + if(player.getInventory().hasItem(item, items.get(k))) { + player.getInventory().removeItem(item, items.get(k), true); + } else { + success = false; + } + } + } + return success; + } catch(JsonProcessingException e) { + e.printStackTrace(); + return false; + } + case "add_xp": + return false; + default: + return true; + } + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/QuestEvents.java b/gameserver/src/main/java/brainwine/gameserver/quest/QuestEvents.java new file mode 100644 index 00000000..0cd87fcb --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestEvents.java @@ -0,0 +1,147 @@ +package brainwine.gameserver.quest; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Zone; + +public class QuestEvents { + private static boolean patternMatch(List event, Object[] pattern) { + if(event.size() != pattern.length) return false; + int iterationCount = Math.min(event.size(), pattern.length); + for(int i = 0; i < iterationCount; i++) { + if(pattern[i] == null) continue; + if(event.get(i) == null) continue; + + if(!Objects.equals(pattern[i], event.get(i))) return false; + } + + return true; + } + + public static void handleEvent(Player player, Object... pattern) { + handleEventWithQuantity(player, 1, pattern); + } + + public static void handleEventWithQuantity(Player player, int quantity, Object... pattern) { + for(Map.Entry questProgressEntry : player.getQuestProgresses().entrySet()) { + String questId = questProgressEntry.getKey(); + QuestProgress questProgress = questProgressEntry.getValue(); + if(questProgress.isComplete()) continue; + + Quest quest = Quests.get(player, questId); + int i = 0; + + boolean anyProgress = false; + if(pattern != null) for(QuestTask task : quest.getTasks()) { + if(task.getEvents() == null) continue; + + if(!task.doesQualify(player)) { + continue; + } + + for(List event : task.getEvents()) { + try { + if(patternMatch(event, pattern)) { + questProgress.setTaskProgress(i, questProgress.getTaskProgress(i) + quantity); + + anyProgress = true; + break; + } + } catch(Exception e) { + e.printStackTrace(); + } + } + i++; + } + + if(anyProgress) { + PlayerQuests.sendPlayerQuestMessage(player, questProgress); + + PlayerQuests.performAction(player, quest, QuestAction.Type.DONE, false); + } + + if(quest.getId().startsWith("daily") + && PlayerQuests.canFinishQuest(player, quest, true) + ) { + PlayerQuests.finishQuest(player, quest); + } + } + } + + public static void handleDig(Player player) { + handleEvent(player, "dig"); + } + + public static void handleCollectItem(Player player, Item item, int quantity) { + handleEventWithQuantity(player, quantity, "collect_item", "id", item.getId()); + } + + public static void handleCraft(Player player, Item item, int quantity) { + handleEventWithQuantity(player, quantity, "craft", "code", item.getCode()); + } + + public static void handleEnterZone(Player player, Zone zone) { + handleEvent(player, "entered", "zone_name", zone.getName()); + } + + public static void handleKill(Player player, Entity other) { + handleEvent(player, "kill"); + handleEvent(player, "kill", "code", other.getType()); + + if(other.isPlayer()) { + // don't reward players for killing each other + } else { + Npc npc = (Npc) other; + handleEvent(player, "kill", "category", npc.getConfig().getCategory()); + } + + } + + public static void handleExplode(Player player, Entity other) { + handleEvent(player, "explode"); + handleEvent(player, "explode", "code", other.getType()); + + if(other.isPlayer()) { + // don't reward players for killing each other + } else { + Npc npc = (Npc) other; + handleEvent(player, "explode", "category", npc.getConfig().getCategory()); + } + } + + public static void handleRaid(Player player) { + handleEvent(player, "raid"); + } + + public static void handleInhibit(Player player, int quantity) { + handleEventWithQuantity(player, quantity, "inhibit"); + } + + public static void handleBury(Player player) { + handleEvent(player, "bury"); + } + + public static void handleChat(Player player) { + handleEvent(player, "chat"); + } + + public static void handleAppearance(Player player, Map appearance) { + for (Object code : appearance.values()) { + handleEvent(player, "appearance", "code", code); + } + } + + public static void handleInteract(Player player, Npc npc) { + handleEvent(player, "interact", "name", npc.getName()); + } + + public static void handleCompleteQuest(Player player) { + handleEvent(player, "complete_quest"); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/QuestProgress.java b/gameserver/src/main/java/brainwine/gameserver/quest/QuestProgress.java new file mode 100644 index 00000000..c6a62478 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestProgress.java @@ -0,0 +1,202 @@ +package brainwine.gameserver.quest; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.player.Player; + +public class QuestProgress { + @JsonProperty("quest_id") + private String questId; + @JsonProperty("tasks") + @JsonInclude(Include.NON_NULL) + private List taskProgresses; + @JsonProperty("completed_at") + @JsonInclude(Include.NON_NULL) + private Long completedAt = null; + + public QuestProgress() {} + public QuestProgress(String questId, List taskProgresses) { + this.questId = questId; + this.taskProgresses = taskProgresses; + } + + @JsonIgnore + public Quest getQuest(Player player) { + return Quests.get(player, getQuestId()); + } + + public int getTaskProgress(int index) { + if(index < 0 || index >= getTaskProgresses().size()) { + return 0; + } + + return getTaskProgresses().get(index); + } + + public void setTaskProgress(int index, int progress) { + while(getTaskProgresses().size() <= index) { + getTaskProgresses().add(0); + } + getTaskProgresses().set(index, progress); + } + + public String getQuestId() { + return questId; + } + + public List getTaskProgresses() { + return taskProgresses; + } + + public Long getCompletedAt() { + return completedAt; + } + + public String getActionChoice(String action) { + return String.format("quest.%s.%s", questId, action); + } + + @JsonIgnore + public boolean isComplete() { + return completedAt != null; + } + + public void markAsComplete() { + completedAt = System.currentTimeMillis(); + } + + public List getDialogSection(Player player, boolean v3) { + List result = new ArrayList<>(); + DialogSection mainSection = new DialogSection(); + result.add(mainSection); + + Quest quest = getQuest(player); + + if(quest == null) { + mainSection.setTitle("QUEST NOT FOUND"); + + return result; + } + + mainSection.setTitle(quest.getTitle()); + mainSection.setText(quest.getDescription()); + + for(int i = 0; i < quest.getTasks().size(); i++) { + result.addAll(quest.getTasks().get(i).getDialogSection(player, getTaskProgress(i), v3)); + } + + DialogSection cancelSection = new DialogSection().setChoice(getActionChoice("cancel")); + + if(v3) { + cancelSection.setText("Cancel Quest"); + } else { + cancelSection.setText("Cancel Quest").setTextColor("ff0000"); + } + + result.add(cancelSection); + + return result; + } + + @JsonIgnore + public Map getClientStatus(Player player) { + Map result = new HashMap<>(); + Quest quest = Quests.get(player, getQuestId()); + + List completedIndices = new ArrayList<>(); + + for(int i = 0; i < quest.getTasks().size(); i++) { + QuestTask task = quest.getTasks().get(i); + // We are hiding that the collect inventory is complete even if it is. The actual check happens in canFinishQuest. + if(task.checkComplete(player, getTaskProgress(i)) && task.getCollectInventory() == null) { + completedIndices.add(i); + } + } + + result.put("progress", completedIndices); + result.put("complete", getCompletedAt() != null); + result.put("active", true); + + return result; + } + + /**Revert all the progress, or return a string reason if this will fail. + * + * @param player player to cancel the quest for + * @return null if the cancellation succeeded, or a string if there is a problem + */ + public String getCannotCancelReason(Player player) { + Quest quest = getQuest(player); + if(quest == null) return null; + + String reason = null; + for(QuestAction.Type actionType : new QuestAction.Type[] { QuestAction.Type.BEGIN, QuestAction.Type.INTERACT }) { + if(quest.getActions().containsKey(actionType)) { + for(QuestAction action : quest.getActions().get(actionType)) { + String currentReason = action.getCannotCancelReason(player); + + if(currentReason != null) { + if (reason == null) { + reason = currentReason; + } else { + reason += ", " + currentReason; + } + } + } + + } + } + + return reason; + } + + public List getRevertImplicationsMessages(Player player) { + List implications = new ArrayList<>(); + Quest quest = getQuest(player); + if(quest == null) { + implications.add("Cannot find quest!"); + return implications; + }; + + for(QuestAction.Type actionType : new QuestAction.Type[] { QuestAction.Type.BEGIN, QuestAction.Type.INTERACT }) { + if(quest.getActions().containsKey(actionType)) { + for(QuestAction action : quest.getActions().get(actionType)) { + String currentReason = action.getRevertImplicationsMessage(); + + if(currentReason != null) { + implications.add(currentReason); + } + } + + } + } + + return implications; + } + + public boolean revertActions(Player player) { + Quest quest = getQuest(player); + if(quest == null) return false; + + boolean success = true; + for(QuestAction.Type actionType : new QuestAction.Type[] { QuestAction.Type.BEGIN, QuestAction.Type.INTERACT }) { + if(quest.getActions().containsKey(actionType)) { + for(QuestAction action : quest.getActions().get(actionType)) { + success = success && action.revertAction(player); + } + } + } + + return success; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/QuestReward.java b/gameserver/src/main/java/brainwine/gameserver/quest/QuestReward.java new file mode 100644 index 00000000..f23fa3d7 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestReward.java @@ -0,0 +1,60 @@ +package brainwine.gameserver.quest; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.loot.Loot; +import brainwine.gameserver.player.Player; + +public class QuestReward { + @JsonProperty(required = false) + private int xp = 0; + @JsonProperty(required = false) + private int crowns = 0; + @JsonProperty(value = "loot_categories", required = false) + private List lootCategories; + + public void reward(Player player) { + if(crowns != 0) { + player.addCrowns(crowns); + } + if(xp != 0) { + player.addExperience(xp, String.format("You have gained %d XP from completing this quest!", xp)); + } + if(lootCategories != null) { + Loot loot = GameServer.getInstance().getLootManager().getRandomLoot(player, lootCategories); + if(loot != null) { + player.awardLoot(loot); + } + } + } + + public int getXp() { + return xp; + } + + public int getCrowns() { + return crowns; + } + + public List getLootCategories() { + return lootCategories; + } + + public QuestReward setXp(int xp) { + this.xp = xp; + return this; + } + + public QuestReward setCrowns(int crowns) { + this.crowns = crowns; + return this; + } + + public QuestReward setLootCategories(List lootCategories) { + this.lootCategories = lootCategories; + return this; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/QuestStory.java b/gameserver/src/main/java/brainwine/gameserver/quest/QuestStory.java new file mode 100644 index 00000000..216086ed --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestStory.java @@ -0,0 +1,73 @@ +package brainwine.gameserver.quest; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import brainwine.gameserver.player.Player; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class QuestStory { + private String intro; + private String accept; + private String begin; + @JsonProperty("begin_mobile") + private String beginMobile; + private String incomplete; + private String complete; + + public String getIntro() { + return intro; + } + public String getAccept() { + return accept; + } + public String getBegin() { + return begin; + } + public String getBeginMobile() { + return beginMobile; + } + public String getIncomplete() { + return incomplete; + } + public String getComplete() { + return complete; + } + public String getBegin(Player player) { + // TODO: check mobile device player properly + if(getBeginMobile() == null) { + return getBegin(); + } + + if(player.isV3()) { + return getBegin(); + } else { + return getBeginMobile(); + } + } + + public QuestStory setIntro(String intro) { + this.intro = intro; + return this; + } + + public QuestStory setAccept(String accept) { + this.accept = accept; + return this; + } + + public QuestStory setBegin(String begin) { + this.begin = begin; + return this; + } + + public QuestStory setIncomplete(String incomplete) { + this.incomplete = incomplete; + return this; + } + + public QuestStory setComplete(String complete) { + this.complete = complete; + return this; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/QuestTask.java b/gameserver/src/main/java/brainwine/gameserver/quest/QuestTask.java new file mode 100644 index 00000000..b349c9d2 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestTask.java @@ -0,0 +1,212 @@ +package brainwine.gameserver.quest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.player.Player; + +public class QuestTask { + @JsonProperty("desc") + private String description; + + @JsonProperty("quantity") + private int quantity = 1; + + @JsonProperty("events") + private List> events = null; + + @JsonProperty("progress") + private List> progressRequirements = null; + + @JsonProperty("qualify") + private List> qualify = null; + + @JsonProperty("action") + private String action; + + @JsonProperty("collect_inventory") + private QuestTaskCollectInventory collectInventory; + + @JsonIgnore + public boolean isReturnTask() { + if(getEvents() != null) for(List event : getEvents()) { + if(event.size() >= 1 && "return".equals(event.get(0))) { + return true; + } + } + + return false; + } + + private boolean checkQualification(Player player, Object... qualification) { + if(player == null || qualification == null || qualification.length == 0) { + return false; + } + + if(Objects.equals("pvp?", qualification[0])) { + return true; // TODO: change when PVP is supported. + } + + if(Objects.equals("current_biome?", qualification[0])) { + return qualification.length >= 2 && Objects.equals(qualification[1], player.getZone().getBiome().getId()); + } + + if(Objects.equals("current_item_group?", qualification[0])) { + return qualification.length >= 2 && Objects.equals(qualification[1], player.getHeldItem().getId()); + } + + return true; + } + + public boolean doesQualify(Player player) { + if(this.getQualify() == null) return true; + + for(List qualification : this.getQualify()) { + if(!checkQualification(player, qualification)) return false; + } + + return true; + } + + private boolean checkProgressRequirements(Player player) { + if(this.getProgressRequirements() == null) return true; + + for(List requirement: getProgressRequirements()) { + if(requirement.isEmpty()) continue; + + if(!(requirement.get(0) instanceof String)) continue; + + switch((String) requirement.get(0)) { + case "quests_completed_in_group": + if(requirement.size() < 2) continue; + boolean found = player.getQuestProgresses().keySet().stream().anyMatch(questId -> { + QuestProgress progress = player.getQuestProgresses().get(questId); + Quest quest = Quests.get(player, questId); + if(progress == null || quest == null) return false; + + return progress.isComplete() && Objects.equals(quest.getGroup(), requirement.get(1)); + }); + + if(!found) return false; + break; + case "players_killed": + // TODO + break; + } + } + + return true; + } + + public boolean checkCollectInventory(Player player) { + return getCollectInventory() == null || getCollectInventory().check(player); + } + + public boolean checkComplete(Player player, int quantity) { + return (getEvents() == null || getQuantity() <= quantity) && checkProgressRequirements(player); + } + + public String getDescription() { + return description; + } + + public QuestTask setDescription(String description) { + this.description = description; + return this; + } + + public int getQuantity() { + return quantity; + } + + public QuestTask setQuantity(Integer quantity) { + this.quantity = quantity; + return this; + } + + public List> getEvents() { + return events; + } + + public QuestTask setEvents(List> events) { + this.events = events; + return this; + } + + public List> getProgressRequirements() { + return progressRequirements; + } + + public QuestTask setProgressRequirements(List> progress) { + this.progressRequirements = progress; + return this; + } + + public List> getQualify() { + return qualify; + } + + public QuestTask setQualify(List> qualify) { + this.qualify = qualify; + return this; + } + + public String getAction() { + return action; + } + + public QuestTask setAction(String action) { + this.action = action; + return this; + } + + public QuestTaskCollectInventory getCollectInventory() { + return collectInventory; + } + + public QuestTask setCollectInventory(QuestTaskCollectInventory collectInventory) { + this.collectInventory = collectInventory; + return this; + } + + public List getDialogSection(Player player, int taskProgress, boolean v3) { + List allResult = new ArrayList<>(); + DialogSection result = new DialogSection(); + + String title = getDescription() + (taskProgress >= 0 ? String.format(" (Progress: %d/%d)", taskProgress, getQuantity()) : ""); + if(v3) { + result.setTitle("" + title + ""); + } else { + result.setTitle(title).setTextColor("#00ffff"); + } + + allResult.add(result); + + if(getQualify() != null && !getQualify().isEmpty()) { + DialogSection qualificationSection = new DialogSection(); + qualificationSection.setText("Qualifications:"); + for(List qualification : getQualify()) { + qualificationSection.addItem(new DialogListItem().setText(qualification.stream().map(Objects::toString).collect(Collectors.joining(" ")))); + } + allResult.add(qualificationSection); + } + + if(getCollectInventory() != null && !getCollectInventory().getRequirements().isEmpty()) { + DialogSection section = getCollectInventory().getDialogSection(); + System.out.println("Collect Item num Items " + section.getItems().size()); + + allResult.add(section); + } + + return allResult; + + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/QuestTaskCollectInventory.java b/gameserver/src/main/java/brainwine/gameserver/quest/QuestTaskCollectInventory.java new file mode 100644 index 00000000..913f37b9 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestTaskCollectInventory.java @@ -0,0 +1,71 @@ +package brainwine.gameserver.quest; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import com.fasterxml.jackson.annotation.JsonCreator; + +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.Pair; + +public class QuestTaskCollectInventory { + List> requirements; + @JsonCreator + public QuestTaskCollectInventory(Map inp) { + requirements = inp.entrySet().stream() + .map(e -> new Pair<>(e.getKey(), e.getValue())) + .collect(Collectors.toList()); + } + + public List> getRequirements() { + return requirements; + } + + public QuestTaskCollectInventory setRequirements(List> requirements) { + this.requirements = requirements; + return this; + } + + public boolean check(Player player) { + if(player == null) { + return false; + } + + for(Pair req : requirements) { + if(!player.getInventory().hasItem(ItemRegistry.getItem(req.getFirst()), req.getLast())) { + return false; + } + } + + return true; + + } + + public void removeFromPlayer(Player player) { + if(player == null) { + return; + } + + for(Pair req : requirements) { + player.getInventory().removeItem(ItemRegistry.getItem(req.getFirst()), req.getLast(), true); + } + } + + public DialogSection getDialogSection() { + DialogSection section = new DialogSection(); + section.setTitle("Things To Collect:"); + for(Pair req : getRequirements()) { + Item item = ItemRegistry.getItem(req.getFirst()); + if(!item.isAir()) section.addItem(new DialogListItem() + .setItem(item.getCode()) + .setText(String.format("%s x %s", item.getTitle(), req.getLast()))); + } + return section; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/Quests.java b/gameserver/src/main/java/brainwine/gameserver/quest/Quests.java new file mode 100644 index 00000000..a03aee59 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/Quests.java @@ -0,0 +1,195 @@ +package brainwine.gameserver.quest; + +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.fasterxml.jackson.core.type.TypeReference; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.resource.ResourceFinder; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.util.PickRandom; +import brainwine.shared.JsonHelper; + +public class Quests { + public static Map> questMaps = new HashMap<>(); + public static Map> questLists = new HashMap<>(); + public static Map titleToPrefix = new HashMap<>(); + public static Map hardcodedQuests = new HashMap<>(); + + private static final Logger logger = LogManager.getLogger(); + + private Quests() {} + + private static void loadHardcodedQuests() { + try { + URL url = ResourceFinder.getResourceUrl("hardcoded-quests.json"); + Map> obj = JsonHelper.readValue(url, new TypeReference>>(){}); + hardcodedQuests.putAll(MapHelper.getMap(obj, "hardcoded_quests")); + } catch (Exception e) { + logger.warn(String.format("Could not load any hardcoded quest entries from hardcoded-quests.json: %s", e.getMessage())); + } + } + + private static void loadRandomQuests() { + RandomQuests.loadConfiguration(); + } + + private static int loadQuestsForCategory(String categoryPrefix, String categoryTitle) { + titleToPrefix.put(categoryTitle, categoryPrefix); + + Map config = GameConfiguration.getBaseConfig(); + Map resultMap = new HashMap<>(); + List resultList = new ArrayList<>(); + + int counter = 0; + for(String questId : config.keySet()) { + try { + if(questId.startsWith(categoryPrefix)) { + Quest quest = JsonHelper.readValue(MapHelper.getMap(config, questId), Quest.class); + quest.setId(questId); + resultMap.put(questId, quest); + resultList.add(quest); + counter++; + } + } catch (Exception e) { + logger.warn(String.format("Could not load quest %s: %s", questId, e.getMessage())); + } + } + + questMaps.put(categoryTitle, resultMap); + questLists.put(categoryTitle, resultList); + + return counter; + } + + public static void loadQuests() { + loadHardcodedQuests(); + loadRandomQuests(); + + int counter = 0; + + counter += loadQuestsForCategory("collect", "Arts and Crafts"); + counter += loadQuestsForCategory("combat", "The Art of War"); + counter += loadQuestsForCategory("cooking", "Let Them Eat Cake"); + counter += loadQuestsForCategory("survival", "Survive and Thrive"); + + logger.info("Successfully loaded {} quests", counter); + } + + public static Quest get(Player player, String questId) { + if(player.getAndroidQuests() != null) { + Quest item = player.getAndroidQuests().get(questId); + if(item != null) return item; + } + + if(player.getDailyQuest() != null + && player.getDailyQuest().getValue() != null) { + for(Quest quest : player.getDailyQuest().getValue()) { + if(quest.getId().equals(questId)) { + return quest; + } + } + } + + return get(questId); + } + + public static Quest get(String questId) { + Map targetMap = null; + + for(String key : titleToPrefix.keySet()) { + if(questId.startsWith(titleToPrefix.get(key))) targetMap = questMaps.get(key); + } + + if(targetMap == null) { + return null; + } + + Quest quest = targetMap.get(questId); + + if(quest == null) { + return null; + } + + quest.setId(questId); + + return quest; + } + + public static String findQuestIdByTitle(Player player, String title) { + if(player != null) { + for(String questId : player.getQuestProgresses().keySet()) { + if(title.equalsIgnoreCase(get(player, questId).getTitle())) { + return questId; + } + } + } + + for(Map map : questMaps.values()) { + for(String questId : map.keySet()) { + if(title.equalsIgnoreCase(map.get(questId).getTitle())) { + return questId; + } + } + } + + return null; + } + + public static List getRandomQuestsFromCategory(Entity me, String categoryTitle, Set excludeQuestIds, int count) { + List targetList = questLists.get(categoryTitle); + + if(targetList == null) { + return null; + } + + targetList = targetList.stream().filter(q -> !excludeQuestIds.contains(q.getId())).collect(Collectors.toList()); + + if(targetList == null) { + return null; + } + + Random random = new Random(me == null ? 123456789L : me.hashCode()); + + return PickRandom.sampleWithoutReplacement(random, targetList, count); + } + + public static QuestProgress getIncompleteQuestProgressInCategory(Player player, String categoryTitle) { + String prefix = titleToPrefix.get(categoryTitle); + + if(prefix == null) { + return null; + } + + if(player.getQuestProgresses() == null) { + return null; + } + + String id = player.getQuestProgresses().keySet().stream() + .filter(p -> !player.getQuestProgresses().get(p).isComplete() && p.startsWith(prefix)) + .findFirst().orElse(null); + + return player.getQuestProgresses().get(id); + } + + /** + * Populate hardcoded-quests.json with Android names for which a specific quest will + * be issued to the player instead of showing the quest offers menu. + */ + public static Map getHardcodedQuests() { + return hardcodedQuests; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuest.java b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuest.java new file mode 100644 index 00000000..b1a3ddf1 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuest.java @@ -0,0 +1,83 @@ +package brainwine.gameserver.quest; + +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.randomquests.*; +import brainwine.gameserver.util.randomobject.RandomListObjectMapperProvider; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = MultiStep.class, name = "multistep"), + @JsonSubTypes.Type(value = Kill.class, name = "kill"), + @JsonSubTypes.Type(value = Collect.class, name = "collect"), + @JsonSubTypes.Type(value = Craft.class, name = "craft"), + @JsonSubTypes.Type(value = Inhibit.class, name = "inhibit"), + @JsonSubTypes.Type(value = Bury.class, name = "bury"), +}) +@RandomListObjectMapperProvider(RandomQuests.MapperProvider.class) +public abstract class RandomQuest { + @JsonProperty + private String type = "none"; + @JsonProperty + private int tier = 1; + @JsonProperty + private int frequency = 1; + @JsonProperty + private RandomQuestReward reward = null; + + @JsonProperty + private List domain = Stream.of( + RandomQuestDomain.DAILY, + RandomQuestDomain.ANDROID_SURVIVAL, + RandomQuestDomain.ANDROID_COMBAT, + RandomQuestDomain.ANDROID_COOKING, + RandomQuestDomain.ANDROID_COLLECT + ).collect(Collectors.toList()); + + protected String joinWithOr(List items) { + if(items == null || items.size() == 0) return "nothing"; + if(items.size() == 1) return items.get(0); + if(items.size() == 2) return items.get(0) + " or " + items.get(1); + else return String.join(", ", items.subList(0, items.size() - 1)) + ", or " + items.get(items.size() - 1); + } + + /**Randomly generate a quest according to the specification found in the class instance. + * + * @param random random instance to generate random values with + * @param player player that the quest is being generated for + * @return a quest. Implementors are not obliged to set the title of the quest, but other fields must have valid values + */ + public abstract Quest nextQuest(Random random, Player player); + + public String getType() { + return type; + } + + public int getTier() { + return tier; + } + + public int getFrequency() { + return frequency; + } + + public RandomQuestReward getReward() { + return reward; + } + + public List getDomain() { + return domain; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuestDomain.java b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuestDomain.java new file mode 100644 index 00000000..076bff67 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuestDomain.java @@ -0,0 +1,25 @@ +package brainwine.gameserver.quest; + +import brainwine.gameserver.util.MapHelper; + +import java.util.Map; + +public enum RandomQuestDomain { + ANDROID_SURVIVAL, + ANDROID_COMBAT, + ANDROID_COOKING, + ANDROID_COLLECT, + DAILY; + + private static Map prefixToDomain = MapHelper.map( + String.class, RandomQuestDomain.class, + "Survive and Thrive", ANDROID_SURVIVAL, + "The Art of War", ANDROID_COMBAT, + "Let Them Eat Cake", ANDROID_COOKING, + "Arts and Crafts", ANDROID_COLLECT + ); + + public static RandomQuestDomain fromCategoryTitle(String category) { + return prefixToDomain.get(category); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuestReward.java b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuestReward.java new file mode 100644 index 00000000..d8d90496 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuestReward.java @@ -0,0 +1,44 @@ +package brainwine.gameserver.quest; + +import java.util.Random; + +import brainwine.gameserver.util.randomobject.*; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RandomQuestReward implements Arbitrary { + @JsonProperty("xp") + private RandomInteger xp = null; + @JsonProperty("crowns") + private RandomInteger crowns = null; + @JsonProperty("loot_categories") + @RandomListItemType(String.class) + private RandomList lootCategories = null; + + public RandomQuestReward() {} + + public RandomQuestReward(RandomInteger xp, RandomInteger crowns, RandomList lootCategories) { + this.xp = xp; + this.crowns = crowns; + this.lootCategories = lootCategories; + } + + @Override + public QuestReward next(Random random) throws ConcretionFailureException { + QuestReward result = new QuestReward(); + if(xp != null) result.setXp(xp.next(random)); + if(crowns != null) result.setCrowns(crowns.next(random)); + if(lootCategories != null) result.setLootCategories(lootCategories.next(random)); + + return result; + } + + public static QuestReward nextOrDefault(Random random, RandomQuestReward... options) throws ConcretionFailureException { + for(RandomQuestReward r : options) { + if(r != null) return r.next(random); + } + + return new QuestReward().setXp(100); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuests.java b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuests.java new file mode 100644 index 00000000..807ddfd0 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuests.java @@ -0,0 +1,107 @@ +package brainwine.gameserver.quest; + +import static brainwine.shared.LogMarkers.SERVER_MARKER; + +import java.io.IOException; +import java.net.URL; +import java.util.*; +import java.util.stream.Collectors; + +import brainwine.gameserver.util.randomobject.ObjectMapperProvider; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.fasterxml.jackson.core.type.TypeReference; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.resource.ResourceFinder; +import brainwine.gameserver.util.WeightedMap; +import io.netty.util.internal.ThreadLocalRandom; + +public class RandomQuests { + private static final Logger logger = LogManager.getLogger(); + private static Configuration configuration = new Configuration(); + + public static class Configuration { + @JsonProperty("level_max_tiers") + private Map levelMaxTiers = new HashMap<>(); + @JsonProperty("strings") + Map> strings = new HashMap<>(); + @JsonProperty("quests") + List randomQuests = new ArrayList<>(); + @JsonProperty("daily_quest_interval") + String dailyQuestInterval = "24h"; + @JsonProperty("daily_quest_count") + int dailyQuestCount = 5; + } + + public static class MapperProvider implements ObjectMapperProvider { + @Override + public ObjectMapper get() { + return JsonHelper.MAPPER; + } + } + + public static void loadConfiguration() { + logger.info(SERVER_MARKER, "Loading random quest configuration..."); + + try { + URL url = ResourceFinder.getResourceUrl("random-quests.json"); + configuration = JsonHelper.MAPPER.readValue(url, new TypeReference(){}); + + logger.info(String.format("Successfully loaded %d random quests.", configuration.randomQuests.size())); + } catch (IOException e) { + logger.error(SERVER_MARKER, "Failed to load random quests", e); + } + } + + public static Configuration getConfiguration() { + return configuration; + } + + public static int getMaxTier(Player player) { + if(player == null) return 1; + + int tier = 1; + int level = player.getLevel(); + for(Map.Entry e : configuration.levelMaxTiers.entrySet()) { + if(level >= e.getValue() && tier < e.getKey()) { + tier = e.getKey(); + } + } + + return tier; + } + + public static List generateRandomPlayerQuests(Player player, RandomQuestDomain domain, int n) { + return generateRandomPlayerQuests(ThreadLocalRandom.current(), player, domain, n); + } + + public static List generateRandomPlayerQuests(Random random, Player player, RandomQuestDomain domain, int n) { + if(configuration.randomQuests.isEmpty()) { + return null; + } + + int maxTier = getMaxTier(player); + List candidates = configuration.randomQuests.stream() + .filter(q -> q.getTier() <= maxTier && q.getDomain().contains(domain)) + .collect(Collectors.toList()); + + WeightedMap wm = new WeightedMap<>( + candidates, + RandomQuest::getFrequency + ); + + List choices = wm.nextN(random, n); + + return choices.stream().map(rq -> rq.nextQuest(random, player)).collect(Collectors.toList()); + } + + public static String getString(Random random, String label) { + List list = configuration.strings.get(label); + if(list == null) return "Please complete the below tasks."; + return list.get(random.nextInt(list.size())); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Bury.java b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Bury.java new file mode 100644 index 00000000..ce796a3a --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Bury.java @@ -0,0 +1,14 @@ +package brainwine.gameserver.quest.randomquests; + +public class Bury extends EventAndQuantity { + public Bury() { + this.setEvent("bury"); + this.setTitle("Bury Skeletons"); + this.setDescriptionSource("bury_description"); + this.setSingularTaskDescription("Bury a skeleton"); + this.setPluralTaskDescription("Bury {QUANTITY} skeletons"); + this.getStory() + .setIntro("You will be burying {QUANTITY} skeletons. Sounds good?") + .setIncomplete("You still haven't buried {QUANTITY} skeletons."); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Collect.java b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Collect.java new file mode 100644 index 00000000..928d1047 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Collect.java @@ -0,0 +1,90 @@ +package brainwine.gameserver.quest.randomquests; + +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.*; +import brainwine.gameserver.util.randomobject.*; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.*; +import java.util.stream.Collectors; + +public class Collect extends RandomQuest { + @JsonProperty("tasks") + @RandomListItemType(CollectItem.class) + private RandomList tasks; + @JsonProperty("task_description") + private String taskDescription = null; + + @RandomListObjectMapperProvider(RandomQuests.MapperProvider.class) + private static class CollectItem { + @JsonProperty("items") + @RandomListItemType(String.class) + RandomList items = new ConstantList<>(); + @JsonProperty("count") + RandomInteger count = new RandomInteger(1); + } + + private QuestTask nextQuestTask(Random random, CollectItem collectItem) throws ConcretionFailureException { + List> events = new ArrayList<>(); + + for(String oneItem : collectItem.items.next(random)) { + events.add(Arrays.asList("collect_item", "id", oneItem)); + } + + int quantity = collectItem.count.next(random); + String myTaskDescription; + if(taskDescription == null) { + myTaskDescription = "Collect " + quantity + " x " + events.stream().map(e -> { + String itemId = (String) e.get(2); + Item item = Item.get(itemId); + String itemTitle; + if(item == null) { + itemTitle = itemId; + } else { + itemTitle = item.getTitle() == null ? itemId : item.getTitle(); + } + return itemTitle; + }).collect(Collectors.joining(" or ")); + } else { + myTaskDescription = taskDescription.replaceAll("\\{QUANTITY\\}", Integer.toString(quantity)); + } + + return new QuestTask() + .setDescription(myTaskDescription) + .setEvents(events) + .setQuantity(quantity); + } + + @Override + public Quest nextQuest(Random random, Player player) { + try { + Quest quest = new Quest(); + + quest.setDescription(RandomQuests.getString(random, "collect_description")); + List collectItemList = tasks.next(random); + List tasks = new ArrayList<>(collectItemList.size()); + for(CollectItem c : collectItemList) { + tasks.add(nextQuestTask(random, c)); + } + quest.setTitle("Collect Items"); + quest.setTasks(tasks); + quest.setReward(RandomQuestReward.nextOrDefault(random, getReward())); + + quest.setStory(new QuestStory() + .setIntro("I ran out of well-thought quests so I am just tasking you to collect the following items: \n" + + tasks.stream().map(QuestTask::getDescription).map(s -> "- " + s + "\n").collect(Collectors.joining()) + + "Sounds good?" + ) + .setAccept("Sure") + .setBegin("OK then. Good luck!") + .setIncomplete("You still haven't collected all the items.") + .setComplete("Good job! You are getting a reward your hard work.\nHope to see you again!") + ); + + return quest; + } catch (ConcretionFailureException e) { + throw new IllegalStateException("Concretion failure!"); + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Craft.java b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Craft.java new file mode 100644 index 00000000..0f60a3b1 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Craft.java @@ -0,0 +1,85 @@ +package brainwine.gameserver.quest.randomquests; + +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.*; +import brainwine.gameserver.util.randomobject.*; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +public class Craft extends RandomQuest { + @JsonProperty("tasks") + @RandomListItemType(CraftItem.class) + private RandomList tasks; + @JsonProperty("task_description") + private String taskDescription = null; + + private static class CraftItem { + @JsonProperty("items") + @RandomListItemType(String.class) + RandomList items = new ConstantList<>(); + @JsonProperty("count") + RandomInteger count = new RandomInteger(1); + } + + @Override + public Quest nextQuest(Random random, Player player) { + try { + Quest quest = new Quest(); + quest.setDescription(RandomQuests.getString(random, "craft_description")); + + List chosenItems = tasks.next(random); + + List tasks = new ArrayList<>(chosenItems.size()); + for(CraftItem craftItem : chosenItems) { + List itemsToCraft = craftItem.items.next(random); + int amount = craftItem.count.next(random); + List> events = new ArrayList<>(itemsToCraft.size()); + List itemNames = new ArrayList<>(itemsToCraft.size()); + + for(String itemId : itemsToCraft) { + Item item = Item.get(itemId); + + itemNames.add(item == null || item.getTitle() == null ? itemId : item.getTitle()); + events.add(Arrays.asList("craft", "code", item == null ? 0 : item.getCode())); + } + + QuestTask task = new QuestTask(); + if(taskDescription == null) { + task.setDescription("Craft " + amount + " of " + joinWithOr(itemNames)); + } else { + task.setDescription(taskDescription.replaceAll("\\{QUANTITY\\}", Integer.toString(amount))); + } + task.setEvents(events); + task.setQuantity(amount); + + tasks.add(task); + } + + quest.setTitle("Craft Items"); + quest.setTasks(tasks); + + quest.setReward(RandomQuestReward.nextOrDefault(random, getReward())); + + quest.setStory(new QuestStory() + .setIntro("I ran out of well-thought quests so I am just tasking you to craft the following items: \n" + + tasks.stream().map(QuestTask::getDescription).map(s -> "- " + s + "\n").collect(Collectors.joining()) + + "Sounds good?" + ) + .setAccept("Alright") + .setBegin("OK then. Good luck!") + .setIncomplete("You still haven't crafted all the items.") + .setComplete("Good job! You are getting a reward your hard work.\nHope to see you again!") + ); + + return quest; + } catch (ConcretionFailureException e) { + throw new IllegalStateException("Concretion failure!"); + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/EventAndQuantity.java b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/EventAndQuantity.java new file mode 100644 index 00000000..cbbede29 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/EventAndQuantity.java @@ -0,0 +1,107 @@ +package brainwine.gameserver.quest.randomquests; + +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.Quest; +import brainwine.gameserver.quest.QuestReward; +import brainwine.gameserver.quest.QuestStory; +import brainwine.gameserver.quest.QuestTask; +import brainwine.gameserver.quest.RandomQuest; +import brainwine.gameserver.quest.RandomQuestReward; +import brainwine.gameserver.quest.RandomQuests; +import brainwine.gameserver.util.randomobject.ConcretionFailureException; +import brainwine.gameserver.util.randomobject.RandomInteger; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Arrays; +import java.util.Random; + +public class EventAndQuantity extends RandomQuest { + @JsonProperty("quantity") + private RandomInteger quantity; + + private String title = "Do Something N Times"; + private String event = "inhibit"; + private String singularTaskDescription = "Do this {QUANTITY} times."; + private String pluralTaskDescription = "Do this {QUANTITY} times."; + private String descriptionSource = null; + private String description = null; + private QuestStory story = new QuestStory() + .setIntro("You will need to inhibit do something {QUANTITY} times. Sounds good?") + .setAccept("Alright") + .setBegin("OK then. Good luck!") + .setIncomplete("You still haven't killed all the necessary entities.") + .setComplete("Good job! You are getting a reward your hard work.\nHope to see you again!"); + + protected EventAndQuantity setEvent(String event) { + this.event = event; + return this; + } + + protected EventAndQuantity setSingularTaskDescription(String singularTaskDescription) { + this.singularTaskDescription = singularTaskDescription; + return this; + } + + protected EventAndQuantity setPluralTaskDescription(String pluralTaskDescription) { + this.pluralTaskDescription = pluralTaskDescription; + return this; + } + + protected EventAndQuantity setTitle(String title) { + this.title = title; + return this; + } + + protected EventAndQuantity setDescriptionSource(String descriptionSource) { + this.descriptionSource = descriptionSource; + return this; + } + + protected EventAndQuantity setDescription(String description) { + this.description = description; + return this; + } + + protected QuestStory getStory() { + return this.story; + } + + private String format(String string, int quantity) { + return string.replaceAll("\\{QUANTITY}", Integer.toString(quantity)); + } + + @Override + public Quest nextQuest(Random random, Player player) { + int quantity; + QuestReward reward; + try { + quantity = this.quantity.next(random); + reward = RandomQuestReward.nextOrDefault(random, getReward()); + } catch (ConcretionFailureException e) { + throw new IllegalStateException("Concretion failure!"); + } + + Quest quest = new Quest(); + quest.setDescription(description != null ? description : RandomQuests.getString(random, descriptionSource)); + + quest.setTasks(Arrays.asList( + new QuestTask() + .setDescription(format(quantity == 1 ? singularTaskDescription : pluralTaskDescription, quantity)) + .setQuantity(quantity) + .setEvents(Arrays.asList(Arrays.asList(event))) + )); + + quest.setTitle(title); + quest.setReward(reward); + + quest.setStory(new QuestStory() + .setIntro(format(story.getIntro(), quantity)) + .setAccept(format(story.getAccept(), quantity)) + .setBegin(format(story.getBegin(), quantity)) + .setIncomplete(format(story.getIncomplete(), quantity)) + .setComplete(format(story.getComplete(), quantity)) + ); + + return quest; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Inhibit.java b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Inhibit.java new file mode 100644 index 00000000..09939120 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Inhibit.java @@ -0,0 +1,14 @@ +package brainwine.gameserver.quest.randomquests; + +public class Inhibit extends EventAndQuantity { + public Inhibit() { + this.setEvent("inhibit"); + this.setTitle("Inhibit Evokers"); + this.setDescriptionSource("inhibit_description"); + this.setSingularTaskDescription("Inhibit an evoker"); + this.setPluralTaskDescription("Inhibit {QUANTITY} evokers"); + this.getStory() + .setIntro("You will be inhibiting {QUANTITY} evokers. Sounds good?") + .setIncomplete("You still haven't inhibited {QUANTITY} evokers."); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Kill.java b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Kill.java new file mode 100644 index 00000000..79ec8ad2 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Kill.java @@ -0,0 +1,196 @@ +package brainwine.gameserver.quest.randomquests; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import brainwine.gameserver.entity.EntityConfig; +import brainwine.gameserver.entity.EntityRegistry; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.*; +import brainwine.gameserver.util.randomobject.*; +import com.fasterxml.jackson.annotation.JsonProperty; +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import org.apache.commons.text.WordUtils; + +public class Kill extends RandomQuest { + @JsonProperty("categories") + @RandomListItemType(String.class) + private RandomList categories = null; + @JsonProperty("codes") + @RandomListItemType(RandomInteger.class) + private RandomList codes = null; + @JsonProperty("entities") + @RandomListItemType(String.class) + private RandomList entityIds = null; + @JsonProperty("quantity") + private RandomInteger quantity = new RandomInteger(1); + @JsonProperty("category_quantity") + private RandomInteger categoryQuantity = null; + @JsonProperty("code_quantity") + private RandomInteger codeQuantity = null; + @JsonProperty("entity_quantity") + private RandomInteger entityIdQuantity = null; + @JsonProperty("task_description") + private String taskDescription = null; + @JsonProperty("actions") + @RandomListItemType(String.class) + private RandomList actions = new ConstantList<>(Arrays.asList("kill", "explode")); + + private List> getKillEvents(List actions, String type, List values) { + List> events = new ArrayList<>(actions.size() * values.size()); + for(String action : actions) { + for(Object value : values) { + events.add(Arrays.asList(action, type, value)); + } + } + + return events; + } + + private QuestTask makeTaskForEntityTypes(List actions, List names, List codes, int quantity) { + List> events = getKillEvents(actions, "code", codes); + + String message; + if(taskDescription == null) { + String actionMessage = WordUtils.capitalize(joinWithOr(actions)); + String times = quantity == 1 ? "" : " " + quantity + " times"; + String concat = joinWithOr(names); + + String beginning; + if(names.size() == 1) beginning = Stream.of("a", "e", "i", "o", "u").anyMatch(concat::startsWith) ? " an " : " a "; + else beginning = " any of "; + + message = actionMessage + beginning + concat + times; + } else { + message = taskDescription.replaceAll("\\{QUANTITY\\}", Integer.toString(quantity)); + } + + return new QuestTask().setDescription(message).setEvents(events).setQuantity(quantity); + } + + private QuestTask makeTaskForEntityCategories(List actions, List categories, int quantity) { + List> events = getKillEvents(actions, "category", categories); + + String message; + if(taskDescription == null) { + String actionMessage = WordUtils.capitalize(String.join(" or ", actions)); + + String times = quantity == 1 ? "" : " " + quantity + " times"; + + String concat = joinWithOr(categories); + + String beginning; + if(categories.size() == 1) beginning = " an entity of category "; + else beginning = " an entity of categories "; + + message = actionMessage + beginning + concat + times; + } else { + message = taskDescription.replaceAll("\\{QUANTITY\\}", Integer.toString(quantity)); + } + + return new QuestTask().setDescription(message).setEvents(events).setQuantity(quantity); + } + + @Override + public Quest nextQuest(Random random, Player player) { + try { + Quest quest = new Quest(); + quest.setDescription(RandomQuests.getString(random, "kill_description")); + + List tasks = new ArrayList<>(); + List actions = this.actions.next(random); + String titleActionMessage = String.join(" or ", actions.stream().map(WordUtils::capitalize).collect(Collectors.toList())); + String title = titleActionMessage; + + if(categories != null) { + List values = categories.next(random); + int quantity = defaultIfNull(categoryQuantity, this.quantity).next(random); + tasks.add(makeTaskForEntityCategories(actions, values, quantity)); + title = titleActionMessage + " " + joinWithOr(values); + } + + if(entityIds != null) { + List values = entityIds.next(random); + int quantity = defaultIfNull(entityIdQuantity, this.quantity).next(random); + List names = new ArrayList<>(); + List codes = new ArrayList<>(); + for(String id : values) { + EntityConfig config = EntityRegistry.getEntityConfig(id); + + if(config == null) { + names.add(id); + codes.add(0); + continue; + } + + names.add(defaultIfNull(config.getTitle(), id)); + codes.add(config.getType()); + } + + tasks.add(makeTaskForEntityTypes(actions, names, codes, quantity)); + title = titleActionMessage + " " + joinWithOr(names); + } + + if(codes != null) { + List values = codes.next(random).stream().map(ri -> ri.next(random)).collect(Collectors.toList()); + int quantity = defaultIfNull(codeQuantity, this.quantity).next(random); + List names = new ArrayList<>(); + List codes = new ArrayList<>(); + for(Integer code : values) { + names.add("(Type: " + code + ")"); + codes.add(code); + } + + tasks.add(makeTaskForEntityTypes(actions, names, codes, quantity)); + title = titleActionMessage + " " + joinWithOr(names); + } + + quest.setTitle(title); + quest.setTasks(tasks); + quest.setReward(RandomQuestReward.nextOrDefault(random, getReward())); + + quest.setStory(new QuestStory() + .setIntro("You gotta be killing: \n" + + tasks.stream().map(QuestTask::getDescription).map(s -> "- " + s + "\n").collect(Collectors.joining()) + + "Sounds good?" + ) + .setAccept("Positive") + .setBegin("OK then. Good luck!") + .setIncomplete("You still haven't killed all the necessary entities.") + .setComplete("Good job! You are getting a reward your hard work.\nHope to see you again!") + ); + + return quest; + } catch (ConcretionFailureException e) { + throw new IllegalStateException("Concretion failure!"); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("Kill{ "); + + sb.append("taskDescription='" + taskDescription + '\'' + + ", reward=" + getReward() + + ", actions=" + actions); + + if(categories != null) { + sb.append(", categories=" + categories + ", categoryQuantity=" + categoryQuantity); + } + if(entityIds != null) { + sb.append(", entityIds=" + entityIds + ", entityIdQuantity=" + entityIdQuantity); + } + if(codes != null) { + sb.append(", codes=" + codes + ", codeQuantity=" + codeQuantity); + } + + sb.append(" }"); + + return sb.toString(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/MultiStep.java b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/MultiStep.java new file mode 100644 index 00000000..7cf21293 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/MultiStep.java @@ -0,0 +1,69 @@ +package brainwine.gameserver.quest.randomquests; + +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.*; +import brainwine.gameserver.util.randomobject.ConcretionFailureException; +import brainwine.gameserver.util.randomobject.ConstantList; +import brainwine.gameserver.util.randomobject.RandomList; +import brainwine.gameserver.util.randomobject.RandomListItemType; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; + +public class MultiStep extends RandomQuest { + @JsonProperty + @RandomListItemType(RandomQuest.class) + private RandomList steps = new ConstantList<>(); + @JsonProperty + private String title = null; + @JsonProperty + private String description = null; + @JsonProperty + private QuestStory story = null; + + @Override + public Quest nextQuest(Random random, Player player) { + try { + List chosen = steps.next(random); + List quests = chosen.stream() + .map(rq -> rq.nextQuest(random, player)) + .collect(Collectors.toList()); + + List tasks = new ArrayList<>(); + + for(Quest quest : quests) { + tasks.addAll(quest.getTasks()); + } + + Quest result = new Quest(); + + if(!quests.isEmpty()) { + Quest toBeCopied = quests.get(quests.size() - 1); + RandomQuestReward altRandomQuestReward = chosen.get(quests.size() - 1).getReward(); + result.setDescription(defaultIfNull(description, toBeCopied.getDescription())); + + return new Quest() + .setTitle(defaultIfNull(title, toBeCopied.getTitle())) + .setDescription(defaultIfNull(description, toBeCopied.getDescription())) + .setTasks(tasks) + .setReward(RandomQuestReward.nextOrDefault(random, getReward(), altRandomQuestReward)) + .setStory(defaultIfNull(story, toBeCopied.getStory())); + } + + return new Quest() + .setTitle(title) + .setDescription(description) + .setTasks(tasks) + .setReward(RandomQuestReward.nextOrDefault(random, getReward())) + .setStory(story); + + } catch(ConcretionFailureException e) { + throw new IllegalStateException("Concretion failure!"); + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarket.java b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarket.java new file mode 100644 index 00000000..feab6b1f --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarket.java @@ -0,0 +1,130 @@ +package brainwine.gameserver.scrapmarket; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.util.MapHelper; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.File; +import java.util.*; + +import static brainwine.shared.LogMarkers.SERVER_MARKER; + +public class ScrapMarket { + private static String FILE_NAME = "scrap-market.json"; + public static int MIN_BARTER_LEVEL = 1; + + private static final Logger logger = LogManager.getLogger(); + private static ScrapMarket instance; + + private Set products = new LinkedHashSet<>(); + private Map> productsBySeller = new HashMap<>(); + private Map> productsByInventoryTab = new HashMap<>(); + private Map itemToInventoryTab = new HashMap<>(); + + public void loadScrapMarketData() { + logger.info(SERVER_MARKER, "Loading android shop data ..."); + loadInventoryTabs(); + loadJson(); + } + + private void loadInventoryTabs() { + itemToInventoryTab.clear(); + + List> inventoryConfig = MapHelper.getList(GameConfiguration.getBaseConfig(), "inventory"); + for(Map tab : inventoryConfig) { + String tabName = MapHelper.getString(tab, "name"); + List itemIds = MapHelper.getList(tab, "items"); + itemIds.forEach(id -> itemToInventoryTab.put(id, tabName)); + } + } + + private void loadJson() { + List newProducts = new ArrayList<>(); + try { + File file = new File(FILE_NAME); + if(file.exists()) { + newProducts = JsonHelper.readValue(file, new TypeReference>() {}); + Objects.requireNonNull(newProducts); + } + } catch(Exception e) { + logger.error(SERVER_MARKER, "Could not load scrap market data", e); + return; + } + + products.clear(); + productsBySeller.clear(); + productsByInventoryTab.clear(); + + for(ScrapMarketProduct product : newProducts) { + if(product.validate()) { + addProduct(product); + } + } + logger.info(SERVER_MARKER, "Successfully loaded {} scrap market products. Skipped {} due to not found seller or item.", + products.size(), + newProducts.size() - products.size() + ); + } + + public void saveJson() { + try { + JsonHelper.writeValue(new File(FILE_NAME), products); + } catch(Exception e) { + logger.error("Failed to write {}", FILE_NAME, e); + } + } + + public void addProduct(ScrapMarketProduct product) { + products.add(product); + String tabName = itemToInventoryTab.getOrDefault(product.getItemId(), "other"); + productsByInventoryTab.computeIfAbsent(tabName, k -> new LinkedHashSet<>()).add(product); + productsBySeller.computeIfAbsent(product.getSellerId(), k -> new LinkedHashMap<>()).put(product.getItemId(), product); + } + + public void removeProduct(ScrapMarketProduct product) { + products.remove(product); + String tabName = itemToInventoryTab.getOrDefault(product.getItemId(), "other"); + Set tabProducts = productsByInventoryTab.get(tabName); + if(tabProducts != null) { + tabProducts.remove(product); + if(tabProducts.isEmpty()) { + productsByInventoryTab.remove(tabName); + } + } + if(productsBySeller.get(product.getSellerId()) != null) { + productsBySeller.get(product.getSellerId()).remove(product.getItemId()); + } + } + + public void removeProduct(String sellerId, String itemId) { + Map products = productsBySeller.get(sellerId); + + if(products != null) { + ScrapMarketProduct product = products.get(itemId); + + if(product != null) { + removeProduct(product); + } + } + } + + public Set getProducts() { + return Collections.unmodifiableSet(products); + } + + public Map> getProductsBySeller() { + return productsBySeller; + } + + public Map> getProductsByInventoryTab() { + return productsByInventoryTab; + } + + public static ScrapMarket getInstance() { + if(instance == null) instance = new ScrapMarket(); + return instance; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketBuySession.java b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketBuySession.java new file mode 100644 index 00000000..cbe0270f --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketBuySession.java @@ -0,0 +1,347 @@ +package brainwine.gameserver.scrapmarket; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.TradeSession; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.function.Consumer; + +public class ScrapMarketBuySession { + private final ScrapMarket shop; + private final Npc me; + private final Player player; + private final Consumer onOutcome; + private Optional currentSection = Optional.empty(); + private Optional currentProduct = Optional.empty(); + private OptionalInt currentQuantity = OptionalInt.empty(); + + private static final Logger logger = LogManager.getLogger(); + + public ScrapMarketBuySession(ScrapMarket shop, Npc me, Player player) { + this(shop, me, player, null); + } + + public ScrapMarketBuySession(ScrapMarket shop, Npc me, Player player, Consumer onOutcome) { + this.shop = shop; + this.me = me; + this.player = player; + this.onOutcome = onOutcome; + } + + private int getAdjustedPrice(ScrapMarketProduct product) { + return product.getPrice(); + } + + private enum CanBuy { + OK(true, "", "How many are you buying?"), + NOT_ENOUGH_SHILLINGS(true, "", "Sorry, you don't have enough shillings to buy any of this item."), + NOT_ENOUGH_IN_STOCK(true, "", "Sorry, but the seller does not have enough of this item in stock."), + EXPIRED(true, "", "Sorry, but this offer is not available anymore."), + ; + + CanBuy(boolean showInShop, String buttonMessage, String dialogMessage) { + this.showInShop = showInShop; + this.buttonMessage = buttonMessage; + this.dialogMessage = dialogMessage; + } + + public final boolean showInShop; + public final String buttonMessage; + public final String dialogMessage; + } + + private ScrapMarketBuySession.CanBuy canBuy(ScrapMarketProduct product) { + return canBuy(product, 0); + } + + private ScrapMarketBuySession.CanBuy canBuy(ScrapMarketProduct product, int quantity) { + int account = player.getInventory().getQuantity(ItemRegistry.getItem("accessories/shillings")); + int adjustedCost = getAdjustedPrice(product); + + if(!shop.getProducts().contains(product)) { + return CanBuy.EXPIRED; + } else if(adjustedCost * quantity > account || product.getUnitQuantity() * adjustedCost > account) { + return CanBuy.NOT_ENOUGH_SHILLINGS; + } else if(!product.inStock(quantity) || !product.availableInInventory(quantity)) { + return CanBuy.NOT_ENOUGH_IN_STOCK; + } else { + return CanBuy.OK; + } + } + + public void showNextDialog() { + if(!player.isOnline() || me != null && (player.getZone() != me.getZone())) { + end(false); + return; + } + + try { + if(!currentSection.isPresent()) showAllSectionsDialog(); + else if(!currentProduct.isPresent()) showSectionDialog(); + else if(!currentQuantity.isPresent()) showQuantityDialog(); + else showConfirmationDialog(); + } catch(Exception e) { + player.notify("A problem occurred during your trade session."); + logger.error("A problem occurred during an android trade session.", e); + } + } + + public void showAllSectionsDialog() { + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Scrap Market"); + + for(String tab : shop.getProductsByInventoryTab().keySet()) { + if(player.isGodMode() || shop.getProductsByInventoryTab().get(tab).stream().anyMatch(p -> !player.getDocumentId().equals(p.getSellerId()))) { + dialog.addSection(new DialogSection().setChoice(tab).setText(StringUtils.capitalize(tab))); + } + } + + dialog.setActions("Cancel"); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || !(ans[0] instanceof String) || "cancel".equals(ans[0])) { + end(false); + return; + } + + String sectionKey = (String)ans[0]; + if(shop.getProductsByInventoryTab().containsKey(sectionKey)) { + currentSection = Optional.of(sectionKey); + } + + showNextDialog(); + }); + } + + public DialogSection getProductSection1(ScrapMarketProduct product) { + Item item = ItemRegistry.getItem(product.getItemId()); + + // This is eyeballed + int spaces = (int)Math.max(0.0f, 1.5f * (18 - item.getTitle().length())); + String padding = String.join("", Collections.nCopies(spaces, " ")); + return new DialogSection() + .addItem(new DialogListItem().setItem(item.getCode()).setText(padding + item.getTitle())); + } + + public DialogSection getProductSection2(ScrapMarketProduct product) { + return new DialogSection().setText(ItemRegistry.getItem(product.getItemId()).getHint()); + } + + public void showSectionDialog() { + LinkedHashSet products = shop.getProductsByInventoryTab().get(currentSection.get()); + + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle(StringUtils.capitalize(currentSection.get())); + + for(ScrapMarketProduct product : products) { + if(!player.isGodMode() && player.getDocumentId().equals(product.getSellerId())) continue; + + int adjustedCost = getAdjustedPrice(product); + Player seller = GameServer.getInstance().getPlayerManager().getPlayerById(product.getSellerId()); + Item item = ItemRegistry.getItem(product.getItemId()); + + CanBuy canBuy = canBuy(product); + + dialog.addSection(getProductSection1(product)); + dialog.addSection(getProductSection2(product)); + + DialogSection buySection = new DialogSection() + .setChoice(product.getDialogId()); + + boolean buttonReddened = canBuy != CanBuy.OK; + String buttonMessage = String.format( + "Buy %s from %s | %d shilling%s for %s", + item.getTitle(), + seller.getName(), + adjustedCost, + adjustedCost == 1 ? "" : "s", + product.getUnitQuantity() == 1 ? "each" : Integer.toString(product.getUnitQuantity()) + ); + + if(buttonReddened) { + if(player.isV3()) { + buySection.setText("" + buttonMessage + ""); + } else { + buySection.setText(buttonMessage).setTextColor("ff8844"); + } + } else { + buySection.setText(buttonMessage); + } + + dialog.addSection(buySection); + } + + dialog.setActions("Back"); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || !(ans[0] instanceof String)) { + end(false); + return; + } + + if("cancel".equals(ans[0])) { + currentSection = Optional.empty(); + showNextDialog(); + return; + } + + String sellerId = ScrapMarketProduct.getSellerIdFromDialogId((String)ans[0]); + String itemId = ScrapMarketProduct.getItemIdFromDialogId((String)ans[0]); + if(shop.getProductsBySeller().containsKey(sellerId) && shop.getProductsBySeller().get(sellerId).containsKey(itemId)) { + currentProduct = Optional.of(shop.getProductsBySeller().get(sellerId).get(itemId)); + } + + showNextDialog(); + }); + } + + public void showQuantityDialog() { + ScrapMarketProduct product = currentProduct.get(); + Player seller = GameServer.getInstance().getPlayerManager().getPlayerById(product.getSellerId()); + Item item = ItemRegistry.getItem(product.getItemId()); + CanBuy canBuy = canBuy(product); + int adjustedCost = product.getPrice(); + + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Buying " + item.getTitle()); + dialog.addSection(getProductSection1(product)); + dialog.addSection(getProductSection2(product)); + + DialogSection buySection = new DialogSection(); + + boolean buttonReddened = canBuy != CanBuy.OK; + String buttonMessage = String.format( + "%s sells %s of these for %d shilling%s.", + seller.getName(), + product.getUnitQuantity() == 1 ? "each" : Integer.toString(product.getUnitQuantity()), + adjustedCost, + adjustedCost == 1 ? "" : "s" + ); + + if(buttonReddened) { + if(player.isV3()) { + buySection.setText("" + buttonMessage + ""); + } else { + buySection.setText(buttonMessage).setTextColor("ff8844"); + } + } else { + buySection.setText(buttonMessage); + } + + dialog.addSection(buySection); + + if(canBuy == CanBuy.OK) { + int maxByPrice = player.getInventory().getQuantity(ItemRegistry.getItem("accessories/shillings")) / getAdjustedPrice(product); + int maxByStock = product.getStock(); + dialog.addSection(TradeSession.Dialogs.createQuantitySelector(Math.min(maxByStock, maxByPrice), product.getUnitQuantity()).setTitle("How many are you buying?")); + } else { + dialog.addSection(new DialogSection().setText(canBuy.dialogMessage)); + } + + player.showDialog(dialog, ans -> { + if(ans.length == 0) { + return; + } + + if("cancel".equals(ans[0])) { + currentProduct = Optional.empty(); + showNextDialog(); + return; + } + + try { + int quantity = Integer.parseInt(ans[0].toString()); + currentQuantity = OptionalInt.of(quantity); + } catch(NumberFormatException ignored) {} + + showNextDialog(); + }); + } + + public void showConfirmationDialog() { + ScrapMarketProduct product = currentProduct.get(); + if(product == null) { + end(false); + return; + } + + Item shillings = ItemRegistry.getItem("accessories/shillings"); + Item purchasedItem = ItemRegistry.getItem(product.getItemId()); + int buyQuantity = currentQuantity.getAsInt(); + int totalQuantity = buyQuantity; + int totalPrice = totalQuantity * getAdjustedPrice(product); + + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Confirming Purchase"); + dialog.addSection(new DialogSection().setTitle("For your") + .addItem(new DialogListItem().setItem(shillings.getCode()).setText(totalPrice + (totalPrice == 1 ? " shilling" : " shillings"))) + ); + dialog.addSection(new DialogSection().setTitle("you will get") + .addItem(new DialogListItem().setItem(purchasedItem.getCode()).setText(purchasedItem.getTitle() + " x " + totalQuantity)) + ); + dialog.addSection(new DialogSection().setText("Do you accept?")); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || !"cancel".equals(ans[0])) { + CanBuy canBuy = canBuy(product, buyQuantity); + if(canBuy == CanBuy.OK) { + Player seller = expectSeller(product); + + if(!player.isGodMode()) { + seller.getInventory().addItem(shillings, totalPrice, true); + player.getInventory().removeItem(shillings, getAdjustedPrice(product), true); + } + + product.purchase(player, buyQuantity); + if(product.getStock() <= 0) { + shop.removeProduct(product); + } + + if(seller.isOnline()) { + seller.notify(String.format( + "%s has bought your %s %s on the Scrap Market for %d shillings.", + player.getName(), + totalQuantity == 1 ? "" : Integer.toString(totalQuantity), + purchasedItem.getTitle(), + totalPrice + )); + } + + if(me != null) me.emote("Good trade!"); + end(true); + } else { + player.showDialog(DialogHelper.messageDialog(canBuy.dialogMessage).setType(DialogType.ANDROID)); + end(false); + } + } else { + currentQuantity = OptionalInt.empty(); + showNextDialog(); + } + }); + } + + public void end(boolean outcome) { + if(onOutcome != null) onOutcome.accept(outcome); + } + + private Player expectSeller(ScrapMarketProduct product) { + Player seller = GameServer.getInstance().getPlayerManager().getPlayerById(product.getSellerId()); + if(seller == null) { + throw new NoSuchElementException("Selling player does not exist anymore."); + } + + return seller; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketOfferSession.java b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketOfferSession.java new file mode 100644 index 00000000..cbb78ce8 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketOfferSession.java @@ -0,0 +1,308 @@ +package brainwine.gameserver.scrapmarket; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.dialog.input.DialogTextInput; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.Tradeability; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.Skill; + +import java.text.DecimalFormat; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class ScrapMarketOfferSession { + private static final double SERVICE_CHARGE_FACTOR = 0.05; + + private ScrapMarket shop; + private Player player; + private Item item; + private Map formDefaults = new HashMap<>(); + private Dialog form = new Dialog() + .setTitle("Offer Item") + // .addSection(new DialogSection().setTitle("Unit Quantity").setInput(new DialogTextInput().setKey("unit_quantity"))) + .addSection(new DialogSection().setTitle("Price").setInput(new DialogTextInput().setKey("price"))) + .addSection(new DialogSection().setTitle("Quantity Listed").setInput(new DialogTextInput().setKey("stock"))) + ; + + public ScrapMarketOfferSession(ScrapMarket shop, Player player, Item item) { + this.shop = shop; + this.player = player; + this.item = item; + } + + public void showNextDialog() { + if(!player.isGodMode()) { + if(getAllowedListings() == 0) { + player.showDialog(DialogHelper.messageDialog( + "Low Barter Skill", + "You must have at least barter level 10 to be able to list items." + ).setType(DialogType.ANDROID)); + return; + } + + Map playerProducts = shop.getProductsBySeller().get(player.getDocumentId()); + + if(playerProducts != null + && !playerProducts.containsKey(item.getId()) + && playerProducts.size() >= getAllowedListings() + ) { + player.showDialog(DialogHelper.messageDialog( + "Too Many Listings", + String.format("You must have higher barter level to list more items. You have already listed your maximum of %d.", getAllowedListings()) + ).setType(DialogType.ANDROID)); + return; + } + } + + if(!checkCanTradeItem(item)) return; + + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Offering " + item.getTitle()); + + dialog.addSection(getProductSection1(item)); + Map playersOldProducts = shop.getProductsBySeller().get(player.getDocumentId()); + ScrapMarketProduct oldProduct = playersOldProducts != null ? playersOldProducts.get(item.getId()) : null; + + DecimalFormat df = new DecimalFormat(); + df.setMaximumFractionDigits(2); + dialog.addSection(new DialogSection().setText(String.format( + "All listings are subject to a %s%% service charge based on the amount listed.", + df.format(SERVICE_CHARGE_FACTOR * 100.0) + ))); + + // Show a warning if the player already has this item for offer. + if(oldProduct != null) { + String message = String.format( + "You already have this item on the Scrap Market, %s for %d shilling%s, with %d left listed. Submitting this offer will take that offer down. You may still pay a service charge.", + oldProduct.getUnitQuantity() == 1 ? "each" : Integer.toString(oldProduct.getUnitQuantity()), + oldProduct.getPrice(), + oldProduct.getPrice() == 1 ? "" : "s", + oldProduct.getStock() + ); + if(player.isV3()) { + dialog.addSection(new DialogSection().setText("" + message + "")); + } else { + dialog.addSection(new DialogSection().setText(message).setTextColor("ff8844")); + } + } + + for(DialogSection formSection : form.getSections()) { + if(formSection.getInput() != null) { + if("stock".equals(formSection.getInput().getKey())) { + formSection.setText(String.format( + "You have %d of %s in your inventory, thus you can list at most this much.", + player.getInventory().getQuantity(item), + item.getTitle() + )); + } + + // form is cloned per instance thus it is safe to modify it. + if(formDefaults.containsKey(formSection.getInput().getKey())) { + formSection.getInput().setValue(formDefaults.get(formSection.getInput().getKey())); + } + } + + dialog.addSection(formSection); + } + + player.showDialog(dialog, this::confirm); + } + + public DialogSection getProductSection1(Item item) { + // This is eyeballed + int spaces = (int)Math.max(0.0f, 1.5f * (18 - item.getTitle().length())); + String padding = String.join("", Collections.nCopies(spaces, " ")); + return new DialogSection() + .addItem(new DialogListItem().setItem(item.getCode()).setText(padding + item.getTitle())); + } + + public void confirm(Object[] ans) { + if(ans.length == 0 || "cancel".equals(ans[0])) { + return; + } + + Map responses = new HashMap<>(); + int i = 0; + for(DialogSection section : form.getSections()) { + if(section.getInput() != null) { + if(i >= ans.length) { + player.showDialog(DialogHelper.messageDialog("Error", "Invalid dialog input.").setType(DialogType.ANDROID)); + return; + } + Object val = ans[i++]; + if(val instanceof Number) { + responses.put(section.getInput().getKey(), (int)val); + } else if(val instanceof String) { + if(((String) val).matches("^\\s*$")) { + continue; + } + try { + responses.put(section.getInput().getKey(), Integer.parseInt((String)val)); + } catch(NumberFormatException e) { + player.showDialog(DialogHelper.messageDialog("Error", "Failed to parse number.").setType(DialogType.ANDROID)); + return; + } + } else { + player.showDialog(DialogHelper.messageDialog("Error", "Unexpected data type.").setType(DialogType.ANDROID)); + return; + } + } + } + + confirm(responses); + } + + public void confirm(Map responses) { + if(!player.isGodMode()) { + if(getAllowedListings() == 0) { + fail("You must have at least barter level 10 to be able to list items."); + return; + } + + Map playerProducts = shop.getProductsBySeller().get(player.getDocumentId()); + + if(playerProducts != null + && !playerProducts.containsKey(item.getId()) + && playerProducts.size() >= getAllowedListings() + ) { + fail(String.format("You can list maximum %d items.", getAllowedListings())); + return; + } + } + + int inventory = player.getInventory().getQuantity(item); + Integer unitQuantity = responses.getOrDefault("unit_quantity", 1); + Integer price = responses.get("price"); + Integer stock = responses.getOrDefault("stock", inventory); + Item shillings = ItemRegistry.getItem("accessories/shillings"); + + if(unitQuantity == null) { + fail("Unit quantity is missing."); + return; + } + + if(price == null) { + fail("Price is missing."); + return; + } + + if(stock == null) { + fail("Stock is missing."); + return; + } + + if(unitQuantity <= 0) { + fail("Unit quantity must be positive."); + return; + } + + if(price <= 0) { + fail("Price must be positive."); + return; + } + + if(stock <= 0) { + fail("Stock must be positive."); + return; + } + + if(stock > inventory) { + fail(String.format("You only have %d of this item.", inventory)); + return; + } + + int totalCost = price * stock; + int serviceCharge = (int)Math.ceil(SERVICE_CHARGE_FACTOR * totalCost); + if(!player.isGodMode()) { + if(!player.getInventory().hasItem(shillings, serviceCharge)) { + fail(String.format("You do not have %d shillings to pay the service charge.", serviceCharge)); + return; + } + } + + Dialog confirmationDialog = new Dialog().setType(DialogType.ANDROID).setTitle("Confirmation"); + + confirmationDialog.addSection(new DialogSection().setText(String.format( + "You are about to list %d %s for %d shilling%s for %s", + stock, + item.getTitle(), + price, + price == 1 ? "" : "s", + unitQuantity == 1 ? "each" : Integer.toString(unitQuantity) + ))); + + if(!player.isGodMode() && serviceCharge > 0) { + confirmationDialog.addSection(new DialogSection().setText(String.format( + "There will be a %d shilling%s service charge.", + serviceCharge, + serviceCharge == 1 ? "" : "s" + ))); + } + + confirmationDialog.addSection(new DialogSection().setText("Are you sure?")); + + confirmationDialog.setActions("yesno"); + + player.showDialog(confirmationDialog, ans -> { + if(!checkCanTradeItem(item)) return; + + if(ans.length > 0 && "Yes".equals(ans[0])) { + if(!player.getInventory().hasItem(shillings, serviceCharge)) return; + + if(!player.isGodMode()) player.getInventory().removeItem(shillings, serviceCharge, true); + shop.removeProduct(player.getDocumentId(), item.getId()); + shop.addProduct(new ScrapMarketProduct(player.getDocumentId(), item.getId(), unitQuantity, stock, price)); + } + }); + } + + public void fail(String message) { + player.showDialog(DialogHelper.messageDialog("Error", message).setType(DialogType.ANDROID)); + } + + public ScrapMarketOfferSession setUnitQuantityDefault(Object unitQuantity) { + formDefaults.put("unit_quantity", unitQuantity.toString()); + return this; + } + + public ScrapMarketOfferSession setPriceDefault(Object price) { + formDefaults.put("price", price.toString()); + return this; + } + + public ScrapMarketOfferSession setStockDefault(Object stock) { + formDefaults.put("stock", stock.toString()); + return this; + } + + private int getAllowedListings() { + int barterLevel = player.getTotalSkillLevel(Skill.BARTER); + if(barterLevel >= 13) return 4; + if(barterLevel >= 12) return 3; + if(barterLevel >= 11) return 2; + if(barterLevel >= 10) return 1; + return 0; + } + + public boolean checkCanTradeItem(Item item) { + if(player.isGodMode()) return true; + + if(item.getTradeability() == Tradeability.FALSE) { + player.showDialog(DialogHelper.messageDialog("Cannot Trade Item", "Sorry but you cannot sell this item.").setType(DialogType.ANDROID)); + return false; + } + + if(item.getTradeability() == Tradeability.LEVELED && player.getLevel() < 20) { + player.showDialog(DialogHelper.messageDialog("Cannot Trade Item", "You must be level 20+ to trade this item.").setType(DialogType.ANDROID)); + return false; + } + + return true; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketProduct.java b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketProduct.java new file mode 100644 index 00000000..70564b1b --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketProduct.java @@ -0,0 +1,145 @@ +package brainwine.gameserver.scrapmarket; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.Skill; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.time.OffsetDateTime; +import java.util.NoSuchElementException; + +public class ScrapMarketProduct { + private final String sellerId; + private final String itemId; + private final int unitQuantity; + private final int price; + private final OffsetDateTime offerDate; + private int stock; + + public ScrapMarketProduct() { + this.sellerId = ""; + this.itemId = ""; + this.unitQuantity = 0; + this.stock = 0; + this.price = 0; + this.offerDate = OffsetDateTime.now(); + } + + public ScrapMarketProduct(String sellerId, String itemId, int unitQuantity, int stock, int price) { + this.sellerId = sellerId; + this.itemId = itemId; + this.unitQuantity = unitQuantity; + this.stock = stock; + this.price = price; + this.offerDate = OffsetDateTime.now(); + } + + public boolean validate() { + GameServer server = GameServer.getInstance(); + Player seller = server.getPlayerManager().getPlayerById(sellerId); + Item item = ItemRegistry.getItem(itemId); + + return seller != null && item != null; + } + + private Player expectSeller() { + Player seller = GameServer.getInstance().getPlayerManager().getPlayerById(sellerId); + if(seller == null) { + throw new NoSuchElementException("Selling player does not exist anymore."); + } + + return seller; + } + + private Item expectItem() { + Item item = ItemRegistry.getItem(itemId); + if(item.isAir()) { + throw new NoSuchElementException("Item not found."); + } + + return item; + } + + private Item expectShillings() { + Item shillings = ItemRegistry.getItem("accessories/shillings"); + if(shillings.isAir()) { + throw new NoSuchElementException("Shillings item not found."); + } + + return shillings; + } + + public boolean inStock(int quantity) { + return quantity <= stock; + } + + public boolean availableInInventory(int quantity) { + return expectSeller().getInventory().hasItem(expectItem(), quantity); + } + + public boolean checkBarterLevel() { + return expectSeller().getTotalSkillLevel(Skill.BARTER) >= ScrapMarket.MIN_BARTER_LEVEL; + } + + public void purchase(Player buyer, int quantity) { + Player seller = expectSeller(); + Item item = expectItem(); + + int totalQuantity = quantity; + + if(!inStock(quantity)) { + throw new IllegalArgumentException("There is not enough of this item for sale."); + } + + if(!availableInInventory(quantity)) { + throw new IllegalArgumentException(seller.getName() + " does not have enough of this item in their inventory."); + } + + if(!checkBarterLevel()) { + throw new IllegalArgumentException(seller.getName() + " does not have a sufficient barter level."); + } + + seller.getInventory().removeItem(item, totalQuantity, true); + buyer.getInventory().addItem(item, totalQuantity, true); + stock -= totalQuantity; + } + + public String getSellerId() { + return sellerId; + } + + public String getItemId() { + return itemId; + } + + public int getUnitQuantity() { + return unitQuantity; + } + + public int getStock() { + return stock; + } + + public int getPrice() { + return price; + } + + @JsonIgnore + public String getDialogId() { + return sellerId + "/" + itemId; + } + + public OffsetDateTime getOfferDate() { + return offerDate; + } + + public static String getSellerIdFromDialogId(String dialogId) { + return dialogId.substring(0, dialogId.indexOf("/")); + } + + public static String getItemIdFromDialogId(String dialogId) { + return dialogId.substring(dialogId.indexOf("/") + 1); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketSession.java b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketSession.java new file mode 100644 index 00000000..3c6c61d9 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketSession.java @@ -0,0 +1,40 @@ +package brainwine.gameserver.scrapmarket; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.player.Player; + +public class ScrapMarketSession { + private ScrapMarket shop; + private Npc me; + private Player player; + + public ScrapMarketSession(ScrapMarket shop, Npc me, Player player) { + this.shop = shop; + this.me = me; + this.player = player; + } + + public void showNextDialog() { + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Scrap Market"); + + dialog.addSection(new DialogSection().setText("Visit Shop").setChoice("buy")); + dialog.addSection(new DialogSection().setText("View My Own Offers").setChoice("my_offers")); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || "cancel".equals(ans[0])) { + return; + } + + if("buy".equals(ans[0])) { + new ScrapMarketBuySession(shop, me, player).showNextDialog(); + } + + if("my_offers".equals(ans[0])) { + new ScrapMarketViewSession(shop, me, player).showNextDialog(); + } + }); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketViewSession.java b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketViewSession.java new file mode 100644 index 00000000..6d75f91e --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/scrapmarket/ScrapMarketViewSession.java @@ -0,0 +1,147 @@ +package brainwine.gameserver.scrapmarket; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogListItem; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.player.Player; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; +import java.util.Optional; + +public class ScrapMarketViewSession { + private final ScrapMarket shop; + private final Npc me; + private final Player player; + + private static final Logger logger = LogManager.getLogger(); + + private Optional currentProduct = Optional.empty(); + private Optional action = Optional.empty(); + + public ScrapMarketViewSession(ScrapMarket shop, Npc me, Player player) { + this.shop = shop; + this.me = me; + this.player = player; + } + + public void showNextDialog() { + if(!player.isOnline() || me != null && (player.getZone() != me.getZone())) { + return; + } + + try { + if(!currentProduct.isPresent() || !action.isPresent()) showSectionDialog(); + else { + if("delete".equals(action.get())) { + confirmDelete(); + } + + else if("edit".equals(action.get())) { + confirmEdit(); + } + + else { + player.notify("Invalid action: " + action.get()); + } + } + } catch(Exception e) { + player.notify("A problem occurred during your trade session."); + logger.error("A problem occurred during an android trade session.", e); + } + } + + public void showSectionDialog() { + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle(player.getName() + "'s Scrap Market Offering"); + + dialog.addSection(new DialogSection().setText("To offer a new item, drag it onto a Bert android and click/tap on \"Offer this on the Scrap Market\"")); + if(shop.getProductsBySeller().containsKey(player.getDocumentId())) { + for(ScrapMarketProduct product : shop.getProductsBySeller().get(player.getDocumentId()).values()) { + Item item = ItemRegistry.getItem(product.getItemId()); + dialog.addSection(getProductSection1(product)); + dialog.addSection(new DialogSection().setText(String.format( + "%d for %d shilling%s, %d in stock", + product.getUnitQuantity(), + product.getPrice(), + product.getPrice() == 1 ? "" : "s", + product.getStock() + ))); + dialog.addSection(new DialogSection().setText("Edit " + item.getTitle() + " Offering").setChoice("edit" + "." + product.getDialogId())); + dialog.addSection(new DialogSection().setText("Take Down " + item.getTitle() + " Offering").setChoice("delete" + "." + product.getDialogId())); + } + } + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || "cancel".equals(ans[0]) || !(ans[0] instanceof String)) { + return; + } + + String choice = (String)ans[0]; + int dotIndex = choice.indexOf('.'); + action = Optional.of(choice.substring(0, dotIndex)); + String productDialogId = choice.substring(dotIndex + 1); + String sellerId = ScrapMarketProduct.getSellerIdFromDialogId(productDialogId); + String itemId = ScrapMarketProduct.getItemIdFromDialogId(productDialogId); + if(shop.getProductsBySeller().containsKey(sellerId)) { + ScrapMarketProduct product = shop.getProductsBySeller().get(sellerId).get(itemId); + if(product != null) { + currentProduct = Optional.of(product); + showNextDialog(); + return; + } + } + + player.notify("Cannot find the selected product."); + }); + } + + public void confirmEdit() { + ScrapMarketProduct product = currentProduct.get(); + Item item = ItemRegistry.getItem(product.getItemId()); + new ScrapMarketOfferSession(shop, player, item) + .setUnitQuantityDefault(product.getUnitQuantity()) + .setPriceDefault(product.getPrice()) + .setStockDefault(product.getStock()) + .showNextDialog(); + } + + public void confirmDelete() { + ScrapMarketProduct product = currentProduct.get(); + Item item = ItemRegistry.getItem(product.getItemId()); + Dialog dialog = new Dialog().setType(DialogType.ANDROID).setTitle("Taking Down Offer"); + + dialog.addSection(getProductSection1(product)); + + dialog.addSection(new DialogSection().setText(String.format("Are you sure you want to take down the offer on %s?", item.getTitle()))); + dialog.setActions("yesno"); + + player.showDialog(dialog, ans -> { + if(ans.length == 0 || ans[0] instanceof String && "yes".equalsIgnoreCase((String)ans[0])) { + shop.removeProduct(product); + } + + currentProduct = Optional.empty(); + action = Optional.empty(); + showNextDialog(); + }); + } + + public DialogSection getProductSection1(ScrapMarketProduct product) { + Item item = ItemRegistry.getItem(product.getItemId()); + + // This is eyeballed + int spaces = (int)Math.max(0.0f, 1.5f * (18 - item.getTitle().length())); + String padding = String.join("", Collections.nCopies(spaces, " ")); + return new DialogSection() + .addItem(new DialogListItem().setItem(item.getCode()).setText(padding + item.getTitle())); + } + + public DialogSection getProductSection2(ScrapMarketProduct product) { + return new DialogSection().setText(ItemRegistry.getItem(product.getItemId()).getHint()); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/server/DefaultPusher.java b/gameserver/src/main/java/brainwine/gameserver/server/DefaultPusher.java new file mode 100644 index 00000000..fb3d6aca --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/server/DefaultPusher.java @@ -0,0 +1,18 @@ +package brainwine.gameserver.server; + +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Zone; + +public class DefaultPusher implements Pusher { + @Override + public void handlePlayerJoin(Player player) {} + + @Override + public void handlePlayerLeave(Player player) {} + + @Override + public void handleZoneDiscovered(Zone zone) {} + + @Override + public void handlePlayerMessage(Player player, String message) {} +} diff --git a/gameserver/src/main/java/brainwine/gameserver/server/IpBans.java b/gameserver/src/main/java/brainwine/gameserver/server/IpBans.java new file mode 100644 index 00000000..447631df --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/server/IpBans.java @@ -0,0 +1,160 @@ +package brainwine.gameserver.server; + +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.pipeline.Connection; +import brainwine.gameserver.util.Cidr; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class IpBans { + private static final Logger logger = LogManager.getLogger(); + private final String FILE_NAME = "banned-ips.json"; + + List bannedIps = new ArrayList<>(); + Map bansByIp = new HashMap<>(); + + public void loadIpBans() { + logger.info("Loading ip bans ..."); + try { + bannedIps = JsonHelper.readValue(new File(FILE_NAME), new TypeReference>() {}); + for(Item item : bannedIps) { + bansByIp.put(item.getIpAddress(), item); + } + } catch(IOException e) { + logger.error("Failed to read " + FILE_NAME, e); + } + } + + public void saveIpBans() { + try { + JsonHelper.writeValue(new File(FILE_NAME), bannedIps); + } catch(IOException e) { + logger.error("Failed to write " + FILE_NAME, e); + } + } + + public Item findMatchingIpBan(Cidr playerIp) { + if(playerIp == null) return null; + for(Item bannedItem : bannedIps) { + if(playerIp.matches(bannedItem.getIpAddress())) { + return bannedItem; + } + } + return null; + } + + public boolean isCidrBanned(Cidr bannedIp) { + return bansByIp.containsKey(bannedIp); + } + + private Item addItem(Item item) { + bannedIps.add(item); + bansByIp.put(item.getIpAddress(), item); + return item; + } + + public void banCidr(Cidr bannedIp, Player scapegoat) { + Item item = bansByIp.get(bannedIp); + if(item == null) { + item = addItem(new Item(bannedIp)); + } + if(scapegoat != null) { + item.getKnownUuids().add(scapegoat.getDocumentId()); + item.getKnownUsernames().add(scapegoat.getName()); + } + } + + public void unbanCidr(Cidr bannedIp) { + Item item = bansByIp.remove(bannedIp); + bannedIps.remove(item); + } + + public void ban(Player player) { + if(!player.isOnline()) return; + + Cidr playerIp = player.getConnection().getIpAddress(); + Item banByIpAddress = bansByIp.get(playerIp); + + if(banByIpAddress == null) { + banByIpAddress = addItem(new Item(playerIp)); + } + + banByIpAddress.getKnownUuids().add(player.getDocumentId()); + banByIpAddress.getKnownUsernames().add(player.getName()); + } + + public Set unbanAndCheckForCidrBlock(Player player) { + return unbanAndCheckForCidrBlock(player, player.getConnection()); + } + + public Set unbanAndCheckForCidrBlock(Player player, Connection connection) { + Set ipBlocks = new HashSet<>(); + List deletedItems = new ArrayList<>(); + Cidr playerCurrentIp = connection != null ? connection.getIpAddress() : null; + for(Item item : bannedIps) { + Cidr firstIp = item.getIpAddress(); + if(item.getIpAddress().equals(playerCurrentIp) + || item.getKnownUuids().contains(player.getDocumentId()) && firstIp != null + ) { + if(item.getKnownUuids().size() > 1) { + ipBlocks.add((playerCurrentIp == null ? firstIp : playerCurrentIp).toString() + " (multiple players)"); + continue; + } + item.getKnownUuids().remove(player.getDocumentId()); + item.getKnownUsernames().remove(player.getName()); + if(item.getIpAddress().equals(playerCurrentIp)) { + bansByIp.remove(playerCurrentIp); + } + if(item.getIpAddress().equals(firstIp)) { + bansByIp.remove(firstIp); + } + deletedItems.add(item); + } else { + if(item.getIpAddress().matches(playerCurrentIp)) { + ipBlocks.add(item.getIpAddress().toString()); + } + } + } + bannedIps.removeAll(deletedItems); + + return ipBlocks; + } + + public static class Item { + private final Cidr ipAddress; + @JsonProperty + private final Set knownUuids = new HashSet<>(); + @JsonProperty + private final Set knownUsernames = new HashSet<>(); + + @JsonCreator + public Item(@JsonProperty("ip_address") Cidr ipAddress) { + this.ipAddress = ipAddress; + } + + public Set getKnownUuids() { + return knownUuids; + } + + public Set getKnownUsernames() { + return knownUsernames; + } + + public Cidr getIpAddress() { + return ipAddress; + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/server/Pusher.java b/gameserver/src/main/java/brainwine/gameserver/server/Pusher.java new file mode 100644 index 00000000..24cd9e8a --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/server/Pusher.java @@ -0,0 +1,10 @@ +package brainwine.gameserver.server; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.zone.Zone; + +public interface Pusher { + void handlePlayerJoin(Player player); + void handlePlayerLeave(Player player); + void handleZoneDiscovered(Zone zone); + void handlePlayerMessage(Player player, String message); +} diff --git a/gameserver/src/main/java/brainwine/gameserver/server/messages/QuestMessage.java b/gameserver/src/main/java/brainwine/gameserver/server/messages/QuestMessage.java new file mode 100644 index 00000000..79ef0862 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/server/messages/QuestMessage.java @@ -0,0 +1,18 @@ +package brainwine.gameserver.server.messages; + +import java.util.Map; + +import brainwine.gameserver.server.Message; +import brainwine.gameserver.server.MessageInfo; + + +@MessageInfo(id = 63, collection = true) +public class QuestMessage extends Message { + public Map details; + public Map status; + + public QuestMessage(Map clientDetails, Map clientStatus) { + details = clientDetails; + status = clientStatus; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/server/messages/ZoneExploredMessage.java b/gameserver/src/main/java/brainwine/gameserver/server/messages/ZoneExploredMessage.java index e4fec542..165a6b1c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/messages/ZoneExploredMessage.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/messages/ZoneExploredMessage.java @@ -7,8 +7,10 @@ public class ZoneExploredMessage extends Message { public int chunkIndex; + public float explorationPercent; - public ZoneExploredMessage(int chunkIndex) { + public ZoneExploredMessage(int chunkIndex, float explorationPercent) { this.chunkIndex = chunkIndex; + this.explorationPercent = explorationPercent; } } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/pipeline/Connection.java b/gameserver/src/main/java/brainwine/gameserver/server/pipeline/Connection.java index 9db78094..c625b4f7 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/pipeline/Connection.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/pipeline/Connection.java @@ -7,6 +7,7 @@ import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; +import brainwine.gameserver.util.Cidr; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -96,6 +97,30 @@ public void kick(String reason, boolean shouldReconnect) { sendMessage(new KickMessage(reason, shouldReconnect)).addListener(ChannelFutureListener.CLOSE); } } + + public Cidr getIpAddress() { + String whole = channel.remoteAddress().toString(); + try { + int start = 0; + int end = whole.length(); + for(int i = 0; i < whole.length(); i++) { + if(whole.charAt(i) == '/') { + start = i + 1; + } + if(whole.charAt(i) == ':') { + if(i == whole.length() - 1 || whole.charAt(i + 1) != ':') { + end = i; + } else { + i++; + } + } + } + return Cidr.create(whole.substring(start, end)); + } catch(Exception e) { + logger.error("Error while parsing ip address for " + whole + ".", e); + return null; + } + } public void setPlayer(Player player) { this.player = player; diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/AuthenticateRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/AuthenticateRequest.java index f391b0a6..6cc48bc7 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/AuthenticateRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/AuthenticateRequest.java @@ -8,11 +8,14 @@ import brainwine.gameserver.player.Player; import brainwine.gameserver.player.PlayerManager; import brainwine.gameserver.player.PlayerRestriction; +import brainwine.gameserver.server.IpBans; import brainwine.gameserver.server.OptionalField; import brainwine.gameserver.server.Request; import brainwine.gameserver.server.RequestInfo; import brainwine.gameserver.server.messages.NotificationMessage; import brainwine.gameserver.server.pipeline.Connection; +import brainwine.gameserver.util.Cidr; +import brainwine.gameserver.util.VersionUtils; import brainwine.gameserver.zone.Zone; @RequestInfo(id = 1) @@ -29,9 +32,18 @@ public class AuthenticateRequest extends Request { public void process(Connection connection) { GameServer server = GameServer.getInstance(); PlayerManager playerManager = server.getPlayerManager(); - - if(!PlayerManager.SUPPORTED_VERSIONS.contains(version)) { - connection.kick("Sorry, this version of Deepworld is not supported."); + + String platform = "default"; + if(VersionUtils.isGreaterOrEqualTo(version, "3")) platform = "Unity"; + + String wantedVersion = PlayerManager.SUPPORTED_VERSIONS.get(platform); + if(wantedVersion == null) wantedVersion = PlayerManager.SUPPORTED_VERSIONS.get("default"); + if(wantedVersion == null) wantedVersion = "99"; + + if(!VersionUtils.isGreaterOrEqualTo(version, wantedVersion)) { + String defaultMessage = "Sorry, this version of Deepworld is not supported."; + String message = PlayerManager.SUPPORTED_VERSIONS.get("message"); + connection.kick(message != null ? message : defaultMessage); return; } @@ -41,12 +53,39 @@ public void process(Connection connection) { connection.kick("The provided session token is invalid or has expired. Please try relogging."); return; } - + + Cidr foundCidr = connection.getIpAddress(); + IpBans.Item foundIpBan = server.getIpBans().findMatchingIpBan(foundCidr); + server.queueSynchronousTask(() -> { Player player = playerManager.getPlayer(name); + if(player.getBlockedUntil() > System.currentTimeMillis()) { + connection.kick(player.getBlockReason() + " You need to wait for " + (player.getBlockedUntil() - System.currentTimeMillis()) / 1000 + " more seconds."); + return; + } PlayerRestriction ban = player.getCurrentBan(); + if(ban == null && foundIpBan != null) { + for(String uuid : foundIpBan.getKnownUuids()) { + Player bannedPlayer = playerManager.getPlayerById(uuid); + if(bannedPlayer == null) continue; + PlayerRestriction otherBan = bannedPlayer.getCurrentBan(); + if(otherBan != null) { + String newReason = otherBan.getReason() + " (You had been banned due to an IP ban)"; + player.ban(newReason, otherBan.getEndDate()); + connection.kick(newReason); + return; + } + } + + // Try to clear the IP ban if no banned player with the ip was found. + if(!server.getIpBans().unbanAndCheckForCidrBlock(player, connection).isEmpty()) { + connection.kick("Your IP was banned for an unknown reason."); + server.notify(String.format("%s tried to join with ip %s which is banned because of the ban on %s.", player.getName(), foundCidr, foundIpBan.getIpAddress()), NotificationType.SYSTEM); + return; + } + } Zone zone = player.getZone(); - + if(ban != null) { // Send player to jail world if they're banned zone = server.getZoneManager().getZoneByName("Hell"); @@ -62,7 +101,7 @@ public void process(Connection connection) { connection.sendDelayedMessage(new NotificationMessage(banMessage, NotificationType.MAINTENANCE), 5000); // Slightly hacky but shouldn't cause any issues } else if(zone == null || !zone.canJoin(player)) { // Try to put player in a random zone if current zone is null or cannot be joined - zone = server.getZoneManager().findBeginnerZone(); + zone = (!player.isInTutorial() && player.getAchievements().isEmpty()) ? server.getZoneManager().findTutorialZone() : server.getZoneManager().findBeginnerZone(); // Kick player if zone is still null (aka it failed to find a suitable random zone) if(zone == null) { diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java index 2bf9f5d5..5ddad66a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java @@ -2,6 +2,7 @@ import java.util.Map; +import brainwine.gameserver.command.CommandAccessLevel; import brainwine.gameserver.entity.Entity; import brainwine.gameserver.item.Action; import brainwine.gameserver.item.Fieldability; @@ -13,6 +14,7 @@ import brainwine.gameserver.player.NotificationType; import brainwine.gameserver.player.Player; import brainwine.gameserver.player.Skill; +import brainwine.gameserver.quest.QuestEvents; import brainwine.gameserver.server.PlayerRequest; import brainwine.gameserver.server.RequestInfo; import brainwine.gameserver.server.messages.BlockChangeMessage; @@ -72,6 +74,20 @@ public void process(Player player) { fail(player, "This block cannot be mined."); return; } + + if(layer == Layer.BASE && !item.isScrubbable()) { + if(item.hasId("base/maw") || item.hasId("base/pipe")) { + fail(player, "You need to plug this first before scrubbing."); + } else { + fail(player, "You can't scrub this item away."); + } + return; + } + + if(layer == Layer.BASE && player.getHeldItem().getAction() != Action.SCRUB) { + fail(player, "You need a scrubber to mine this item."); + return; + } if(!player.isGodMode() && item.isEntity()) { fail(player, "You must destroy the entity instead of its mount."); @@ -95,14 +111,21 @@ public void process(Player player) { fail(player, "You must keep at least one world teleporter active."); return; } - + break; default: break; } } - + if(digging) { zone.digBlock(x, y); + QuestEvents.handleDig(player); + return; + } + + // Scrub the base layer if block is being mined with a scrubber + if(layer == Layer.BASE && player.getHeldItem().getAction() == Action.SCRUB && block.getBack() == 0 && block.getFront() == 0) { + zone.updateBlock(x, y, Layer.BASE, Item.AIR, 0); return; } @@ -170,14 +193,25 @@ public void process(Player player) { } // Check for entity spawns - if(item.hasEntitySpawns() && block.getMod(layer) == 0 && !item.hasTimer() && !item.hasUse(ItemUseType.SPAWN)) { - zone.spawnEntity(item.getEntitySpawns().next(), x, y); + boolean entitySpawns = item.hasEntitySpawns() && block.getMod(layer) == 0 && !item.hasTimer() && !item.hasUse(ItemUseType.SPAWN); + if((!block.isNatural() || item.getEntitySpawnAccessLevel() == CommandAccessLevel.EVERYONE) + && entitySpawns && item.getEntitySpawnAccessLevel().isPrivileged(player, block)) { + int left = item.getEntitySpawnQuantity().getFirst(); + int right = item.getEntitySpawnQuantity().getLast() + 1; + int quantity = (int)(left + Math.random() * (right - left)); + for(int i = 0; i < quantity; i++) { + zone.spawnEntity(item.getEntitySpawns().next(), x, y); + } } // Determine inventory item Item inventoryItem; - - if(item.getMod() == ModType.DECAY && block.getMod(layer) > 0) { + + if(entitySpawns && (!item.getEntitySpawnAccessLevel().isPrivileged(player, block) || (block.isNatural() && item.getEntitySpawnAccessLevel() != CommandAccessLevel.EVERYONE))) { + inventoryItem = item; + // Non-standard behavior + player.sendMessage(new InventoryMessage(player.getInventory().getClientConfig(item.getInventoryItem()))); + } else if(item.getMod() == ModType.DECAY && block.getMod(layer) > 0) { inventoryItem = item.getDecayInventoryItem(); } else if(item.hasModInventoryItem()) { inventoryItem = item.getModInventoryItem(block.getMod(layer)); @@ -187,25 +221,29 @@ public void process(Player player) { int quantity = 1; player.getStatistics().trackItemMined(item); - + + boolean trackQuest = false; if(block.isNatural()) { player.getStatistics().trackItemScavenged(item); + trackQuest = true; } // Check stack mod if(item.getMod() == ModType.STACK) { quantity = Math.max(1, block.getMod(layer)); } - - zone.updateBlock(x, y, layer, 0, 0, player); + + // Check pile use type + if(item.hasUse(ItemUseType.PILE)) { + quantity = (block.getMod(layer) + 1) * (int)item.getUse(ItemUseType.PILE); + } // Apply mining bonus if there is one if(item.hasMiningBonus()) { MiningBonus bonus = item.getMiningBonus(); - - if(Math.random() < player.getMiningBonusChance(bonus)) { - if(!bonus.getItem().isAir()) { - inventoryItem = bonus.getItem(); + if(Math.random() < player.getMiningBonusChance(bonus) && bonus.getMod() <= block.getMod(layer)) { + if(!bonus.computeItem(item).isAir()) { + inventoryItem = bonus.computeItem(item); } if(bonus.isDoubleLoot()) { @@ -215,9 +253,24 @@ public void process(Player player) { player.notify(bonus.getNotification(), NotificationType.FANCY_EMOTE); } } - + + zone.updateBlock(x, y, layer, 0, 0, player); + + // Spawn blocks back if the zone is tutorial + if(zone.isTutorial() && zone.getRules().isAutoCleanEnabled()) { + zone.addBlockTimer(x, y, zone.getRules().getAutoCleanDuration(), () -> { + zone.updateBlock(x, y, Layer.FRONT, item); + }); + } + if(!inventoryItem.isAir()) { player.getInventory().addItem(inventoryItem, quantity, true); + if(trackQuest && layer == Layer.FRONT) { + QuestEvents.handleCollectItem(player, inventoryItem, quantity); + if(!item.equals(inventoryItem)) { + QuestEvents.handleCollectItem(player, item, 1); + } + } } } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockPlaceRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockPlaceRequest.java index 96e542d2..50b1ac3b 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockPlaceRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockPlaceRequest.java @@ -1,17 +1,21 @@ package brainwine.gameserver.server.requests; +import java.util.HashMap; import java.util.UUID; +import brainwine.gameserver.GameServer; import brainwine.gameserver.entity.EntityConfig; import brainwine.gameserver.entity.npc.Npc; import brainwine.gameserver.item.DamageType; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.ItemGroup; +import brainwine.gameserver.item.ItemRegistry; import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.item.Layer; import brainwine.gameserver.item.ModType; import brainwine.gameserver.player.Player; import brainwine.gameserver.player.Skill; +import brainwine.gameserver.quest.QuestEvents; import brainwine.gameserver.server.PlayerRequest; import brainwine.gameserver.server.RequestInfo; import brainwine.gameserver.server.messages.BlockChangeMessage; @@ -34,6 +38,9 @@ public class BlockPlaceRequest extends PlayerRequest { @Override public void process(Player player) { Zone zone = player.getZone(); + + // It gets reassigned but the old value is needed on failure. + Item item = this.item; if(player.isDead()) { return; @@ -80,8 +87,8 @@ public void process(Player player) { } if(!player.isGodMode() && item.hasSpacing() && zone.getMetaBlocks().stream().anyMatch(block - -> (item.hasSpacingItems() ? item.getSpacingItems().contains(block.getItem()) : block.getItem() == item) - && MathUtils.inRange(block.getX(), block.getY(), x, y, item.getSpacing()))) { + -> (this.item.hasSpacingItems() ? this.item.getSpacingItems().contains(block.getItem()) : block.getItem() == this.item) + && MathUtils.inRange(block.getX(), block.getY(), x, y, this.item.getSpacing()))) { fail(player, String.format("%s must be at least %s blocks away from other %ss.", item.getTitle(), item.getSpacing(), item.getTitle().toLowerCase())); return; } @@ -104,23 +111,114 @@ public void process(Player player) { return; } } - + + Integer itemLimit = GameServer.getInstance().getZoneActivityManager().getPlayerItemLimits(zone.getActivity()).get(item.getId()); + if(!player.isGodMode() && itemLimit != null && (itemLimit == 0 || zone.getMetaBlocksWithItem(item).stream() + .filter(x -> player.equals(x.getOwner())) + .count() >= itemLimit) + ) { + fail(player, itemLimit == 0 + ? "This item cannot be placed in this world." + : "You can only place " + itemLimit + " of these in this world." + ); + return; + } + if(!player.isGodMode() && item.isDish() && zone.willDishOverlap(x, y, item.getField(), player)) { fail(player, "Dish will overlap another protector."); return; } + + if(!player.isGodMode() && zone.isTutorial()) { + fail(player, "You can't place blocks in the tutorial world"); + return; + } if(layer == Layer.LIQUID) { mod = 5; } else if(item.getMod() == ModType.ROTATION && !item.isMirrorable()) { mod = findRotationMod(zone, x, y, item.getBlockWidth(), item.getBlockHeight()); } - - zone.updateBlock(x, y, layer, item, mod, player); - player.getInventory().removeItem(item); + + Item inventory = item; + boolean isBlockPlaced = false; + int inventoryRemoveQuantity = 1; + boolean inventoryRemoveSendMessage = false; + + // DIFFERENT PLACED ITEM LOGIC + + // Process pile placement if applicable + if(!ItemRegistry.getPile(inventory).isAir()) { + item = ItemRegistry.getPile(inventory); + int unit = (int)item.getUse(ItemUseType.PILE); + if(!player.isGodMode() && !player.getInventory().hasItem(inventory, unit)) { + fail(player, "You don't have enough of this item to pile."); + return; + } + + inventoryRemoveQuantity = unit; + inventoryRemoveSendMessage = true; + mod = 0; + } + + // AT THIS POINT `item` IS WHAT THE ITEM THAT IS ACTUALLY PLACED WILL BE. + + // Process jar use if applicable + if(item.getPlaceTransform() != null) { + Block block = zone.getBlock(x, y); + if(block == null) return; + + for(String originalId : item.getPlaceTransform().keySet()) { + Item original = ItemRegistry.getItem(originalId); + Item replacement = ItemRegistry.getItem(item.getPlaceTransform().get(originalId)); + + if(original == null || replacement == null) continue; + + if(block.getItem(original.getLayer()).equals(original)) { + if(original.getLayer() != replacement.getLayer()) { + zone.updateBlock(x, y, original.getLayer(), Item.AIR); + } + zone.updateBlock(x, y, replacement.getLayer(), replacement); + isBlockPlaced = true; + break; + } + } + } + + // Place the item as a block if no block had been placed yet + if(!isBlockPlaced) { + zone.updateBlock(x, y, layer, item, mod, player); + } + + player.getInventory().removeItem(inventory, inventoryRemoveQuantity, inventoryRemoveSendMessage); player.getStatistics().trackItemPlaced(); player.trackPlacement(x, y, item); - + + // Disintegrate earth-like blocks if they don't have a back layer + Block block = zone.getBlock(x, y); + if(zone.getRules().isAutoCleanEnabled() + && (item.getCode() == 510 || item.getCode() == 511 || item.getCode() == 512) + && block.getBase() == 0 && block.getBack() == 0 + ) { + zone.addBlockTimer(x, y, zone.getRules().getAutoCleanDuration(), () -> { + zone.updateBlock(x, y, Layer.FRONT, "ground/earth-dug"); + zone.addBlockTimer(x, y, zone.getRules().getAutoCleanDuration(), () -> { + zone.updateBlock(x, y, Layer.FRONT, 0); + }); + }); + } + + // TODO: implement item "metadata" field instead + if(item.hasUse(ItemUseType.XP_SIGN)) { + MetaBlock metaBlock = zone.getMetaBlock(x, y); + + if(metaBlock != null) { + HashMap newMetadata = new HashMap<>(metaBlock.getMetadata()); + newMetadata.put("vc", item.getUse(ItemUseType.XP_SIGN)); + zone.setMetaBlock(x, y, item, player, newMetadata); + } + } + // Create block timer if applicable if(item.hasTimer()) { createBlockTimer(zone, player); @@ -253,12 +351,32 @@ private void processBurial(Zone zone, Player player) { } player.getStatistics().trackUndertaking(); + QuestEvents.handleBury(player); } private void createBlockTimer(Zone zone, Player player) { String type = item.getTimerType(); int value = item.getTimerValue(); Runnable task = null; + + // Bomb suppression + if(type != null && type.startsWith("bomb")) { + for(MetaBlock suppressor : zone.getMetaBlocksWithUse(ItemUseType.SUPPRESS_BOMB)) { + if( + // The suppressor is close enough. + MathUtils.distance(suppressor.getX(), suppressor.getY(), x, y) <= suppressor.getItem().getPower() + // The suppressor is powered. + && zone.getBlock(suppressor.getX(), suppressor.getY()).getFrontMod() > 0 + ) { + Item replacement = ItemRegistry.getItem(item.getId() + "-inert"); + if(!replacement.isAir()) { + zone.updateBlock(x, y, layer, replacement); + } + + return; + } + } + } switch(type) { case "front mod": diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockUseRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockUseRequest.java index 73318b40..811bdd20 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockUseRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockUseRequest.java @@ -83,7 +83,7 @@ public void process(Player player) { ItemInteraction interaction = use.getInteraction(); if(interaction != null) { - interaction.interact(zone, player, x, y, layer, item, mod, metaBlock, config, data); + interaction.interact(zone, player, x, y, layer, item, mod, metaBlock, config.getConfig(), data); } }); } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlocksRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlocksRequest.java index 9f20d7a1..2820d846 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlocksRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlocksRequest.java @@ -4,11 +4,13 @@ import java.util.List; import brainwine.gameserver.player.Player; +import brainwine.gameserver.player.Skill; import brainwine.gameserver.server.PlayerRequest; import brainwine.gameserver.server.RequestInfo; import brainwine.gameserver.server.messages.BlockMetaMessage; import brainwine.gameserver.server.messages.BlocksMessage; import brainwine.gameserver.server.messages.LightMessage; +import brainwine.gameserver.util.MathUtils; import brainwine.gameserver.zone.Chunk; import brainwine.gameserver.zone.MetaBlock; import brainwine.gameserver.zone.Zone; @@ -23,7 +25,7 @@ public void process(Player player) { Zone zone = player.getZone(); // TODO threshold should probably be based on chunk size & perception level - if(!player.isGodMode() && player.getActiveChunkCount() > 64) { + if(!player.isGodMode() && player.getActiveChunkCount() > 70) { return; } @@ -56,7 +58,11 @@ public void process(Player player) { chunks.add(chunk); metaBlocks.addAll(zone.getLocalMetaBlocksInChunk(index)); - player.addActiveChunk(index); + + // If the player is newly seeing the chunk, mark it as fresh + if(player.addActiveChunk(index)) { + chunk.setLoadTime(System.currentTimeMillis()); + } if(chunk.getX() < minX || minX == -1) { minX = chunk.getX(); diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/ChangeAppearanceRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/ChangeAppearanceRequest.java index 22885ed2..a0b82a69 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/ChangeAppearanceRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/ChangeAppearanceRequest.java @@ -82,6 +82,6 @@ public void process(Player player) { } private void fail(Player player) { - player.sendMessage(new EntityChangeMessage(player.getId(), player.getAppearance())); + player.sendMessage(new EntityChangeMessage(player.getId(), player.getVisibleAppearance())); } } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/ChatRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/ChatRequest.java index d03e04bf..0b4d6232 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/ChatRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/ChatRequest.java @@ -1,5 +1,6 @@ package brainwine.gameserver.server.requests; +import brainwine.gameserver.chat.PlayerProfanity; import brainwine.gameserver.command.CommandManager; import brainwine.gameserver.player.NotificationType; import brainwine.gameserver.player.Player; @@ -25,7 +26,9 @@ public void process(Player player) { player.notify("You are currently muted. Your chat message was not sent.", NotificationType.SYSTEM); return; } + + String filteredText = PlayerProfanity.filterAndPunish(player, text); - player.getZone().sendChatMessage(player, text); + player.getZone().sendChatMessage(player, filteredText); } } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java index 618f8da1..7739e19f 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java @@ -5,9 +5,13 @@ import brainwine.gameserver.item.CraftingRequirement; import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.item.usetypeconfig.ExtendedSteamableConfig; import brainwine.gameserver.player.Inventory; import brainwine.gameserver.player.Player; import brainwine.gameserver.player.Skill; +import brainwine.gameserver.quest.QuestEvents; import brainwine.gameserver.server.OptionalField; import brainwine.gameserver.server.PlayerRequest; import brainwine.gameserver.server.RequestInfo; @@ -65,10 +69,18 @@ public void process(Player player) { // Check for each crafting helper if it is present in the workshop and available for use for(CraftingRequirement craftingHelper : item.getCraftingHelpers()) { int quantityRequired = craftingHelper.getQuantity(); - + // Fetch list of crafting helpers of this type that are present in the workshop + Item poweredItem; + if(craftingHelper.getItem().hasUse(ItemUseType.EXTENDED_STEAMABLE) && craftingHelper.getItem().isMirrorable()) { + ExtendedSteamableConfig steamable = craftingHelper.getItem().getStructuredUse(ItemUseType.EXTENDED_STEAMABLE); + poweredItem = ItemRegistry.getItem(steamable.getOnVariantId()); + } else { + poweredItem = craftingHelper.getItem(); + } + List presentCraftingHelpers = workshop.stream() - .filter(metaBlock -> metaBlock.getItem() == craftingHelper.getItem()).collect(Collectors.toList()); + .filter(metaBlock -> metaBlock.getItem() == craftingHelper.getItem() || metaBlock.getItem() == poweredItem).collect(Collectors.toList()); int quantityMissing = quantityRequired - presentCraftingHelpers.size(); // Check if workshop is still missing crafting helpers of this type and notify the player if this is the case @@ -79,9 +91,9 @@ public void process(Player player) { } // Perform additional checks if the crafting helper requires steam to function - if(craftingHelper.getItem().usesSteam()) { + if(craftingHelper.getItem().usesSteam() || craftingHelper.getItem().hasUse(ItemUseType.EXTENDED_STEAMABLE)) { quantityMissing = quantityRequired - (int)presentCraftingHelpers.stream() - .filter(metaBlock -> zone.getBlock(metaBlock.getX(), metaBlock.getY()).getFrontMod() == 1).count(); + .filter(metaBlock -> zone.isBlockPowered(metaBlock.getX(), metaBlock.getY())).count(); // Notify the player if not enough crafting helpers are powered if(quantityMissing > 0) { @@ -100,5 +112,6 @@ public void process(Player player) { int totalQuantity = item.getCraftingQuantity() * quantity; inventory.addItem(item, totalQuantity, item.requiresWorkshop()); player.getStatistics().trackItemCrafted(item, totalQuantity); + QuestEvents.handleCraft(player, item, totalQuantity); } } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java index 49dfc1c3..c070ca65 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.stream.Collectors; +import brainwine.gameserver.player.TradeSession; import org.apache.commons.text.WordUtils; import brainwine.gameserver.GameServer; @@ -61,6 +62,11 @@ private void showPlayerDialog(Player player) { // Create player info dialog Player subject = GameServer.getInstance().getPlayerManager().getPlayer((String)input[0]); + if(subject == null) { + player.showDialog(DialogHelper.messageDialog("Player not found!")); + return; + } + Dialog dialog = new Dialog().setTitle(subject.getName()); // Online status section @@ -68,7 +74,11 @@ private void showPlayerDialog(Player player) { dialog.addSection(new DialogSection().setText(String.format("Online\nCurrently in %s", subject.getZone().getName()))); if(player.getZone() != subject.getZone()) { - dialog.addSection(new DialogSection().setText(String.format("Goto %s", subject.getZone().getName())).setChoice("visit")); + if(subject.getZone() == null) { + dialog.addSection(new DialogSection().setText("Online but not in a zone.")); + } else { + dialog.addSection(new DialogSection().setText(String.format("Goto %s", subject.getZone().getName())).setChoice("visit")); + } } } else { dialog.addSection(new DialogSection().setText("Offline")); @@ -133,29 +143,64 @@ private void onSkillUpgrade(Player player) { .setOptions(upgradeableSkillNames) .setMaxColumns(3) .setKey("skill"))); - + player.showDialog(dialog, input -> { if(input.length == 0 || input[0].equals("cancel")) { return; } - - if(player.getSkillPoints() <= 0) { - player.notify("Sorry, you are out of skill points. Level up to earn some more!"); - return; - } - + Skill skill = Skill.fromId(input[0].toString()); - + if(!player.getUpgradeableSkills().contains(skill)) { player.notify("Sorry, you cannot upgrade that skill right now."); return; } - - int newSkillLevel = player.getSkillLevel(skill) + 1; - player.setSkillLevel(skill, newSkillLevel); - player.setSkillPoints(player.getSkillPoints() - 1); - player.showDialog(DialogHelper.messageDialog(String.format("You've successfully upgraded your %s skill to level %s!", - WordUtils.capitalize(skill.getId()), newSkillLevel))); + + int possibleUpgrade = Math.min(player.getSkillPoints(), Player.MAX_NATURAL_SKILL_LEVEL - player.getSkillLevel(skill)); + if(possibleUpgrade > 1) { + Dialog quantityDialog = new Dialog(); + + quantityDialog.setTitle(String.format("Upgrade %s", WordUtils.capitalize(skill.getId()))); + + quantityDialog.addSection(TradeSession.Dialogs.createQuantitySelector(possibleUpgrade).setTitle("How many skill points would you like to add?")); + + player.showDialog(quantityDialog, ans -> { + if(ans.length > 0 && !"cancel".equals(ans[0])) { + try { + int quantity = Integer.parseInt(ans[0].toString()); + onSkillUpgradeConfirm(player, skill, quantity); + } catch (NumberFormatException e) { + player.notify("Invalid number selection!"); + } + } + }); + } else { + onSkillUpgradeConfirm(player, skill, 1); + } }); } + + private void onSkillUpgradeConfirm(Player player, Skill skill, int quantity) { + if(player.getSkillPoints() < quantity) { + player.notify("Sorry, you are out of skill points. Level up to earn some more!"); + return; + } + + if(!player.getUpgradeableSkills().contains(skill)) { + player.notify("Sorry, you cannot upgrade that skill right now."); + return; + } + + int newSkillLevel = player.getSkillLevel(skill) + quantity; + + if(newSkillLevel > Player.MAX_NATURAL_SKILL_LEVEL) { + player.notify("Sorry, you cannot upgrade that skill this much right now."); + return; + } + + player.setSkillLevel(skill, newSkillLevel); + player.setSkillPoints(player.getSkillPoints() - quantity); + player.showDialog(DialogHelper.messageDialog(String.format("You've successfully upgraded your %s skill to level %s!", + WordUtils.capitalize(skill.getId()), newSkillLevel))); + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/EntityUseRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/EntityUseRequest.java index e5654b29..64ecb78c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/EntityUseRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/EntityUseRequest.java @@ -3,6 +3,7 @@ import java.util.List; import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.entity.npc.Npc; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.ItemRegistry; import brainwine.gameserver.player.Player; @@ -37,8 +38,17 @@ public void process(Player player) { player.tradeItem(targetPlayer, item); } } - + return; } + + // Handle NPC interaction + Npc npc = (Npc)entity; + + if(data instanceof List) { + npc.interact(player, ((List)data).toArray()); + } else { + npc.interact(player, data); + } } } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/InventoryUseRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/InventoryUseRequest.java index 5b800eac..8f4f9b47 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/InventoryUseRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/InventoryUseRequest.java @@ -5,6 +5,7 @@ import brainwine.gameserver.entity.Entity; import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Action; import brainwine.gameserver.item.Item; import brainwine.gameserver.player.Player; import brainwine.gameserver.server.OptionalField; @@ -39,6 +40,10 @@ public void process(Player player) { if(status == 1) { player.consume(item, details); } + } else if(item.getAction() == Action.TELEPORT && !item.isConsumable()) { + if(status == 1) { + player.consume(item, details); + } } else { // Set current held item if applicable if(type == 0 && status != 2) { @@ -71,7 +76,21 @@ public void process(Player player) { return; } } - + + // Check exoskeleton part cooldown + if(status == 1 + && player.isV3() + && "prosthetics".equals(item.getCategory()) + && player.isMomentaryAccessoryOnCooldown(item) + && !player.getMomentaryAccessoriesUsedSinceLogin().contains(item) + ) { + if(item.getAction() == Action.SHIELD) { + long cooldownUntil = player.getMomentaryAccessoryLastUsedAt(item) + (long)((item.getFiringDuration() + item.getFiringInterval()) * 1000); + player.blockUntil(cooldownUntil, String.format("You can't use your %s yet!", item.getTitle())); + return; + } + } + // Send item use data to other players in the zone if no details are present player.sendMessageToTrackers(new EntityItemUseMessage(player.getId(), type, item, status)); } diff --git a/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java b/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java index 11dc7067..a4d2e6d8 100644 --- a/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java @@ -3,12 +3,14 @@ import static brainwine.shared.LogMarkers.SERVER_MARKER; import java.net.URL; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.Map.Entry; +import brainwine.gameserver.server.models.PlayerStat; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -36,7 +38,10 @@ public static void loadShopData() { logger.info(SERVER_MARKER, "Loading shop data ..."); sections.clear(); products.clear(); - + + // Clear out default shop config + Map gameConfig = GameConfiguration.getBaseConfig(); + try { URL url = ResourceFinder.getResourceUrl("shop.json"); Map data = JsonHelper.readValue(url, new TypeReference>(){}); @@ -53,7 +58,7 @@ public static void loadShopData() { Map data = JsonHelper.readValue(entry.getValue(), new TypeReference>(){}); data.put("key", entry.getKey()); data.put("items", data.remove("products")); - MapHelper.appendList(GameConfiguration.getBaseConfig(), "shop.sections", data); + MapHelper.appendList(gameConfig, "shop.sections", data); } // Create product data @@ -84,14 +89,13 @@ public static void loadShopData() { data.put("image", image.getBaseSprite()); } - MapHelper.appendList(GameConfiguration.getBaseConfig(), "shop.items", data); + MapHelper.appendList(gameConfig, "shop.items", data); } } catch(Exception e) { logger.error(SERVER_MARKER, "An error occured while converting shop data", e); products.clear(); // Clear products so purchases can't be made - return; } - + logger.info(SERVER_MARKER, "Successfully loaded {} product{}", products.size(), products.size() == 1 ? "" : "s"); } @@ -118,6 +122,7 @@ public static boolean purchaseProduct(Player player, Product product) { player.setCrowns(player.getCrowns() - product.getCost()); product.purchase(player); + player.getStatistics().trackCrownsSpent(product.getCost()); return true; } diff --git a/gameserver/src/main/java/brainwine/gameserver/shop/ZoneProduct.java b/gameserver/src/main/java/brainwine/gameserver/shop/ZoneProduct.java index e9a4eee7..bcf6a630 100644 --- a/gameserver/src/main/java/brainwine/gameserver/shop/ZoneProduct.java +++ b/gameserver/src/main/java/brainwine/gameserver/shop/ZoneProduct.java @@ -1,5 +1,6 @@ package brainwine.gameserver.shop; +import brainwine.gameserver.zone.ZoneRules; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -46,6 +47,7 @@ public void purchase(Player player) { zone.setOwner(player); zone.setPrivate(true); zone.setProtected(true); + zone.setRules(ZoneRules.getPrivateDefaults()); GameServer.getInstance().getZoneManager().addZone(zone); // Ask player if they want to travel to their newly purchased world diff --git a/gameserver/src/main/java/brainwine/gameserver/util/Cidr.java b/gameserver/src/main/java/brainwine/gameserver/util/Cidr.java new file mode 100644 index 00000000..41bdbf72 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/Cidr.java @@ -0,0 +1,14 @@ +package brainwine.gameserver.util; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public abstract class Cidr { + + @JsonCreator + public static Cidr create(String string) { + return new IpV4Cidr(string); + } + + public abstract boolean matches(Cidr other); + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/IpV4Cidr.java b/gameserver/src/main/java/brainwine/gameserver/util/IpV4Cidr.java new file mode 100644 index 00000000..1b7e8f3d --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/IpV4Cidr.java @@ -0,0 +1,74 @@ +package brainwine.gameserver.util; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Objects; + +public class IpV4Cidr extends Cidr { + private final int ip; + private final int maskBits; + + @JsonCreator + public IpV4Cidr(String string) throws IllegalArgumentException { + if(string == null || string.isEmpty()) { + throw new IllegalArgumentException("Null or empty."); + } + + String[] cidrParts = string.split("/"); + String[] ipParts = cidrParts[0].split("\\."); + + if(cidrParts.length != 1 && cidrParts.length != 2) { + throw new IllegalArgumentException("Not 1 or 2 parts in the CIDR notation"); + } + + if(ipParts.length != 4) { + throw new IllegalArgumentException("Not 4 parts for the IP address"); + } + + try { + int a = Integer.parseInt(ipParts[0]); + int b = Integer.parseInt(ipParts[1]); + int c = Integer.parseInt(ipParts[2]); + int d = Integer.parseInt(ipParts[3]); + ip = a << 24 | b << 16 | c << 8 | d; + if(cidrParts.length == 2) { + maskBits = Integer.parseInt(cidrParts[1]); + } else { + maskBits = 32; + } + } catch(NumberFormatException e) { + throw new IllegalArgumentException("Malformed number component"); + } + } + + @Override + public boolean matches(Cidr obj) { + if(obj instanceof IpV4Cidr) { + IpV4Cidr other = (IpV4Cidr)obj; + int mask = -1 << Math.min(this.maskBits, other.maskBits); + return (this.ip & mask) == (other.ip & mask); + } + return false; + } + + @Override + public boolean equals(Object other) { + return other instanceof IpV4Cidr && ((IpV4Cidr) other).ip == ip && ((IpV4Cidr)other).maskBits == maskBits; + } + + @Override + public int hashCode() { + return Objects.hash(ip, maskBits); + } + + @Override + @JsonValue + public String toString() { + String result = ((ip >>> 24) & 255) + "." + ((ip >>> 16) & 255) + "." + ((ip >>> 8) & 255) + "." + (ip & 255); + if(maskBits != 32) { + result += "/" + maskBits; + } + return result; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/PickRandom.java b/gameserver/src/main/java/brainwine/gameserver/util/PickRandom.java new file mode 100644 index 00000000..354bdaa5 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/PickRandom.java @@ -0,0 +1,55 @@ +package brainwine.gameserver.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +public class PickRandom { + + /**Draw sampleSize random items from the given array. + * + * @param type of elements + * @param arr the array + * @param sampleSize the number of items to return + * @returns a new array + */ + public static List sampleWithoutReplacement(List arr, int sampleSize) { + return sampleWithoutReplacement(ThreadLocalRandom.current(), arr, sampleSize); + } + + /**Draw sampleSize random items from the given array and the random number generator. + * + * @param type of elements + * @param random Random instance to roll dice with + * @param arr the array + * @param sampleSize the number of items to return + * @returns a new array + */ + public static List sampleWithoutReplacement(Random random, List arr, int sampleSize) { + if(arr.size() <= sampleSize) { + return new ArrayList<>(arr); + } + + int selected = 0; + int dealtWith = 0; + + List result = new ArrayList<>(sampleSize); + + while(selected < sampleSize) + { + double uniform = random.nextDouble(); + + if(uniform * (arr.size() - dealtWith) >= sampleSize - selected) { + dealtWith++; + } else { + result.add(arr.get(dealtWith)); + selected++; + dealtWith++; + } + } + + return result; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/RuleRecord.java b/gameserver/src/main/java/brainwine/gameserver/util/RuleRecord.java new file mode 100644 index 00000000..60865027 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/RuleRecord.java @@ -0,0 +1,180 @@ +package brainwine.gameserver.util; + +import brainwine.gameserver.command.CommandExecutor; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.HashMap; +import java.util.Map; + +/**A class that defines a set of rules that can be set by the player. + * Class fields annotated with the Rule annotation should not be final. + */ +public class RuleRecord { + private static class SetRuleException extends Exception { + public SetRuleException(String reason) { + super(reason); + } + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + protected @interface Rule { + String value(); + boolean adminOnly() default false; + int minValue() default Integer.MIN_VALUE; + int maxValue() default Integer.MAX_VALUE; + } + + private Map getAccessibleRules(CommandExecutor executor) { + Map result = new HashMap<>(); + for(Field f : getClass().getDeclaredFields()) { + Rule rule = f.getAnnotation(Rule.class); + if(rule == null) continue; + if(rule.adminOnly() && !executor.isAdmin()) continue; + f.setAccessible(true); + result.put(rule.value(), f); + } + + return result; + } + + @JsonIgnore + public List getRules(CommandExecutor executor) { + Map accessibleRules = getAccessibleRules(executor); + + List result = new ArrayList<>(accessibleRules.size()); + for(String key : accessibleRules.keySet()) { + try { + result.add(ruleToString(accessibleRules.get(key))); + } catch (IllegalAccessException e) { + e.printStackTrace(); + result.add("Error while processing rule " + key); + } + } + + return result; + } + + public String getRule(CommandExecutor executor, String key) { + Map accessibleRules = getAccessibleRules(executor); + Field field = accessibleRules.get(key); + + if(field == null) return "Rule " + key + " not found or you don't have access to it"; + + try { + return ruleToString(field); + } catch (IllegalAccessException e) { + e.printStackTrace(); + return "Error while processing rule " + key; + } + } + + private String ruleToString(Field field) throws IllegalAccessException { + Rule rule = field.getAnnotation(Rule.class); + if(rule == null) throw new IllegalAccessException("Rule annotation is missing."); + String current = rule.value(); + + current += " " + field.get(this); + + List notes = new ArrayList<>(); + if(rule.minValue() != Integer.MIN_VALUE) notes.add("min " + rule.minValue()); + if(rule.minValue() != Integer.MIN_VALUE) notes.add("max " + rule.maxValue()); + + if(!notes.isEmpty()) current += " (" + String.join(", ", notes) + ")"; + return current; + } + + public String setRule(CommandExecutor executor, String key, String value) { + if(key == null) { + return "Rule key is null"; + } + + final String lower = key.toLowerCase(); + try { + Map accessibleRules = getAccessibleRules(executor); + + Field field = accessibleRules.get(key); + + if(field == null) { + return "Rule " + key + " not found or you don't have access to it"; + } + + Rule rule = field.getAnnotation(Rule.class); + + if(Boolean.TYPE.equals(field.getType())) { + field.setBoolean(this, parseBoolean(value)); + return null; + } + + if(Integer.TYPE.equals(field.getType())) { + field.set(this, parseInt(value, rule)); + return null; + } + + if(String.class.equals(field.getType())) { + field.set(this, parseString(value, rule)); + return null; + } + + return "Rule type not matched"; + } catch (SetRuleException e) { + return "Invalid argument: " + e.getMessage(); + } catch (Exception e) { + e.printStackTrace(); + return "An unexpected error occurred"; + } + } + + private int parseInt(String str, Rule rule) throws SetRuleException { + try { + int value = Integer.parseInt(str); + + if(value < rule.minValue()) { + throw new SetRuleException("Value must be at least " + rule.minValue()); + } + + if(value > rule.maxValue()) { + throw new SetRuleException("Value must be at most " + rule.maxValue()); + } + + return value; + } catch(NumberFormatException e) { + throw new SetRuleException(e.getMessage()); + } + } + + private boolean parseBoolean(String str) throws SetRuleException { + switch(str.toLowerCase()) { + case "yes": + case "true": + case "on": + return true; + case "no": + case "false": + case "off": + return false; + default: + throw new SetRuleException(str + " is not a Boolean"); + } + } + + private String parseString(String str, Rule rule) throws SetRuleException { + int len = str.length(); + if(len < rule.minValue()) { + throw new SetRuleException("Value must be at least " + rule.minValue() + " characters long."); + } + + if(len > rule.maxValue()) { + throw new SetRuleException("Value must be at most " + rule.maxValue() + " characters long."); + } + + return str; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/ValueWithExpiry.java b/gameserver/src/main/java/brainwine/gameserver/util/ValueWithExpiry.java new file mode 100644 index 00000000..e8a5d6d8 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/ValueWithExpiry.java @@ -0,0 +1,87 @@ +package brainwine.gameserver.util; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Calendar; + +public class ValueWithExpiry { + @JsonProperty("expires_at") + private Calendar expiresAt; + @JsonProperty("value") + private T value; + + private Calendar now() { + return Calendar.getInstance(); + } + + public ValueWithExpiry() {} + + /**Make the value expire within the specified time. This uses the system clock to compute the expiration date. + * + * @param value wrapped value + * @param duration duration to be parsed with {@link DateTimeUtils#parseFormattedDuration(String)} + */ + public ValueWithExpiry(T value, String duration) { + this.expiresAt = now(); + this.expiresAt.add(Calendar.MINUTE, DateTimeUtils.parseFormattedDuration(duration)); + this.value = value; + } + + /**Make the value expired at the specified time. + * + * @param value wrapped value + * @param expiresAt duration + */ + public ValueWithExpiry(T value, Calendar expiresAt) { + this.expiresAt = expiresAt; + this.value = value; + } + + /**Get a ValueWithExpiry that is guaranteed to be expired. Do not use the contained value in the returned instance. + * + * @return an expired ValueWithExpiry instance + * @param type of the contained value. Assume the value to be null. + */ + public static ValueWithExpiry getExpired() { + ValueWithExpiry value = new ValueWithExpiry<>(); + value.expiresAt = null; + value.value = null; + return value; + } + + /**Check if the value is expired according to the system clock + * + * @return whether the value is expired. Note that it will return true also if the expiration date is null. + */ + @JsonIgnore + public boolean isExpired() { + return isExpired(Calendar.getInstance()); + } + + /**Check if the value is expired according to some assumed current time + * + * @param now the assumed current time + * @return whether the value is expired. Note that it will return true also if the expiration date is null. + */ + public boolean isExpired(Calendar now) { + return expiresAt == null || expiresAt.before(now); + } + + /**Return the time until expiry in milliseconds + * + * @param currentTime the time since epoch + * @return + */ + public long getTimeUntilExpiry(long currentTime) { + return expiresAt.getTimeInMillis() - currentTime; + } + + /**Return the wrapped value. This doesn't check if the value is expired + * + * @return + */ + public T getValue() { + return value; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/WeightedMap.java b/gameserver/src/main/java/brainwine/gameserver/util/WeightedMap.java index b99bc808..ff804769 100644 --- a/gameserver/src/main/java/brainwine/gameserver/util/WeightedMap.java +++ b/gameserver/src/main/java/brainwine/gameserver/util/WeightedMap.java @@ -1,8 +1,10 @@ package brainwine.gameserver.util; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Random; @@ -86,6 +88,22 @@ public T next(Random random, T def) { return def; } + + public List nextN(Random random, int n) { + if(entries.isEmpty()) return null; + + List choices = new ArrayList<>(n); + for(int i = 0; i < n; i++) { + T choice = next(random); + choices.add(choice); + double oldWeight = entries.get(choice); + double newWeight = oldWeight * oldWeight / totalWeight; + totalWeight += newWeight - oldWeight; + entries.put(choice, newWeight); + } + + return choices; + } @JsonValue public Map getEntries() { diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/Arbitrary.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/Arbitrary.java new file mode 100644 index 00000000..900237b5 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/Arbitrary.java @@ -0,0 +1,13 @@ +package brainwine.gameserver.util.randomobject; + +import java.util.Random; + +import io.netty.util.internal.ThreadLocalRandom; + +public interface Arbitrary { + T next(Random random) throws ConcretionFailureException; + + default T next() throws ConcretionFailureException { + return next(ThreadLocalRandom.current()); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConcatList.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConcatList.java new file mode 100644 index 00000000..2208e4bf --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConcatList.java @@ -0,0 +1,33 @@ +package brainwine.gameserver.util.randomobject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +public class ConcatList extends RandomList { + private List> concat; + + public ConcatList(List> concat) { + this.concat = concat; + } + + @Override + public List next(Random random) throws ConcretionFailureException { + List result = new ArrayList<>(); + + if(concat == null) return result; + + for(RandomList l : concat) { + result.addAll(l.next(random)); + } + + return result; + } + + @Override + public String toString() { + return "ConcatList(" + concat.stream().map(r -> r.toString()).collect(Collectors.joining(", ")) + ")"; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConcretionFailureException.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConcretionFailureException.java new file mode 100644 index 00000000..8a19ab9f --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConcretionFailureException.java @@ -0,0 +1,8 @@ +package brainwine.gameserver.util.randomobject; + +public class ConcretionFailureException extends Exception { + public static final long serialVersionUID = 4235367787L; + + public ConcretionFailureException() { super(); } + public ConcretionFailureException(String message) { super(message); } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConstantList.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConstantList.java new file mode 100644 index 00000000..9c5e1f8b --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ConstantList.java @@ -0,0 +1,36 @@ +package brainwine.gameserver.util.randomobject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +public class ConstantList extends RandomList { + private List list; + + public ConstantList() { + this.list = new ArrayList<>(); + } + + public ConstantList(List list) { + this.list = list; + } + + @Override + public List next(Random random) { + return list; + } + + public int size() { + return list.size(); + } + + public List getList() { + return list; + } + + @Override + public String toString() { + return "ConstantList(" + list.stream().map(r -> r.toString()).collect(Collectors.joining(", ")) + ")"; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ObjectMapperProvider.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ObjectMapperProvider.java new file mode 100644 index 00000000..452cc3e4 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/ObjectMapperProvider.java @@ -0,0 +1,7 @@ +package brainwine.gameserver.util.randomobject; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public interface ObjectMapperProvider { + ObjectMapper get(); +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomInteger.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomInteger.java new file mode 100644 index 00000000..390c4767 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomInteger.java @@ -0,0 +1,54 @@ +package brainwine.gameserver.util.randomobject; + +import java.util.List; +import java.util.Random; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonMappingException; + +public class RandomInteger implements Arbitrary { + private boolean isConcrete = false; + private Integer value = null; + private List randomBetween = null; + + public RandomInteger() {} + + @JsonCreator + public RandomInteger(Integer value) { + this.value = value; + this.isConcrete = true; + } + + @JsonCreator + public RandomInteger(@JsonProperty("random_between") List randomBetween ) throws JsonMappingException { + if(!(randomBetween.size() == 2 || randomBetween.size() == 3)) { + throw new JsonMappingException("Invalid input for random_between: provide either 2 or 3 constant numbers"); + } + + if(randomBetween.get(0) > randomBetween.get(1)) { + throw new JsonMappingException("Invalid input for random_between: range min is greater than range max"); + } + + if(randomBetween.size() == 3 && randomBetween.get(2) <= 0) { + throw new JsonMappingException("Invalid input for random_between: only positive step values are allowed"); + } + this.randomBetween = randomBetween; + } + + @Override + public Integer next(Random random) { + if(this.isConcrete) { + return value; + } else { + int step = 1; + if(randomBetween.size() == 3) { + step = randomBetween.get(2); + } + int lowerBound = randomBetween.get(0) / step; + int upperBound = randomBetween.get(1) / step + 1; + return step * random.nextInt(upperBound - lowerBound) + lowerBound; + } + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomList.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomList.java new file mode 100644 index 00000000..65bb1c84 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomList.java @@ -0,0 +1,8 @@ +package brainwine.gameserver.util.randomobject; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(using = RandomListDeserializer.class) +public abstract class RandomList implements Arbitrary> {} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListDeserializer.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListDeserializer.java new file mode 100644 index 00000000..6322bfeb --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListDeserializer.java @@ -0,0 +1,97 @@ +package brainwine.gameserver.util.randomobject; + +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.List; + +public class RandomListDeserializer extends StdDeserializer> implements ContextualDeserializer { + private Class itemType; + private ObjectMapper mapper; + + public RandomListDeserializer() { + this(null, null); + } + + public RandomListDeserializer(Class itemType, ObjectMapper mapper) { + super(RandomList.class); + this.itemType = itemType; + this.mapper = mapper; + } + + @Override + public RandomList deserialize(JsonParser parser, DeserializationContext context) throws IOException, JsonProcessingException + { + JsonNode node = parser.readValueAsTree(); + + return deserialize(node, context); + } + + private RandomList deserialize(JsonNode node, DeserializationContext context) throws IOException { + if(node.isObject()) { + if(node.hasNonNull("choices")) { + RandomList choices = deserialize(node.get("choices"), context); + if(node.has("pick")) { + RandomInteger pick = mapper.readValue(mapper.treeAsTokens(node.get("pick")), RandomInteger.class); + return new RandomPickList<>(choices, pick); + } else { + return new RandomPickList<>(choices); + } + + } + + if(node.hasNonNull("concat")) { + List> concatList = new ArrayList<>(); + JsonNode concatNode = node.get("concat"); + for(int i = 0; i < concatNode.size(); i++) { + JsonNode current = concatNode.get(i); + concatList.add(deserialize(current, context)); + } + return new ConcatList<>(concatList); + } + } + + if(node.isArray()) { + List arr = new ArrayList<>(); + for(int i = 0; i < node.size(); i++) { + JsonNode current = node.get(i); + arr.add(mapper.readValue(mapper.treeAsTokens(current), itemType)); + } + return new ConstantList<>(arr); + } + + return null; + } + + /** Standard procedure to resolve some annotation. */ + private T getAnnotation(BeanProperty property, Class clazz) throws Exception { + T value = property.getMember().getAnnotation(clazz); + if(value == null) value = property.getMember().getDeclaringClass().getAnnotation(clazz); + if(value == null) throw new Exception(clazz.getName() + " is missing!"); + + return value; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext context, BeanProperty property) { + try { + if(property == null) { + throw new Exception("BeanProperty is missing!"); + } + + RandomListItemType itemType = getAnnotation(property, RandomListItemType.class); + + return new RandomListDeserializer<>(itemType.value(), JsonHelper.MAPPER); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListItemType.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListItemType.java new file mode 100644 index 00000000..506ca510 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListItemType.java @@ -0,0 +1,12 @@ +package brainwine.gameserver.util.randomobject; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.TYPE}) +public @interface RandomListItemType { + Class value() default Object.class; +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListObjectMapperProvider.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListObjectMapperProvider.java new file mode 100644 index 00000000..3768d4ac --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomListObjectMapperProvider.java @@ -0,0 +1,12 @@ +package brainwine.gameserver.util.randomobject; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.TYPE}) +public @interface RandomListObjectMapperProvider { + Class value(); +} diff --git a/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomPickList.java b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomPickList.java new file mode 100644 index 00000000..f263218f --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/util/randomobject/RandomPickList.java @@ -0,0 +1,34 @@ +package brainwine.gameserver.util.randomobject; + +import java.util.List; +import java.util.Random; + +import brainwine.gameserver.util.PickRandom; + +public class RandomPickList extends RandomList { + RandomList choices; + RandomInteger pick; + + public RandomPickList(RandomList choices) { + this.choices = choices; + this.pick = new RandomInteger(1); + } + + public RandomPickList(RandomList choices, RandomInteger pick) { + this.choices = choices; + this.pick = pick; + } + + @Override + public List next(Random random) throws ConcretionFailureException { + List list = choices.next(random); + int count = pick.next(random); + return PickRandom.sampleWithoutReplacement(random, list, count); + } + + @Override + public String toString() { + return "RandomPickList(" + pick + " of " + choices + ")"; + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Chunk.java b/gameserver/src/main/java/brainwine/gameserver/zone/Chunk.java index e594e62d..2f6f1877 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Chunk.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Chunk.java @@ -16,6 +16,7 @@ public class Chunk { private final int width; private final int height; private final Block[] blocks; + private long loadTime; private long saveTime; private boolean modified; @@ -84,6 +85,15 @@ private boolean isIndexInBounds(int index) { public Block[] getBlocks() { return blocks; } + + public void setLoadTime(long loadTime) { + this.loadTime = loadTime; + } + + @JsonIgnore + public long getLoadTime() { + return loadTime; + } public void setSaveTime(long saveTime) { this.saveTime = saveTime; diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ChunkManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/ChunkManager.java index ff0fa07d..0868d68d 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/ChunkManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/ChunkManager.java @@ -199,7 +199,7 @@ private Chunk loadChunk(int index) { chunk.setSaveTime(saveTime); return chunk; } catch(Exception e) { - logger.error(SERVER_MARKER, "Could not load chunk {} of zone {}", index, zone.getDocumentId(), e); + // logger.error(SERVER_MARKER, "Could not load chunk {} of zone {}", index, zone.getDocumentId(), e); } return null; diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/DungeonType.java b/gameserver/src/main/java/brainwine/gameserver/zone/DungeonType.java new file mode 100644 index 00000000..b517af8b --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/DungeonType.java @@ -0,0 +1,64 @@ +package brainwine.gameserver.zone; + +// Order of the items is important. The dungeon indexer will take the largest ordinal enum value determined. +public enum DungeonType { + PUZZLE( + "You raided a dungeon!", + "%s raided a dungeon.", + "This container is secured by protectors in the area.", + 100 + ), + EVOKER( + "You inhibited an evoker!", + "%s inhibited an evoker.", + "This container is secured by evokers in the area.", + 500 + ), + ; + private final String selfRaidMessage; + private final String peerRaidMessage; + private final String containerProtectedMessage; + private final int xpReward; + + DungeonType(String selfRaidMessage, String peerRaidMessage, String containerProtectedMessage, int xpReward) { + this.selfRaidMessage = selfRaidMessage; + this.peerRaidMessage = peerRaidMessage; + this.containerProtectedMessage = containerProtectedMessage; + this.xpReward = xpReward; + } + + public String getSelfRaidMessage() { + return selfRaidMessage; + } + + public String getPeerRaidMessage() { + return peerRaidMessage; + } + + public String getContainerProtectedMessage() { + return containerProtectedMessage; + } + + public int getXpReward() { + return xpReward; + } + + public static DungeonType morePrior(DungeonType a, DungeonType b) { + if(a == null) a = DungeonType.PUZZLE; + if(b == null) b = DungeonType.PUZZLE; + return b.ordinal() > a.ordinal() ? b : a; + } + + public static DungeonType fromMetaBlock(MetaBlock metaBlock) { + DungeonType dungeonType; + switch(metaBlock.getItem().getId()) { + case "mechanical/spawner-brain": + dungeonType = DungeonType.EVOKER; + break; + default: + dungeonType = DungeonType.PUZZLE; + } + + return dungeonType; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/DynamicsManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/DynamicsManager.java new file mode 100644 index 00000000..97be2de0 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/DynamicsManager.java @@ -0,0 +1,64 @@ +package brainwine.gameserver.zone; + +import brainwine.gameserver.zone.dynamics.ZoneDynamic; + +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class DynamicsManager { + Zone zone; + Map, Deque> ongoingDynamics = new HashMap<>(); + + public DynamicsManager(Zone zone) { + this.zone = zone; + } + + public boolean hasOngoingDynamic(Class dynamicType) { + Deque val = ongoingDynamics.get(dynamicType); + return val != null && !val.isEmpty(); + } + + public void beginDynamic(ZoneDynamic dynamic) { + Deque dynamicsOfType = ongoingDynamics.getOrDefault(dynamic.getClass(), new LinkedList<>()); + int maxInstances = dynamic.maxInstances(); + + if(maxInstances > 0) while(dynamicsOfType.size() >= maxInstances) { + dynamicsOfType.removeFirst().close(); + } + + dynamicsOfType.addLast(dynamic); + ongoingDynamics.put(dynamic.getClass(), dynamicsOfType); + } + + public void tick(float deltaTime) { + List> keysToDelete = new ArrayList<>(); + + for(Map.Entry, Deque> pair : ongoingDynamics.entrySet()) { + Deque dynamics = pair.getValue(); + List finished = new ArrayList<>(); + for(ZoneDynamic dynamic : pair.getValue()) { + dynamic.tick(deltaTime); + if(dynamic.isFinished()) { + dynamic.close(); + finished.add(dynamic); + } + } + dynamics.removeAll(finished); + if(dynamics.isEmpty()) { + keysToDelete.add(pair.getKey()); + } + } + + for(Class key : keysToDelete) { + ongoingDynamics.remove(key); + } + } + + public Deque getOngoingDynamics(Class clazz) { + return ongoingDynamics.getOrDefault(clazz, new LinkedList<>()); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java index 82ae7fc9..7e61b744 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java @@ -8,13 +8,23 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Map.Entry; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; +import brainwine.gameserver.anticheat.AfkEntitySpawn; +import brainwine.gameserver.anticheat.AnticheatManager; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.quest.QuestEvents; +import brainwine.gameserver.server.messages.EventMessage; +import brainwine.gameserver.server.messages.NotificationMessage; +import brainwine.gameserver.util.MathUtils; +import brainwine.gameserver.zone.dynamics.EvokerInvasion; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -44,7 +54,8 @@ public class EntityManager { public static final long SPAWN_INTERVAL = 200; private static final Logger logger = LogManager.getLogger(); private static final ThreadLocalRandom random = ThreadLocalRandom.current(); - private static final Map> spawns = new HashMap<>(); + private static final Map> globalSpawns = new HashMap<>(); + private final List spawns = new ArrayList<>(); private final Map entities = new ConcurrentHashMap<>(); // TODO is there a better solution? private final Map npcs = new HashMap<>(); private final Map mountedNpcs = new HashMap<>(); @@ -52,38 +63,88 @@ public class EntityManager { private final Map playersByName = new HashMap<>(); private final Zone zone; private int entityDiscriminator; + private int currentMaxPlayers; private long lastSpawnAt = System.currentTimeMillis(); + private long lastInvasionAt; + private long timeUntilNextInvasion = 10000; + private long lastInhibitionTime = System.currentTimeMillis(); + private long lastRevenantDishCheckTime = System.currentTimeMillis(); public EntityManager(Zone zone) { this.zone = zone; } public static void loadEntitySpawns() { - spawns.clear(); + globalSpawns.clear(); logger.info(SERVER_MARKER, "Loading entity spawns ..."); try { URL url = ResourceFinder.getResourceUrl("spawning.json", true); - spawns.putAll(JsonHelper.readValue(url, new TypeReference>>(){})); + Map> loaded = JsonHelper.readValue(url, new TypeReference>>(){}); + + // Validate all entity spawns. + for(Map.Entry> entry : loaded.entrySet()) { + List validSpawns = new ArrayList<>(entry.getValue().size()); + for(EntitySpawn spawn : entry.getValue()) { + if(spawn.getEntityConfig() != null) { + validSpawns.add(spawn); + } else { + logger.warn("Entity " + spawn.getEntity() + " not found for " + entry.getKey() + " spawns."); + } + } + globalSpawns.put(entry.getKey(), validSpawns); + } + } catch (IOException e) { logger.error(SERVER_MARKER, "Failed to load entity spawns", e); } } - - private static List getEligibleEntitySpawns(Biome biome, String locale, double depth, double acidity, Item baseItem) { - return spawns.entrySet().stream() - .filter(entry -> entry.getKey() == biome) - .map(Entry::getValue) - .flatMap(Collection::stream) + + public void updateSpawnRates() { + if(spawns.isEmpty()) { + try { + spawns.addAll( + JsonHelper.readValue( + JsonHelper.writeValueAsString(globalSpawns.get(zone.getBiome())), + new TypeReference>() {} + ) + ); + } catch(Exception e) { + throw new RuntimeException("Cannot initialize individual zone entity spawns.", e); + } + } + + int difficulty = zone.getMassSpawnerConfiguration() != null + ? zone.getMassSpawnerConfiguration().getDifficulty() + : 3; + + for(EntitySpawn spawn : spawns) { + spawn.resetFrequency(); + + boolean isFriendly = spawn.getEntityConfig().isFriendly(); + boolean isHostile = !isFriendly; + + if(difficulty == 1 && isHostile) spawn.setFrequency(0.0); + if(difficulty == 2 && isFriendly) spawn.setFrequency(2.0 * spawn.getFrequency()); + if(difficulty == 4 && isHostile) spawn.setFrequency(2.0 * spawn.getFrequency()); + if(difficulty == 5 && isHostile) spawn.setFrequency(3.0 * spawn.getFrequency()); + } + } + + private List getEligibleEntitySpawns(Biome biome, String locale, double depth, double acidity, Item baseItem, ZoneRules rules) { + return spawns.stream() .filter(spawn -> locale.equalsIgnoreCase(spawn.getLocale()) && depth >= spawn.getMinDepth() && depth <= spawn.getMaxDepth() - && acidity >= spawn.getMinAcidity() && acidity <= spawn.getMaxAcidity() + && ( + (rules.isHostileEntitySpawnsEnabled() || acidity >= spawn.getMinAcidity()) && + (rules.isPeacefulEntitySpawnsEnabled() || acidity <= spawn.getMaxAcidity()) + ) && ((!baseItem.hasId("base/maw") && !baseItem.hasId("base/pipe")) || spawn.getOrifice() == baseItem)) .collect(Collectors.toList()); } - private static EntitySpawn getRandomEligibleEntitySpawn(Biome biome, String locale, double depth, double acidity, Item baseItem) { - return new WeightedMap<>(getEligibleEntitySpawns(biome, locale, depth, acidity, baseItem), EntitySpawn::getFrequency).next(); + private EntitySpawn getRandomEligibleEntitySpawn(Biome biome, String locale, double depth, double acidity, Item baseItem, ZoneRules rules) { + return new WeightedMap<>(getEligibleEntitySpawns(biome, locale, depth, acidity, baseItem, rules), EntitySpawn::getFrequency).next(); } public void tick(float deltaTime) { @@ -102,6 +163,27 @@ public void tick(float deltaTime) { spawnRandomEntity(); lastSpawnAt = now; } + + // Blow up powered inhibitors if there are players in the world + if(System.currentTimeMillis() > lastInhibitionTime + 1000) { + if(!players.isEmpty()) processInhibitors(); + lastInhibitionTime = System.currentTimeMillis(); + } + + // Process active evokers if there are players in the world + if(System.currentTimeMillis() > lastInvasionAt + timeUntilNextInvasion) { + if(!zone.getPlayers().isEmpty()) { + tryEvoking(); + } + timeUntilNextInvasion = Math.max(10000 / Math.max(zone.getPlayers().size(), 1), 20000); + lastInvasionAt = System.currentTimeMillis(); + } + + // Check if all guards are still in the vicinity of the revenant dishes + if(System.currentTimeMillis() > lastRevenantDishCheckTime + 5000) { + checkGuardians(); + lastRevenantDishCheckTime = System.currentTimeMillis(); + } } private void spawnRandomEntity() { @@ -109,18 +191,22 @@ private void spawnRandomEntity() { List visibleChunks = zone.getVisibleChunks(); List chunks = immediate ? visibleChunks : zone.getLoadedChunks().stream() .filter(chunk -> !visibleChunks.contains(chunk)).collect(Collectors.toList()); - + + boolean isNotConfigured = zone.getMassSpawnerConfiguration() == null || !zone.getMassSpawnerConfiguration().isEnabled(); + boolean doMaws = isNotConfigured || zone.getMassSpawnerConfiguration().isMawSpawningEnabled(); + boolean doAreas = isNotConfigured || zone.getMassSpawnerConfiguration().isAreaSpawningEnabled(); + if(!chunks.isEmpty()) { List eligiblePositions = new ArrayList<>(); Chunk chunk = chunks.get(random.nextInt(chunks.size())); - - for(int x = chunk.getX(); x < chunk.getX() + chunk.getWidth(); x++) { + + for(int x = chunk.getX(); x < chunk.getX() + chunk.getWidth(); x++) { for(int y = chunk.getY(); y < chunk.getY() + chunk.getHeight(); y++) { Block block = chunk.getBlock(x, y); Item baseItem = block.getBaseItem(); - if((immediate && baseItem.hasId("base/maw") || baseItem.hasId("base/pipe")) || - (!immediate && block.getBackItem().isAir() && block.getFrontItem().isAir())) { + if((immediate && doMaws && (baseItem.hasId("base/maw") || baseItem.hasId("base/pipe"))) || + (!immediate && doAreas && block.getBackItem().isAir() && block.getFrontItem().isAir())) { eligiblePositions.add(new Vector2i(x, y)); } } @@ -133,7 +219,7 @@ private void spawnRandomEntity() { Block block = chunk.getBlock(x, y); String locale = block.getBaseItem().isAir() ? "sky" : "cave"; EntitySpawn spawn = getRandomEligibleEntitySpawn( - zone.getBiome(), locale, y / (double)zone.getHeight(), zone.getAcidity(), block.getBaseItem()); + zone.getBiome(), locale, y / (double)zone.getHeight(), zone.getAcidity(), block.getBaseItem(), zone.getRules()); if(immediate) { if(tryBustOrifice(x, y, Layer.BACK) || tryBustOrifice(x, y, Layer.FRONT)) { @@ -142,7 +228,13 @@ private void spawnRandomEntity() { } if(spawn != null) { - EntityConfig config = spawn.getEntity(); + EntityConfig config; + AfkEntitySpawn afkEntitySpawn = AnticheatManager.getConfig().getAfkEntitySpawn(); + if(afkEntitySpawn.isEnabled() && chunk.getLoadTime() + afkEntitySpawn.getDuration() < System.currentTimeMillis()) { + config = spawn.getAfkEntityConfig(); + } else { + config = spawn.getEntityConfig(); + } if(config != null) { spawnEntity(new Npc(zone, config), x, y); @@ -200,7 +292,7 @@ public void trySpawnBlockEntity(int x, int y) { int index = zone.getBlockIndex(x, y); // Check for guardian entity - if(item.getGuardLevel() > 0) { + if(item.getGuardLevel() > 0 || item.hasUse(ItemUseType.REVENANT_DISH)) { MetaBlock metaBlock = zone.getMetaBlock(x, y); if(metaBlock != null) { @@ -241,7 +333,122 @@ public void trySpawnBlockEntity(int x, int y) { } } } - + + public void checkGuardians() { + Map> needs = new HashMap<>(); + // TODO maybe also include enemy protectors + List dishes = zone.getMetaBlocksWithUse(ItemUseType.REVENANT_DISH); + + // Add the needed guard counts to each dish's hash map + for(MetaBlock dish : dishes) { + List guards = MapHelper.getList(dish.getMetadata(), "!"); + if(guards == null) continue; + Map dishCounts = new HashMap<>(); + guards.forEach(name -> dishCounts.merge(name, 1, Integer::sum)); + + needs.put(zone.getBlockIndex(dish.getX(), dish.getY()), dishCounts); + } + + // Remove each npc from its dish's guard counts + for(Npc npc : npcs.values()) { + if(npc.isGuard()) { + Map dishCounts = needs.get(zone.getBlockIndex(npc.getGuardBlock().getX(), npc.getGuardBlock().getY())); + if(dishCounts != null) { + if(npc.inRange(npc.getGuardBlock().getX(), npc.getGuardBlock().getY(), 38.0)) { + dishCounts.merge(npc.getConfig().getName(), -1, Integer::sum); + } else { + // Forget about entity if it has gotten too far. + npc.setGuardBlock(null); + npc.setHealth(0f); + } + } + } + } + + // Resolve the difference + for(Map.Entry> dishCountsPair : needs.entrySet()) { + int x = dishCountsPair.getKey() % zone.getWidth(); + int y = dishCountsPair.getKey() / zone.getWidth(); + for(Map.Entry guardian : dishCountsPair.getValue().entrySet()) { + // This loop won't run if the need for the entity has fallen to negative + for(int i = 0; i < guardian.getValue(); i++) { + Npc entity = spawnEntity(guardian.getKey(), x, y); + + if(entity != null) { + entity.setGuardBlock(x, y); + } + } + } + } + } + + public void updateRevenantDish(int x, int y, boolean newlyLoaded) { + MetaBlock metaBlock = zone.getMetaBlock(x, y); + if(metaBlock != null && metaBlock.getItem().hasUse(ItemUseType.REVENANT_DISH)) { + int wave; + if(!metaBlock.hasProperty("w") || !metaBlock.hasProperty("!")) { + wave = 3; + startRevenantDishWave(x, y, 3); + newlyLoaded = true; + } else { + List guards = MapHelper.getList(metaBlock.getMetadata(), "!"); + if(guards == null) { + guards = new ArrayList<>(); + newlyLoaded = true; + } + + int currentWave = metaBlock.hasProperty("!") ? metaBlock.getIntProperty("w") : 0; + if(!guards.isEmpty()) { + wave = currentWave; + } else { + wave = currentWave - 1; + } + + if(currentWave != wave) { + startRevenantDishWave(x, y, wave); + newlyLoaded = true; + } + } + + if(newlyLoaded) trySpawnBlockEntity(x, y); + + if(wave <= 0) { + zone.updateBlock(x, y, Layer.FRONT, Item.AIR); + zone.spawnEffect(x, y, "bomb-electric", 5); + } + } + } + + public void startRevenantDishWave(int x, int y, int wave) { + String type; + int count; + // Waves start from 3, go down to 1, and reach 0 which is when the infernal protector is destroyed. + if(wave == 3) { + type = "revenant"; + count = 5; + } else if(wave == 2) { + type = "dire-revenant"; + count = 3; + } else if(wave == 1) { + type = "revenant-lord"; + count = 1; + } else { + type = "terrapus/adult"; + count = 0; + } + + List guards = new ArrayList<>(); + for(int i = 0; i < count; i++) { + guards.add(type); + } + + MetaBlock metaBlock = zone.getMetaBlock(x, y); + if(metaBlock != null) { + metaBlock.setProperty("w", wave); + metaBlock.setProperty("!", guards); + } + } + public void spawnPersistentNpcs(Collection data) { for(NpcData entry : data) { if(entry.getType() == null) { @@ -250,6 +457,7 @@ public void spawnPersistentNpcs(Collection data) { Npc npc = new Npc(zone, entry.getType()); npc.setName(entry.getName()); + npc.setJob(entry.getJob()); spawnEntity(npc, entry.getX(), entry.getY()); } } @@ -297,8 +505,11 @@ public void addEntity(Entity entity) { player.onZoneEntered(); players.put(entityId, player); playersByName.put(player.getName().toLowerCase(), player); + zone.removeTemporaryAccess(player); player.sendMessageToPeers(new EntityStatusMessage(player, EntityStatus.ENTERING)); player.sendMessageToPeers(new EntityPositionMessage(player)); + player.sendMessage(new EventMessage("playerIconDidChange", player.getIconEmoji())); + currentMaxPlayers = Math.max(currentMaxPlayers, getPlayerCount()); } else if(entity instanceof Npc) { npcs.put(entityId, (Npc)entity); } @@ -329,7 +540,84 @@ public void removeEntity(Entity entity) { } } } - + + public void tryEvoking() { + Set candidateSet = new HashSet<>(); + + for(MetaBlock evoker : zone.getMetaBlocksWithItem("mechanical/spawner-brain")) { + candidateSet.addAll(zone.getPlayersInRange(evoker.getX(), evoker.getY(), 30)); + } + + List candidates = candidateSet.stream() + .filter(p -> !p.isGodMode()) + .collect(Collectors.toList()); + + if(!candidates.isEmpty()) { + startInvasion(candidates.get((int) (Math.random() * candidates.size())), zone.getMassSpawnerConfiguration().getDifficulty()); + } + } + + public void processInhibitors() { + Map evokersInhibited = new HashMap<>(); + List players = new ArrayList<>(zone.getPlayers()); + boolean inhibited = false; + for(MetaBlock evoker : zone.getMetaBlocksWithItem("mechanical/spawner-brain")) { + if(zone.isBlockPowered(evoker.getX(), evoker.getY())) { + inhibited = true; + zone.spawnEffect(evoker.getX(), evoker.getY(), "bomb-electric", 5); + zone.updateBlock(evoker.getX(), evoker.getY(), Layer.FRONT, Item.AIR); + + int minIndex = -1; + double minDistance = Double.POSITIVE_INFINITY; + for(int i = 0; i < players.size(); i++) { + double playerDistance = MathUtils.distance(players.get(i).getX(), players.get(i).getY(), evoker.getX(), evoker.getY()); + if(minDistance > playerDistance) { + minIndex = i; + minDistance = playerDistance; + } + } + + String dungeonId = evoker.getStringProperty("@"); + if(dungeonId != null) { + zone.destroyGuardBlock(dungeonId, minIndex == -1 ? null : players.get(minIndex)); + } + + if(minIndex != -1) { + Player player = players.get(minIndex); + evokersInhibited.merge(player, 1, Integer::sum); + } + } + } + + for(Map.Entry score : evokersInhibited.entrySet()) { + Player player = score.getKey(); + int count = score.getValue(); + player.getStatistics().trackEvokersInhibited(count); + QuestEvents.handleInhibit(player, count); + String suffix = count == 1 ? " inhibited an evoker!" : " inhibited " + count + " evokers!"; + player.notify("You" + suffix, NotificationType.SYSTEM); + player.notifyPeers(player.getName() + suffix, NotificationType.SYSTEM); + player.addExperience(500 * count); + } + + if(inhibited && !checkEvokers()) { + zone.sendMessage(new NotificationMessage("All evokers have been inhibited!", NotificationType.SYSTEM)); + } + } + + public boolean checkEvokers() { + return !zone.getMetaBlocksWithItem("mechanical/spawner-brain").isEmpty(); + } + + public synchronized void startInvasion(Player target, int difficulty) { + lastInvasionAt = System.currentTimeMillis(); + zone.getDynamicsManager().beginDynamic(new EvokerInvasion(zone, target, difficulty, 4)); + } + + public long getLastInvasionAt() { + return lastInvasionAt; + } + public Entity getEntity(int entityId) { return entities.get(entityId); } @@ -377,4 +665,8 @@ public int getPlayerCount() { public Collection getPlayers() { return Collections.unmodifiableCollection(players.values()); } + + public int getCurrentMaxPlayers() { + return currentMaxPlayers; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/EntitySpawn.java b/gameserver/src/main/java/brainwine/gameserver/zone/EntitySpawn.java index 8908ea81..8d6bd22e 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/EntitySpawn.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/EntitySpawn.java @@ -1,5 +1,7 @@ package brainwine.gameserver.zone; +import brainwine.gameserver.entity.EntityRegistry; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -11,7 +13,10 @@ public class EntitySpawn { @JsonProperty("entity") - private EntityConfig entity; + private String entity; + + @JsonProperty("afk") + private String afkEntity; @JsonProperty("locale") private String locale; @@ -33,8 +38,37 @@ public class EntitySpawn { @JsonProperty("frequency") private double frequency = 1; + + @JsonIgnore + private double originalFrequency = frequency; + + @JsonIgnore + private EntityConfig entityConfig = null; + + @JsonIgnore + private EntityConfig afkEntityConfig = null; - public EntityConfig getEntity() { + public EntityConfig getEntityConfig() { + if(entityConfig == null) { + entityConfig = EntityRegistry.getEntityConfig(entity); + } + + return entityConfig; + } + + public EntityConfig getAfkEntityConfig() { + if(afkEntity == null) { + return getEntityConfig(); + } + + if(afkEntityConfig == null) { + afkEntityConfig = EntityRegistry.getEntityConfig(afkEntity); + } + + return afkEntityConfig; + } + + public String getEntity() { return entity; } @@ -66,4 +100,12 @@ public double getFrequency() { return frequency; } + public void setFrequency(double frequency) { + this.frequency = frequency; + } + + public void resetFrequency() { + this.frequency = originalFrequency; + } + } diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/GrowthManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/GrowthManager.java index 1d4af2f2..84117e2e 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/GrowthManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/GrowthManager.java @@ -3,14 +3,17 @@ import static brainwine.shared.LogMarkers.SERVER_MARKER; import java.net.URL; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Set; +import brainwine.gameserver.util.MathUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,12 +31,16 @@ public class GrowthManager { public static final int MAX_RAIN_CYCLES = 500; // Maximum number of rain cycles that are permitted in a single growth update + private static final String GROW_LAMP = "lighting/grow-lamp-lit"; + private static final double GROW_LAMP_CENTER_OFFSET = 0.5; + private static final double GROW_LAMP_RANGE = 5.0; private static final Logger logger = LogManager.getLogger(); private static final Map growables = new HashMap<>(); private static final Map>> sourcesByBiome = new HashMap<>(); private final Set sourceIndices = new HashSet<>(); private final Map> sources; private final Zone zone; + private List growLamps = new ArrayList<>(); public GrowthManager(Zone zone) { this.sources = sourcesByBiome.getOrDefault(zone.getBiome(), Collections.emptyMap()); @@ -53,6 +60,82 @@ public static void loadGrowthData() { logger.error(SERVER_MARKER, "Could not load growth data", e); } } + + public boolean isReceivingLight(int x, int y) { + if(zone.getSunlight()[x] >= y) { + return true; + } else { + for(MetaBlock growLamp : growLamps) { + if(y >= growLamp.getY() && MathUtils.distance(x, y, growLamp.getX() + GROW_LAMP_CENTER_OFFSET, growLamp.getY()) < GROW_LAMP_RANGE) { + return true; + } + } + return false; + } + } + + public boolean fertilize(int x, int y) { + growLamps = zone.getMetaBlocksWithItem(GROW_LAMP); + if(!isReceivingLight(x, y)) return false; + if(!zone.areCoordinatesInBounds(x, y) || !zone.areCoordinatesInBounds(x, y + 1)) return false; + + int replaceY = -1; + int plantY = -1; + int sourceY = -1; + Item replacementItem = Item.AIR; + Item plantItem = Item.AIR; + + Item belowItem = zone.getBlock(x, y + 1).getFrontItem(); + if(belowItem.hasId("ground/earth-compost")) { + // Fertilizer placed directly on compost. + plantY = y; + sourceY = y + 1; + } else if(growables.containsKey(belowItem)) { + // Fertilizer placed onto plant. + if(!zone.areCoordinatesInBounds(x, y + 2)) return false; + plantItem = belowItem; + plantY = y + 1; + replacementItem = growables.containsKey(plantItem) ? growables.get(plantItem).getReplaceSource() : null; + replaceY = y + 2; + } else { + if(belowItem.isAir()) { + // Fertilizer placed above compost - same behaviour as immediate placement. + sourceY = y + 2; + plantY = y + 1; + } else { + // Fertilizer placed onto bulb. + WeightedMap source = sources.get(belowItem); + if(source == null || source.isEmpty()) return false; + plantItem = source.next(); + replacementItem = growables.containsKey(plantItem) ? growables.get(plantItem).getReplaceSource() : null; + replaceY = y + 1; + plantY = y + 1; + } + } + + if(sourceY != -1) { + if(!zone.areCoordinatesInBounds(x, sourceY)) return false; + WeightedMap source = sources.get(zone.getBlock(x, sourceY).getFrontItem()); + if(source == null || source.isEmpty()) return false; + plantItem = source.next(); + } + + zone.updateBlock(x, y, Layer.FRONT, Item.AIR); + // stack blocks on top of each other + int currentY = y + 2; + if(replaceY != -1) { + currentY = Math.min(currentY, replaceY); + if(replacementItem != null) zone.updateBlock(x, currentY, Layer.FRONT, replacementItem); + currentY--; + } + if(plantY != -1) { + currentY = Math.min(currentY, plantY); + zone.updateBlock(x, currentY, Layer.FRONT, plantItem, growables.containsKey(plantItem) ? growables.get(plantItem).getMaxMod() : 0); + currentY--; + } + + return true; + } /** * Calls {@link #updateGrowables(int, Collection)} where {@code sourceIndices} is the currently indexed growables. @@ -66,7 +149,7 @@ public void updateGrowables(int rainCycles) { */ public void updateGrowables(int rainCycles, Collection sourceIndices) { // Do nothing if zone isn't purified - if(!zone.isPurified() && zone.getBiome() != Biome.HELL) { + if(!zone.isPurified()) { return; } @@ -74,29 +157,29 @@ public void updateGrowables(int rainCycles, Collection sourceIndices) { if(rainCycles < 1 || sourceIndices.isEmpty()) { return; } + + growLamps = zone.getMetaBlocksWithItem(GROW_LAMP); // Reduce overhead by reducing the number of iterations in exchange for a growth chance boost rainCycles = Math.min(MAX_RAIN_CYCLES, rainCycles); int growthChanceBoost = Math.min(10, rainCycles); rainCycles /= growthChanceBoost; - + // Update growth for each rain cycle + List indices = new ArrayList<>(sourceIndices); for(int i = 0; i < rainCycles; i++) { - Iterator iterator = sourceIndices.iterator(); - - while(iterator.hasNext()) { - int index = iterator.next(); + for(int index : indices) { int x = index % zone.getWidth(); int y = index / zone.getWidth(); // Unindex if chunk is not loaded if(y == 0 || !zone.isChunkLoaded(x, y)) { - iterator.remove(); + sourceIndices.remove(index); continue; } // Skip if sunlight can't reach this source - if(zone.getSunlight()[x] < y) { + if(!isReceivingLight(x, y)) { continue; } @@ -105,7 +188,7 @@ public void updateGrowables(int rainCycles, Collection sourceIndices) { // Unindex if block is not a source if(!sources.containsKey(sourceItem)) { - iterator.remove(); + sourceIndices.remove(index); continue; } diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/HolographConfiguration.java b/gameserver/src/main/java/brainwine/gameserver/zone/HolographConfiguration.java new file mode 100644 index 00000000..205f7292 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/HolographConfiguration.java @@ -0,0 +1,192 @@ +package brainwine.gameserver.zone; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.command.CommandAccessLevel; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.AppearanceSlot; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.messages.EntityChangeMessage; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.util.MathUtils; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.HashMap; +import java.util.Map; + +public class HolographConfiguration extends WorldMachineConfiguration { + @JsonProperty + private CommandAccessLevel onOffAccessLevel = CommandAccessLevel.OWNERS; + @JsonProperty + private CommandAccessLevel changeOutfitAccessLevel = CommandAccessLevel.OWNERS; + @JsonProperty + private int timeout = 0; + @JsonProperty + private long outfitChangeTime = System.currentTimeMillis(); + @JsonProperty + private final Map outfitOverrides = new HashMap<>(); + @JsonProperty + private boolean turnedOn = false; + private int tickCounter = 0; + + @JsonIgnore + @Override + public boolean isEnabled() { + return isEnabled("machines/holograph"); + } + + @Override + protected String getDialogName() { + return "dialogs.world_machines.holograph.configure"; + } + + @Override + protected String getPublicDialogName() { + return "dialogs.world_machines.holograph.public"; + } + + @Override + protected void configure(Zone zone, Map values) throws IllegalArgumentException { + CommandAccessLevel[] accessLevels = CommandAccessLevel.values(); + if(values.containsKey("on_off")) { + onOffAccessLevel = accessLevels[MathUtils.clamp(expectInteger(values.get("on_off")), 0, 2)]; + } + + if(values.containsKey("change_outfit")) { + changeOutfitAccessLevel = accessLevels[MathUtils.clamp(expectInteger(values.get("change_outfit")), 0, 2)]; + } + + if(values.containsKey("timeout")) { + timeout = MathUtils.clamp(expectInteger(values.get("timeout")), 0, 600_000); + } + } + + @Override + public void handleCommand(Player player, Zone zone, Item item, String command) { + if(System.currentTimeMillis() - outfitChangeTime < 500) { + player.notify("You send commands too frequently. Please try again later."); + return; + } + + switch(command) { + case "change_outfit": + if(changeOutfitAccessLevel.isPrivileged(player, zone)) { + setOutfitFrom(player, zone, item.getPower()); + } else { + player.notify( + changeOutfitAccessLevel == CommandAccessLevel.OWNERS + ? "Only the world owner is able to change the outfit of the holograph." + : "Only the world members are able to change the outfit of the holograph." + ); + } + break; + case "turn_on": + case "turn_off": + if(onOffAccessLevel.isPrivileged(player, zone)) { + if ("turn_on".equals(command)) { + turnOnMachine(zone); + } else { + turnOffMachine(zone); + } + } else { + player.notify( + changeOutfitAccessLevel == CommandAccessLevel.OWNERS + ? "Only the world owner is able to turn " + command + " the holograph." + : "Only the world members are able to turn " + command + " the holograph." + ); + } + break; + } + } + + @Override + protected Object getValue(String key) { + switch(key) { + case "on_off": + return onOffAccessLevel.ordinal(); + case "change_outfit": + return changeOutfitAccessLevel.ordinal(); + case "timeout": + return Integer.toString(timeout); + default: + return null; + } + } + + public CommandAccessLevel getOnOffAccessLevel() { + return onOffAccessLevel; + } + + public CommandAccessLevel getChangeOutfitAccessLevel() { + return changeOutfitAccessLevel; + } + + public long getTimeout() { + return timeout; + } + + public boolean isTurnedOn() { + return turnedOn; + } + + public Map getOutfitOverrides() { + return outfitOverrides; + } + + public void tick(float deltaTime) { + if(turnedOn && tickCounter == 0 && !isEnabled()) { + turnOffMachine(zone); + } + + if(timeout != 0 && System.currentTimeMillis() > timeout + outfitChangeTime && turnedOn) { + turnOffMachine(zone); + } + + tickCounter++; + if(tickCounter >= 16) tickCounter = 0; + } + + public void turnOnMachine(Zone zone) { + if(turnedOn) return; + outfitChangeTime = System.currentTimeMillis(); + turnedOn = true; + updateZoneAppearances(zone); + } + + public void turnOffMachine(Zone zone) { + if(!turnedOn) return; + turnedOn = false; + updateZoneAppearances(zone); + } + + public void setOutfitFrom(Player player, Zone zone, float availablePower) { + Map appearancePower = MapHelper.getMap(GameConfiguration.getBaseConfig(), "dialogs.world_machines.holograph.appearance", new HashMap<>()); + outfitOverrides.clear(); + Map outfit = player.getAppearance(); + for(AppearanceSlot slot : AppearanceSlot.values()) { + if(expectInteger(appearancePower.getOrDefault(slot.getCategory(), 0)) < availablePower) { + outfitOverrides.put(slot.getId(), outfit.get(slot.getId())); + } + } + if(turnedOn) { + updateZoneAppearances(zone); + } + outfitChangeTime = System.currentTimeMillis(); + } + + private void updateZoneAppearances(Zone zone) { + for(Player player : zone.getPlayers()) { + zone.spawnEffect(player.getX(), player.getY(), "bomb-teleport", 4); + zone.sendMessage(new EntityChangeMessage(player.getId(), player.getVisibleAppearance())); + } + } + + public Map overrideAppearance(Map appearance) { + if(turnedOn && !outfitOverrides.isEmpty() && isEnabled()) { + appearance = new HashMap<>(appearance); + appearance.putAll(outfitOverrides); + } + + return appearance; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/LiquidManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/LiquidManager.java index d745de14..0694d82e 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/LiquidManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/LiquidManager.java @@ -2,12 +2,15 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.Layer; +import brainwine.gameserver.server.models.BlockChangeData; public class LiquidManager { @@ -33,6 +36,12 @@ public void tick(double deltaTime) { } private int updateLiquids() { + // Don't do anything if liquid gravity is disabled + if(!zone.getWeatherMachineConfiguration().isLiquidGravityEnabled()) { + liquidIndices.clear(); + return 0; + } + // Sort in reverse order so that lower liquid blocks are updated first List liquidIndicesToUpdate = new ArrayList<>(liquidIndices); Collections.sort(liquidIndicesToUpdate, Collections.reverseOrder()); @@ -157,9 +166,50 @@ public int settleLiquids() { return updateCount; } + + public void processClientLiquidContinuity(Map changeDataMap) { + Map newChanges = new HashMap<>(); + int updateMinIndex = Layer.LIQUID.ordinal() * zone.getWidth() * zone.getHeight(); + for(BlockChangeData data : changeDataMap.values()) { + if(data.getLayer() != Layer.LIQUID) continue; + int x = data.getX(); + int y = data.getY(); + if(!zone.isChunkLoaded(x, y)) continue; + + Block thisBlock = zone.getBlock(x, y); + boolean thisBlockHasLiquid = !thisBlock.getLiquidItem().isAir() && thisBlock.getLiquidMod() > 0; + + int thisIndex = updateMinIndex + zone.getBlockIndex(x, y); + int belowChangeIndex = updateMinIndex + zone.getBlockIndex(x, y + 1); + + // Make below block full if there is liquid here. + if(zone.isChunkLoaded(x, y + 1)) { + Block belowBlock = zone.getBlock(x, y + 1); + if(!belowBlock.getLiquidItem().isAir()) { + if(thisBlockHasLiquid) { + newChanges.put(belowChangeIndex, new BlockChangeData(x, y + 1, Layer.LIQUID, 0, belowBlock.getLiquidItem(), 5)); + } else if(belowBlock.getLiquidMod() < 5) { + newChanges.put(belowChangeIndex, new BlockChangeData(x, y + 1, Layer.LIQUID, 0, belowBlock.getLiquidItem(), belowBlock.getLiquidMod())); + } + } + } + + // Sometimes this liquid can flow underneath another. + if(thisBlockHasLiquid && zone.isChunkLoaded(x, y - 1)) { + Block aboveBlock = zone.getBlock(x, y - 1); + if(!aboveBlock.getLiquidItem().isAir() && aboveBlock.getLiquidMod() > 0) { + newChanges.put(thisIndex, new BlockChangeData(x, y, Layer.LIQUID, 0, thisBlock.getLiquidItem(), 5)); + } + } + } + + changeDataMap.putAll(newChanges); + } public void indexLiquidBlock(int x, int y) { - indexLiquidBlock(zone.getBlockIndex(x, y)); + if(zone.getWeatherMachineConfiguration().isLiquidGravityEnabled()) { + indexLiquidBlock(zone.getBlockIndex(x, y)); + } } public void indexLiquidBlock(int index) { diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/MachineManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/MachineManager.java index bd36f436..d58c783b 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/MachineManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/MachineManager.java @@ -11,6 +11,7 @@ import java.util.stream.Stream; import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.player.Player; import brainwine.gameserver.server.messages.ZoneStatusMessage; import brainwine.gameserver.util.MapHelper; @@ -24,6 +25,9 @@ public class MachineManager { private final Map> discoveredParts = new HashMap<>(); private final Map machineBlocks = new HashMap<>(); private final Zone zone; + private final Map massSpawners = new HashMap<>(); + private final Map massTeleporters = new HashMap<>(); + private final Map weatherMachines = new HashMap<>(); public MachineManager(Zone zone) { this.zone = zone; @@ -181,6 +185,18 @@ protected void loadData(ZoneConfigFile config) { .collect(Collectors.toCollection(ArrayList::new)))); this.discoveredParts.putAll(discoveredParts); } + + protected boolean hasMassTeleporter() { + return !massTeleporters.isEmpty(); + } + + protected boolean hasMassSpawner() { + return !massSpawners.isEmpty(); + } + + protected boolean hasWeatherMachine() { + return !weatherMachines.isEmpty(); + } protected void indexMetaBlock(int index, MetaBlock metaBlock) { EcologicalMachine machine = EcologicalMachine.fromBase(metaBlock.getItem()); @@ -188,6 +204,20 @@ protected void indexMetaBlock(int index, MetaBlock metaBlock) { if(machine != null) { machineBlocks.put(index, metaBlock); updateMachineStatus(machine); + return; + } + + if(metaBlock.getItem().hasUse(ItemUseType.WORLD_MACHINE)) { + switch((String) metaBlock.getItem().getUse(ItemUseType.WORLD_MACHINE)) { + case "spawner": + massSpawners.put(index, metaBlock); + break; + case "teleport": + massTeleporters.put(index, metaBlock); + break; + case "weather": + weatherMachines.put(index, metaBlock); + } } } @@ -196,6 +226,23 @@ protected void unindexMetaBlock(int index) { if(metaBlock != null) { updateMachineStatus(EcologicalMachine.fromBase(metaBlock.getItem())); + + if(metaBlock.getItem().hasUse(ItemUseType.WORLD_MACHINE)) { + switch((String) metaBlock.getItem().getUse(ItemUseType.WORLD_MACHINE)) { + case "spawner": + massSpawners.remove(index); + if(!hasMassSpawner()) zone.getMassSpawnerConfiguration().reset(zone); + break; + case "teleport": + massTeleporters.remove(index); + if(!hasMassTeleporter()) zone.getMassTeleporterConfiguration().reset(); + break; + case "weather": + weatherMachines.remove(index); + if(!hasWeatherMachine()) zone.getWeatherMachineConfiguration().reset(); + break; + } + } } } } diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/MassSpawnerConfiguration.java b/gameserver/src/main/java/brainwine/gameserver/zone/MassSpawnerConfiguration.java new file mode 100644 index 00000000..cd693617 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/MassSpawnerConfiguration.java @@ -0,0 +1,97 @@ +package brainwine.gameserver.zone; + +import brainwine.gameserver.command.CommandAccessLevel; +import brainwine.gameserver.util.MathUtils; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class MassSpawnerConfiguration extends WorldMachineConfiguration { + int difficulty = 3; + boolean mawSpawningEnabled = true; + boolean areaSpawningEnabled = true; + CommandAccessLevel evokeAccess = CommandAccessLevel.OWNERS; + + @JsonIgnore + @Override + public boolean isEnabled() { + return isEnabled("machines/mass-spawner"); + } + + @Override + protected String getDialogName() { + return "dialogs.world_machines.spawner.configure"; + } + + public void reset(Zone zone) { + difficulty = 3; + mawSpawningEnabled = true; + areaSpawningEnabled = true; + evokeAccess = CommandAccessLevel.OWNERS; + onUpdate(zone); + } + + @Override + protected void configure(Zone zone, Map values) throws IllegalArgumentException { + difficulty = 3; + mawSpawningEnabled = true; + areaSpawningEnabled = true; + evokeAccess = CommandAccessLevel.OWNERS; + + if(values.get("hostility") != null) { + difficulty = MathUtils.clamp(2 * expectInteger(values.get("hostility")) + 1, 1, 5); + } + + if(values.get("maw_spawning") != null) { + mawSpawningEnabled = expectInteger(values.get("maw_spawning")) == 0; + } + + if(values.get("area_spawning") != null) { + areaSpawningEnabled = expectInteger(values.get("area_spawning")) == 0; + } + + if(values.get("evoke") != null) { + evokeAccess = CommandAccessLevel.values()[MathUtils.clamp(expectInteger(values.get("evoke")), 0, 2)]; + } + + onUpdate(zone); + } + + @Override + protected Object getValue(String key) { + switch(key) { + case "hostility": + return difficulty / 2; + case "maw_spawning": + return mawSpawningEnabled ? 0 : 1; + case "area_spawning": + return areaSpawningEnabled ? 0 : 1; + case "evoke": + return evokeAccess.ordinal(); + } + + return null; + } + + private void onUpdate(Zone zone) { + zone.getEntityManager().updateSpawnRates(); + } + + public int getDifficulty() { + return difficulty; + } + + public boolean isMawSpawningEnabled() { + return mawSpawningEnabled; + } + + public boolean isAreaSpawningEnabled() { + return areaSpawningEnabled; + } + + public CommandAccessLevel getEvokeAccess() { + return evokeAccess; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/MassTeleporterConfiguration.java b/gameserver/src/main/java/brainwine/gameserver/zone/MassTeleporterConfiguration.java new file mode 100644 index 00000000..92c8d062 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/MassTeleporterConfiguration.java @@ -0,0 +1,113 @@ +package brainwine.gameserver.zone; + +import brainwine.gameserver.command.CommandAccessLevel; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemUseType; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MathUtils; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class MassTeleporterConfiguration extends WorldMachineConfiguration { + private CommandAccessLevel teleportToPlayerAccess = CommandAccessLevel.OWNERS; + private CommandAccessLevel teleportToPlaqueAccess = CommandAccessLevel.OWNERS; + private CommandAccessLevel teleportInProtectedAreaAccess = CommandAccessLevel.OWNERS; + private CommandAccessLevel summonOtherPlayerAccess = CommandAccessLevel.OWNERS; + + @Override + protected String getDialogName() { + return "dialogs.world_machines.teleport.configure"; + } + + public void reset() { + teleportInProtectedAreaAccess = CommandAccessLevel.OWNERS; + teleportToPlayerAccess = CommandAccessLevel.OWNERS; + teleportToPlaqueAccess = CommandAccessLevel.OWNERS; + summonOtherPlayerAccess = CommandAccessLevel.OWNERS; + } + + @Override + public void configure(Zone zone, Map values) throws IllegalArgumentException { + reset(); + + CommandAccessLevel[] arrLevels = CommandAccessLevel.values(); + if(values.containsKey("tp_player")) { + teleportToPlayerAccess = arrLevels[MathUtils.clamp(expectInteger(values.get("tp_player")), 0, 3)]; + } + + if(values.containsKey("tp_plaque")) { + teleportToPlaqueAccess = arrLevels[MathUtils.clamp(expectInteger(values.get("tp_plaque")), 0, 3)]; + } + + if(values.containsKey("tp_protected")) { + teleportInProtectedAreaAccess = arrLevels[MathUtils.clamp(expectInteger(values.get("tp_protected")), 0, 3)]; + } + + if(values.containsKey("summon")) { + summonOtherPlayerAccess = arrLevels[MathUtils.clamp(expectInteger(values.get("summon")), 0, 2)]; + } + } + + @Override + protected Object getValue(String key) { + switch(key) { + case "tp_player": + return teleportToPlayerAccess.ordinal(); + case "tp_plaque": + return teleportToPlaqueAccess.ordinal(); + case "tp_protected": + return teleportInProtectedAreaAccess.ordinal(); + case "summon": + return summonOtherPlayerAccess.ordinal(); + } + + return null; + } + + @Override + public void handleCommand(Player player, Zone zone, Item item, String command) { + switch(command) { + case "deactivate_natural_teleporters": + deactivateNaturalTeleporters(player, zone); + break; + } + } + + @JsonIgnore + @Override + public boolean isEnabled() { + return isEnabled("machines/mass-teleporter"); + } + + public CommandAccessLevel getTeleportToPlayerAccess() { + return teleportToPlayerAccess; + } + + public CommandAccessLevel getTeleportToPlaqueAccess() { + return teleportToPlaqueAccess; + } + + public CommandAccessLevel getTeleportInProtectedAreaAccess() { + return teleportInProtectedAreaAccess; + } + + public CommandAccessLevel getSummonOtherPlayerAccess() { + return summonOtherPlayerAccess; + } + + public void deactivateNaturalTeleporters(Player player, Zone zone) { + if(zone.isOwner(player)) { + for(MetaBlock metaBlock : zone.getMetaBlocksWithUse(ItemUseType.TELEPORT)) { + if(!metaBlock.hasOwner() && !metaBlock.getItem().hasUse(ItemUseType.ZONE_TELEPORT)) { + zone.updateBlock(metaBlock.getX(), metaBlock.getY(), Layer.FRONT, Item.AIR); + } + } + + player.notify("Natural teleporters destroyed!"); + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/SteamManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/SteamManager.java index 03c2ac25..7798563e 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/SteamManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/SteamManager.java @@ -2,13 +2,23 @@ import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.Set; import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.item.Layer; +import brainwine.gameserver.item.ModType; +import brainwine.gameserver.item.usetypeconfig.ExtendedSteamableConfig; +import brainwine.gameserver.item.usetypeconfig.SteamSourceConfig; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.util.Vector2i; /** * Distributes steam through collectors to nearby machines via pipes. @@ -22,10 +32,13 @@ public class SteamManager { public static final byte STATE_PIPE = 0x1; // Pipe public static final byte STATE_COLLECTOR = 0x2; // Active collector private final Set collectorIndices = new HashSet<>(); + private final Set steamSourceIndices = new HashSet<>(); private final Set steamableIndices = new HashSet<>(); private final Set processedIndices = new HashSet<>(); private final List expiredSteamableIndices = new ArrayList<>(); private final Queue processQueue = new ArrayDeque<>(); + private final Map> extendedSteamableInletIndices = new HashMap<>(); + private final Map> extendedSteamableMainIndices = new HashMap<>(); private final Zone zone; private byte[] data; private long lastUpdateAt; @@ -41,6 +54,7 @@ public void tick(double deltaTime) { // Check if it's time to update steam yet if(now > lastUpdateAt + STEAM_UPDATE_INTERVAL) { updateSteam(); + tickSteamSources(); lastUpdateAt = now; } } @@ -100,7 +114,30 @@ private void updateSteam() { processQueue.add(new SteamIteration(x + 1, y + 1, 2, 0)); // Bottom processQueue.add(new SteamIteration(x - 1, y - 1, 3, 0)); // Left } - + + // Enqueue blocks at the bottom left side of all steam sources + for(int index : steamSourceIndices) { + int x = index % zone.getWidth(); + int y = index / zone.getWidth(); + + // Skip if no player is close to this collector + if(zone.getPlayersInRange(x, y, MAX_COLLECTOR_DISTANCE).isEmpty()) { + continue; + } + + Block block = zone.getBlock(x, y); + Item item = block.getFrontItem(); + SteamSourceConfig steamSource = item.getStructuredUse(ItemUseType.STEAM_SOURCE); + boolean mirrored = item.isMirrorable() && block.getFrontMod() != 0; + + for(SteamSourceConfig.Outlet outlet : steamSource.getOutlets()) { + int dx = mirrored ? item.getBlockWidth() - outlet.getPosition().getX() - 1 : outlet.getPosition().getX(); + int dir = mirrored ? (outlet.getDirection() == 3 ? 1 : outlet.getDirection() == 1 ? 3 : outlet.getDirection()): outlet.getDirection(); + processQueue.add(new SteamIteration(x + dx, y + outlet.getPosition().getY(), dir, 0)); + } + } + + Set poweredExtendedSteamableInlets = new HashSet<>(); // Travel down the pipeline and power on any machines that are reached by it while(!processQueue.isEmpty()) { SteamIteration iteration = processQueue.poll(); @@ -136,6 +173,11 @@ private void updateSteam() { continue; } + + Set mainIndices = extendedSteamableInletIndices.get(index); + if(mainIndices != null && !mainIndices.isEmpty()) { + poweredExtendedSteamableInlets.add(index); + } byte direction = iteration.getDirection(); int nextDepth = depth + 1; @@ -146,35 +188,135 @@ private void updateSteam() { if(direction != 0) processQueue.add(new SteamIteration(x, y + 1, 2, nextDepth)); // Bottom if(direction != 1) processQueue.add(new SteamIteration(x - 1, y, 3, nextDepth)); // Left } + + for(int inletIndex : new HashSet(extendedSteamableInletIndices.keySet())) { + boolean powered = poweredExtendedSteamableInlets.contains(inletIndex); + for(int mainIndex : new HashSet(extendedSteamableInletIndices.get(inletIndex))) { + int x = mainIndex % zone.getWidth(); + int y = mainIndex / zone.getWidth(); + + // Items without the extended steam use type had been removed beforehand. + Block block = zone.getBlock(x, y); + if(block == null) continue; + Item item = zone.getBlock(x, y).getFrontItem(); + boolean serverSide = item.getMod() == ModType.ROTATION; + if(serverSide) { + ExtendedSteamableConfig steamable = item.getStructuredUse(ItemUseType.EXTENDED_STEAMABLE); + boolean poweredBeforehand = item.hasId(steamable.getOnVariantId()); + + if(poweredBeforehand != powered) { + String newItemCode = powered ? steamable.getOnVariantId() : steamable.getOffVariantId(); + zone.updateBlock(x, y, Layer.FRONT, newItemCode, block.getFrontMod()); + } + } else { + zone.updateBlockMod(x, y, Layer.FRONT, powered ? 1 : 0); + } + } + } + } + + public void setSteamSourcePowered(int x, int y, boolean powered, Player owner) { + Block block = zone.getBlock(x, y); + Item item = block.getFrontItem(); + SteamSourceConfig steamSource = item.getStructuredUse(ItemUseType.STEAM_SOURCE); + if(item.isMirrorable()) { + zone.updateBlock(x, y, Layer.FRONT, ItemRegistry.getItem(powered ? steamSource.getOnVariantId() : steamSource.getOffVariantId()), block.getFrontMod(), owner); + } else { + // Update whole block to force it to be re-indexed. + zone.updateBlock(x, y, Layer.FRONT, block.getFrontItem(), powered ? 1 : 0, owner); + } + } + + public boolean isSteamSourcePowered(int x, int y) { + Block block = zone.getBlock(x, y); + Item item = block.getFrontItem(); + if(!item.hasUse(ItemUseType.STEAM_SOURCE)) return false; + SteamSourceConfig steamSource = item.getStructuredUse(ItemUseType.STEAM_SOURCE); + if(item.isMirrorable()) { + return block.getFrontItem().hasId(steamSource.getOnVariantId()); + } else { + return block.getFrontMod() > 0; + } + } + + private void tickSteamSources() { + long currentTime = System.currentTimeMillis(); + for(MetaBlock metaBlock : zone.getMetaBlocksWithUse(ItemUseType.STEAM_SOURCE)) { + if(isSteamSourcePowered(metaBlock.getX(), metaBlock.getY())) { + long f = MapHelper.getLong(metaBlock.getMetadata(), "f", 0); + if(f > 0 && f < currentTime) { + setSteamSourcePowered(metaBlock.getX(), metaBlock.getY(), false, metaBlock.getOwner()); + } + } + } + } + + public void unindexBlock(int x, int y) { + int index = zone.getBlockIndex(x, y); + + List inlets = extendedSteamableMainIndices.remove(index); + if(inlets != null) for(int inlet : inlets) { + Set steamableIndices = extendedSteamableInletIndices.get(inlet); + if(steamableIndices != null) steamableIndices.remove(index); + } } public void indexBlock(int x, int y, Item item) { int index = zone.getBlockIndex(x, y); - + + unindexBlock(x, y); + // Does it use steam? - if(!item.usesSteam()) { - steamableIndices.remove(index); - - // Is it a pipe? - if(!item.hasId("mechanical/pipe")) { - - // Is it a collector and is it on top of a steam vent? - if(!item.hasId("mechanical/collector") || !isCollectorActive(x, y)) { - collectorIndices.remove(index); - setState(index, STATE_EMPTY); - return; - } - - collectorIndices.add(index); - setState(index, STATE_COLLECTOR); - return; - } - + if(item.usesSteam()) { + steamableIndices.add(index); + setState(index, STATE_EMPTY); + return; + } + + // Is it a pipe? + if(item.hasId("mechanical/pipe") || item.hasId("mechanical/pipeiron") || item.hasId("mechanical/pipecopper")) { setState(index, STATE_PIPE); return; } - - steamableIndices.add(index); + + // Is it a collector and is it on top of a steam vent? + if(item.hasId("mechanical/collector") && isCollectorActive(x, y)) { + collectorIndices.add(index); + setState(index, STATE_COLLECTOR); + return; + } else { + collectorIndices.remove(index); + } + + // Is it a powered steam source + if(isSteamSourcePowered(x, y)) { + steamSourceIndices.add(index); + setState(index, STATE_COLLECTOR); + return; + } else { + steamSourceIndices.remove(index); + } + + if(item.hasUse(ItemUseType.EXTENDED_STEAMABLE)) { + ExtendedSteamableConfig steamable = item.getStructuredUse(ItemUseType.EXTENDED_STEAMABLE); + + Block block = zone.getBlock(x, y); + boolean flipped = item.isMirrorable() && block.getFrontMod() != 0; + List inletIndices = new ArrayList<>(steamable.getInlets().size()); + for(Vector2i position : steamable.getInlets()) { + int worldX = x + (flipped ? (item.getBlockWidth() - position.getX() - 1) : position.getX()); + int worldY = y + position.getY(); + + if(zone.areCoordinatesInBounds(worldX, worldY)) { + int inletIndex = zone.getBlockIndex(worldX, worldY); + + extendedSteamableInletIndices.computeIfAbsent(inletIndex, HashSet::new).add(index); + inletIndices.add(inletIndex); + } + } + extendedSteamableMainIndices.put(index, inletIndices); + } + setState(index, STATE_EMPTY); } diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/WeatherMachineConfiguration.java b/gameserver/src/main/java/brainwine/gameserver/zone/WeatherMachineConfiguration.java new file mode 100644 index 00000000..9dd3e99b --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/WeatherMachineConfiguration.java @@ -0,0 +1,153 @@ +package brainwine.gameserver.zone; + +import brainwine.gameserver.util.MathUtils; +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.apache.commons.text.WordUtils; + +import java.util.Map; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; + +public class WeatherMachineConfiguration extends WorldMachineConfiguration { + public enum Environment { + PLEASANT, + DANGEROUS, + SEVERE, + EXTREME, + } + public enum DayAndNightCycleMode { + NORMAL, + FAST, + SLOW, + REALTIME, + DAY, + NIGHT, + } + public enum Precipitation { + NORMAL, + ALWAYS, + NONE + } + + private DayAndNightCycleMode dayAndNightCycleMode; + private int timeZone; + private Precipitation precipitation; + private int degreeOfDanger; + private boolean fastGrowthEnabled; + private boolean liquidGravityEnabled; + + public WeatherMachineConfiguration() { + reset(); + } + + @JsonIgnore + @Override + public boolean isEnabled() { + return isEnabled("machines/weather-machine"); + } + + @Override + protected String getDialogName() { + return "dialogs.world_machines.weather.configure"; + } + + protected void reset() { + dayAndNightCycleMode = DayAndNightCycleMode.NORMAL; + timeZone = 0; + precipitation = Precipitation.NORMAL; + degreeOfDanger = 3; + fastGrowthEnabled = false; + liquidGravityEnabled = true; + } + + @Override + protected void configure(Zone zone, Map values) throws IllegalArgumentException { + boolean previouslyLiquidGravityEnabled = liquidGravityEnabled; + reset(); + + if(values.get("day_night_cycle") != null) { + dayAndNightCycleMode = DayAndNightCycleMode.valueOf(expectString(values.get("day_night_cycle")).toUpperCase()); + } + + if(values.get("precipitation_frequency") != null) { + precipitation = Precipitation.valueOf(expectString(values.get("precipitation_frequency")).toUpperCase()); + } + + if(values.get("environment") != null) { + degreeOfDanger = 2 * Environment.valueOf(expectString(values.get("environment")).toUpperCase()).ordinal() + 1; + } + + if(values.get("growth_index") != null) { + fastGrowthEnabled = expectString(values.get("growth_index")).equalsIgnoreCase("High"); + } + + if(values.get("liquid_gravity") != null) { + liquidGravityEnabled = expectString(values.get("liquid_gravity")).equalsIgnoreCase("Normal"); + } + + timeZone = MathUtils.clamp(expectInteger(defaultIfNull(values.get("day_night_cycle_time_zone"), timeZone)), -12, 12); + + // Reindex liquids in active chunks. The rest of the chunks will get indexed when they get loaded. + if(!previouslyLiquidGravityEnabled && liquidGravityEnabled) { + int chunkWidth = zone.getChunkWidth(); + int chunkHeight = zone.getChunkHeight(); + LiquidManager liquidManager = zone.getLiquidManager(); + for(Chunk c : zone.getLoadedChunks()) { + int chunkX = c.getX(); + int chunkY = c.getY(); + for(int i = 0; i < chunkWidth; i++) { + for(int j = 0; j < chunkHeight; j++) { + Block block = zone.getBlock(chunkX + i, chunkY + j); + if(!block.getLiquidItem().isAir() && block.getLiquidMod() > 0) { + liquidManager.indexLiquidBlock(chunkX + i, chunkY + j); + } + } + } + } + } + } + + @Override + protected Object getValue(String key) { + switch(key) { + case "day_night_cycle": + return WordUtils.capitalizeFully(dayAndNightCycleMode.toString()); + case "day_night_cycle_time_zone": + return Integer.toString(timeZone); + case "precipitation_frequency": + return WordUtils.capitalizeFully(precipitation.toString()); + case "environment": + return WordUtils.capitalizeFully(Environment.values()[degreeOfDanger / 2].toString()); + case "growth_index": + return fastGrowthEnabled ? "High" : "Normal"; + case "liquid_gravity": + return liquidGravityEnabled ? "None" : "Normal"; + default: + return null; + } + } + + public DayAndNightCycleMode getDayAndNightCycleMode() { + return dayAndNightCycleMode; + } + + public int getTimeZone() { + return timeZone; + } + + public Precipitation getPrecipitation() { + return precipitation; + } + + public int getDegreeOfDanger() { + return degreeOfDanger; + } + + public boolean isFastGrowthEnabled() { + return fastGrowthEnabled; + } + + public boolean isLiquidGravityEnabled() { + return liquidGravityEnabled; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/WeatherManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/WeatherManager.java index d4b3d72f..09dce6e0 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/WeatherManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/WeatherManager.java @@ -22,12 +22,27 @@ public WeatherManager(Zone zone) { public void tick(float deltaTime) { long now = System.currentTimeMillis(); - + if(now > rainStart + rainDuration) { + WeatherMachineConfiguration.Precipitation setting = zone.getWeatherMachineConfiguration().isEnabled() + ? zone.getWeatherMachineConfiguration().getPrecipitation() + : WeatherMachineConfiguration.Precipitation.NORMAL; boolean dry = rainPower > 0; + boolean updateGrowables = dry; + + if(setting == WeatherMachineConfiguration.Precipitation.ALWAYS) { + dry = false; + updateGrowables = true; + } + + if(setting == WeatherMachineConfiguration.Precipitation.NONE) { + dry = true; + updateGrowables = false; + } + createRandomRain(dry); - - if(dry) { + + if(updateGrowables) { zone.updateGrowables(1); } } diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/WorldMachineConfiguration.java b/gameserver/src/main/java/brainwine/gameserver/zone/WorldMachineConfiguration.java new file mode 100644 index 00000000..e8418645 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/WorldMachineConfiguration.java @@ -0,0 +1,207 @@ +package brainwine.gameserver.zone; + +import brainwine.gameserver.GameConfiguration; +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.input.DialogTextIndexInput; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public abstract class WorldMachineConfiguration { + @JsonIgnore + protected Zone zone; + @JsonIgnore + private long activation = System.currentTimeMillis() + 1000; + private int machineX; + private int machineY; + + protected abstract String getDialogName(); + protected abstract void configure(Zone zone, Map values) throws IllegalArgumentException; + + protected abstract Object getValue(String key); + + protected boolean isEnabled(String itemName) { + if((machineX == 0 && machineY == 0) || activation > System.currentTimeMillis()) return false; + Block block = zone.getBlockSafe(machineX, machineY); + if(block == null || !block.getFrontItem().getId().startsWith(itemName)) return false; + return zone.isBlockPowered(machineX, machineY); + } + + protected boolean isEnabled() { + return false; + } + + public T setZone(Zone zone) { + this.zone = zone; + return (T)this; + } + + public int getMachineX() { + return machineX; + } + + public int getMachineY() { + return machineY; + } + + protected String getPublicDialogName() { + return "override.me"; + } + + public void handleCommand(Player player, Zone zone, Item item, String command) {} + + public void configure(Player player, Zone zone, Item item, int x, int y) { + configure(player, zone, item.getPower(), x, y); + } + + public void configure(Player player, Zone zone, float availablePower, int x, int y) { + Dialog dialog = getConfigurationDialog(availablePower); + + if(dialog == null) { + player.notify("Configuration dialog not found!"); + return; + } + + player.showDialog(dialog, ans -> handleConfigurationDialog(player, zone, dialog, ans, x, y)); + } + + private Dialog getConfigurationDialog(float availablePower) { + Map original = MapHelper.getMap(GameConfiguration.getBaseConfig(), getDialogName()); + if(original == null) return null; + Map config = new HashMap<>(original); + List> sections = (List>)config.get("sections"); + + List retainedSections = sections.stream() + .filter(s -> (s.get("power") == null || (int) s.get("power") <= availablePower)) + .map(s -> { + DialogSection section; + try { + section = JsonHelper.readValue(s, DialogSection.class); + } catch(JsonProcessingException e) { + section = new DialogSection().setTitle("ERROR").setInput(new DialogTextIndexInput()); + } + + Object value = getValue(section.getInput().getKey()); + if(value != null) { + section.getInput().setValue(value); + } + + return section; + }) + .collect(Collectors.toList()); + config.put("sections", retainedSections); + + try { + return JsonHelper.readValue(config, Dialog.class); + } catch(JsonProcessingException e) { + return null; + } + } + + private void handleConfigurationDialog(Player player, Zone zone, Dialog dialog, Object[] ans, int x, int y) { + if(ans.length >= 1 && ans[0].equals("cancel")) return; + + if(player.getZone() == null || (!player.isGodMode() && !player.getZone().isOwner(player))) { + player.notify("Sorry, you do not own this world."); + return; + } + + if(dialog.getSections().size() > ans.length) return; + + Map values = new HashMap<>(); + + for(int i = 0; i < dialog.getSections().size(); i++) { + if(ans[i] != null) { + values.put(dialog.getSections().get(i).getInput().getKey(), ans[i]); + } else { + player.notify("Invalid input!"); + } + } + + try { + configure(zone, values); + if(x != -1 && y != -1) { + machineX = x; + machineY = y; + } + } catch(IllegalArgumentException e) { + player.notify("Invalid input!"); + } + } + + private void handlePublicDialog(Player player, Zone zone, Item item, Dialog dialog, Object[] ans) { + if(ans.length < 1) return; + if(ans.length > 1) { + player.notify("I don't know what to do."); + return; + } + if("cancel".equals(ans[0])) return; + if(ans[0] instanceof String) { + handleCommand(player, zone, item, (String)ans[0]); + } + } + + public void interactPublicly(Player player, Zone zone, Item item) { + Dialog dialog = null; + + // Filter out that require too much power. + try { + float availablePower = item.getPower(); + Map dialogConfig = new HashMap<>(MapHelper.getMap(GameConfiguration.getBaseConfig(), getPublicDialogName())); + if(dialogConfig.containsKey("sections")) { + List retainedSections = new ArrayList<>(); + for(Object section : MapHelper.getList(dialogConfig, "sections")) { + if(section instanceof Map) { + Object power = ((Map) section).get("power"); + if(power == null || (int) power <= availablePower) { + retainedSections.add(section); + } + } + } + dialogConfig.put("sections", retainedSections); + dialog = JsonHelper.readValue(dialogConfig, Dialog.class); + } + } catch(Exception e) { + e.printStackTrace(); + player.showDialog(DialogHelper.messageDialog("Public Dialog Parsing Error", e.getMessage())); + return; + } + + final Dialog finalDialog = dialog; + player.showDialog(dialog, ans -> handlePublicDialog(player, zone, item, finalDialog, ans)); + } + + protected String expectString(Object obj) throws IllegalArgumentException { + if(obj instanceof String) { + return (String)obj; + } else { + throw new IllegalArgumentException(); + } + } + + protected int expectInteger(Object obj) throws IllegalArgumentException { + if(obj instanceof Integer) { + return (int)obj; + } + + if(obj instanceof String) { + try { + return Integer.parseInt((String) obj); + } catch(Exception ignored) {} + } + + throw new IllegalArgumentException(); + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java index 0a13c8c7..59fc0f7a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java @@ -2,6 +2,7 @@ import java.io.File; import java.time.OffsetDateTime; +import java.time.temporal.ChronoField; import java.time.temporal.TemporalUnit; import java.util.ArrayList; import java.util.Arrays; @@ -26,6 +27,8 @@ import brainwine.gameserver.GameServer; import brainwine.gameserver.Timer; +import brainwine.gameserver.anticheat.AnticheatManager; +import brainwine.gameserver.anticheat.Exploration; import brainwine.gameserver.entity.Entity; import brainwine.gameserver.entity.npc.Npc; import brainwine.gameserver.entity.npc.NpcData; @@ -37,11 +40,13 @@ import brainwine.gameserver.item.Layer; import brainwine.gameserver.item.MetaType; import brainwine.gameserver.item.ModType; +import brainwine.gameserver.item.usetypeconfig.ExtendedSteamableConfig; import brainwine.gameserver.minigame.Minigame; import brainwine.gameserver.player.ChatType; import brainwine.gameserver.player.NotificationType; import brainwine.gameserver.player.Player; import brainwine.gameserver.prefab.Prefab; +import brainwine.gameserver.quest.QuestEvents; import brainwine.gameserver.server.Message; import brainwine.gameserver.server.messages.BlockChangeMessage; import brainwine.gameserver.server.messages.BlockMetaMessage; @@ -82,6 +87,8 @@ public class Zone { private int[] depths; private boolean[] chunksExplored; private int chunksExploredCount; + private int undergroundChunksExploredCount; + private int totalUndergroundChunks; private OffsetDateTime creationDate = OffsetDateTime.now(); private float time = (float)Math.random(); // TODO temporary private float temperature; @@ -89,8 +96,15 @@ public class Zone { private boolean isPrivate; private boolean isProtected; private boolean pvp; + private boolean market; + private boolean tutorial; private String entryCode; private String owner; + private MassSpawnerConfiguration massSpawnerConfiguration = new MassSpawnerConfiguration().setZone(this); + private MassTeleporterConfiguration massTeleporterConfiguration = new MassTeleporterConfiguration().setZone(this); + private WeatherMachineConfiguration weatherMachineConfiguration = new WeatherMachineConfiguration().setZone(this); + private HolographConfiguration holographConfiguration = new HolographConfiguration().setZone(this); + private ZoneRules rules = new ZoneRules(); private final ChunkManager chunkManager; private final SteamManager steamManager; private final GrowthManager growthManager; @@ -98,10 +112,13 @@ public class Zone { private final EntityManager entityManager = new EntityManager(this); private final LiquidManager liquidManager = new LiquidManager(this); private final MachineManager machineManager = new MachineManager(this); + private final DynamicsManager dynamicsManager = new DynamicsManager(this); private final Set pendingSunlight = new HashSet<>(); private final List members = new ArrayList<>(); + private final Set temporarilyAllowedPlayers = new HashSet<>(); private final List> blockTimers = new ArrayList<>(); private final Map dungeons = new HashMap<>(); + private final Map dungeonTypes = new HashMap<>(); private final Map metaBlocks = new HashMap<>(); private final Map globalMetaBlocks = new HashMap<>(); private final Map fieldBlocks = new HashMap<>(); @@ -112,7 +129,10 @@ public class Zone { private long lastStatusUpdate = System.currentTimeMillis(); private int ticksElapsed; private boolean modified; - + private boolean frozen = false; + private double xpMultiplier = 1.0; + private boolean entityShouldDrop = true; + protected Zone(String documentId, ZoneConfigFile config, ZoneDataFile data) { this(documentId, config.getName(), config.getBiome(), config.getWidth(), config.getHeight()); int[] surface = data.getSurface(); @@ -131,11 +151,19 @@ protected Zone(String documentId, ZoneConfigFile config, ZoneDataFile data) { owner = config.getOwner(); members.addAll(config.getMembers()); actionHistory.putAll(config.getActionHistory()); - acidity = biome == Biome.ARCTIC || biome == Biome.SPACE ? 0 : config.getAcidity(); + acidity = config.getAcidity(); isPrivate = config.isPrivate(); isProtected = config.isProtected(); pvp = config.isPvp(); + market = config.isMarket(); + tutorial = config.isTutorial(); creationDate = config.getCreationDate(); + massSpawnerConfiguration = config.getMassSpawnerConfiguration().setZone(this); + massTeleporterConfiguration = config.getMassTeleporterConfiguration().setZone(this); + weatherMachineConfiguration = config.getWeatherMachineConfiguration().setZone(this); + holographConfiguration = config.getHolographConfiguration().setZone(this); + entityManager.updateSpawnRates(); + setRules(config.getRules()); } public Zone(String documentId, String name, Biome biome, int width, int height) { @@ -150,10 +178,12 @@ public Zone(String documentId, String name, Biome biome, int width, int height) surface = new int[width]; sunlight = new int[width]; chunksExplored = new boolean[numChunksWidth * numChunksHeight]; - acidity = biome == Biome.ARCTIC || biome == Biome.SPACE ? 0 : 1; + recalculateChunksExploredCount(); + acidity = 1.0f; chunkManager = new ChunkManager(this); steamManager = new SteamManager(this); growthManager = new GrowthManager(this); + entityManager.updateSpawnRates(); Arrays.fill(surface, height); Arrays.fill(sunlight, height); } @@ -169,10 +199,39 @@ public void tick(float deltaTime) { entityManager.tick(deltaTime); liquidManager.tick(deltaTime); steamManager.tick(deltaTime); + dynamicsManager.tick(deltaTime); simulate(deltaTime); - - // One full cycle = 1200 seconds = 20 minutes - time += deltaTime * (1.0F / 1200.0F); + + switch(getWeatherMachineConfiguration().isEnabled() ? getWeatherMachineConfiguration().getDayAndNightCycleMode() : WeatherMachineConfiguration.DayAndNightCycleMode.NORMAL) { + case REALTIME: + OffsetDateTime current = OffsetDateTime.now(); + int wantedOffset = getWeatherMachineConfiguration().getTimeZone(); + int realOffset = current.getOffset().get(ChronoField.OFFSET_SECONDS) / 3600; + float currentHours = current.get(ChronoField.MILLI_OF_DAY) / 3_600_000.f; + float hours = currentHours + (wantedOffset - realOffset); + if(hours < 0) { + hours += 24.0f; + } + time = hours / 24.0f; + break; + case FAST: + time += deltaTime * (1.0F / 800.0F); + break; + case SLOW: + time += deltaTime * (1.0F / 1600.0F); + break; + case DAY: + time = 0.5f; + break; + case NIGHT: + time = 0.0f; + break; + case NORMAL: + default: + // One full cycle = 1200 seconds = 20 minutes + time += deltaTime * (1.0F / 1200.0F); + break; + } if(time >= 1.0F) { time -= 1.0F; @@ -205,6 +264,9 @@ public void tick(float deltaTime) { minigame.tick(deltaTime); } } + + // Update world machines + holographConfiguration.tick(deltaTime); // Process block timers if(!blockTimers.isEmpty()) { @@ -212,6 +274,8 @@ public void tick(float deltaTime) { blockTimers.removeAll(readyTimers); readyTimers.forEach(Timer::process); } + + liquidManager.processClientLiquidContinuity(blockChanges); // Send block changes to players who they are relevant to if(!blockChanges.isEmpty()) { @@ -245,7 +309,11 @@ public void tick(float deltaTime) { // Deal true damage to the target, scaling with distance from field block if(distance < radius) { float damage = maxDamage * (1.0F - distance / radius); - player.attack(null, item, damage, item.getFieldDamage().getType(), true); + if(damage > 0) { + player.attack(null, item, damage, item.getFieldDamage().getType(), true); + } else if(damage < 0) { + player.heal(-damage); + } } } } @@ -253,7 +321,29 @@ public void tick(float deltaTime) { ticksElapsed++; } - + + public void freeze() { + freeze(null); + } + + public void freeze(String reason) { + frozen = true; + + String playerMessage = reason == null ? "The zone " + getName() + " will be in maintenance for a while." : reason; + + for(Player player : getPlayers()) { + player.changeZone(null); + player.kick(playerMessage, true); + } + + blockChanges.clear(); + } + + public void thaw() { + blockChanges.clear(); + frozen = false; + } + /** * Simulate happenings that take longer periods of time */ @@ -315,6 +405,7 @@ public void sendChatMessage(Player sender, String text) { */ public void sendChatMessage(Player sender, String text, ChatType type) { sendMessage(new ChatMessage(sender.getId(), text, type)); + QuestEvents.handleChat(sender); GameServer.getInstance().notify(String.format("%s: %s", sender.getName(), text), NotificationType.CHAT); } @@ -442,7 +533,9 @@ public void explode(int x, int y, float radius, Entity cause, boolean destructiv updateBlock(x, y, Layer.BACK, 0); } } - + + List furtherProcessingPositions = new ArrayList<>(); + List furtherProcessingItems = new ArrayList<>(); // Destroy blocks within range if the explosion is destructive if(destructive) { int rayCount = (int)Math.ceil(radius * 8); @@ -480,8 +573,9 @@ public void explode(int x, int y, float radius, Entity cause, boolean destructiv if(!areCoordinatesInBounds(positionX, positionY)) { break; } - - Item frontItem = getBlock(positionX, positionY).getFrontItem(); + + Block block = getBlock(positionX, positionY); + Item frontItem = block.getFrontItem(); double distance = MathUtils.distance(x, y, positionX, positionY); double power = radius - distance; @@ -519,6 +613,11 @@ public void explode(int x, int y, float radius, Entity cause, boolean destructiv } affectedBlocks.add(position); + boolean entitySpawns = frontItem.hasEntitySpawns() && block.getFrontMod() == 0 && !frontItem.hasTimer() && !frontItem.hasUse(ItemUseType.SPAWN); + if(entitySpawns) { + furtherProcessingItems.add(frontItem); + furtherProcessingPositions.add(position); + } } } @@ -546,6 +645,24 @@ public void explode(int x, int y, float radius, Entity cause, boolean destructiv double distance = MathUtils.distance(x, y, entity.getX(), entity.getY()); float damage = (float)(baseDamage - distance); entity.attack(cause, item, damage, damageType); + + if(entity.isDead() && cause != null && cause.isPlayer()) { + QuestEvents.handleExplode((Player) cause, entity); + } + } + } + + for(int i = 0; i < furtherProcessingPositions.size(); i++) { + Item frontItem = furtherProcessingItems.get(i); + Vector2i position = furtherProcessingPositions.get(i); + + if(frontItem.hasEntitySpawns()) { + int left = frontItem.getEntitySpawnQuantity().getFirst(); + int right = frontItem.getEntitySpawnQuantity().getLast() + 1; + int quantity = (int)(left + Math.random() * (right - left)); + for(int j = 0; j < quantity; j++) { + spawnEntity(frontItem.getEntitySpawns().next(), position.getX(), position.getY()); + } } } } @@ -657,42 +774,59 @@ public boolean isBlockOccupied(int x, int y, Layer layer) { return !item.isAir() && !item.canPlaceOver(); } - + public boolean isBlockProtected(int x, int y) { return isBlockProtected(x, y, null); } - + public boolean isBlockProtected(int x, int y, Player player) { - return isBlockProtected(x, y, player, fieldBlocks.values()); + return isBlockProtected(x, y, player, false); } - + + public boolean isBlockProtected(int x, int y, Player player, boolean skipSelf) { + return isBlockProtected(x, y, player, skipSelf, fieldBlocks.values()); + } + public boolean isBlockProtected(int x, int y, Player player, Collection fieldBlocks) { + return isBlockProtected(x, y, player, false, fieldBlocks); + } + + public boolean isBlockProtected(int x, int y, Player player, boolean skipSelf, Collection fieldBlocks) { // Check bounds if(!areCoordinatesInBounds(x, y)) { return true; } - + // Check protection at zone level if(player != null && isProtected(player)) { return true; } - + Item frontItem = getBlock(x, y).getFrontItem(); // TODO can load chunks! MetaBlock metaBlock = getMetaBlock(x, y); - + // Check block owner if it has a field - if(frontItem.hasField() && (metaBlock == null || !metaBlock.isOwnedBy(player))) { + if(!skipSelf && frontItem.hasField() && (metaBlock == null || !metaBlock.isOwnedBy(player))) { return true; } - + + return isBlockProtectedByField(x, y, player, skipSelf, fieldBlocks); + } + + public boolean isBlockProtectedByField(int x, int y, Player player, boolean skipSelf, Collection fieldBlocks) { + MetaBlock metaBlock = getMetaBlock(x, y); + // Check field blocks for(MetaBlock fieldBlock : fieldBlocks) { + // Skip block if it is the current block and we need to skip it + if(skipSelf && fieldBlock == metaBlock) continue; + Item item = fieldBlock.getItem(); int fX = fieldBlock.getX(); int fY = fieldBlock.getY(); int field = fieldBlock.getItem().getField(); - - if(player == null || (!fieldBlock.isOwnedBy(player) + + if(player == null || (!fieldBlock.isOwnedBy(player) && !(fieldBlock.getIntProperty("t") == 1 && player.hasFollower(fieldBlock.getOwner())))) { if(item.isDish()) { if(MathUtils.inRange(x, y, fX, fY, field)) { @@ -705,7 +839,31 @@ public boolean isBlockProtected(int x, int y, Player player, Collection replacedItems = new HashMap<>(); @@ -796,11 +955,15 @@ public void placePrefab(Prefab prefab, int x, int y, Random random, boolean mirr } } }); - - for(int i = 0; i < width; i++) { - for(int j = 0; j < height; j++) { + + boolean[] ruinMask = new boolean[width]; + for(int j = 0; j < height; j++) { + for(int i = 0; i < width; i++) { + ruinMask[i] = prefab.isRuin() && SimplexNoise.noise2(seed, (x + i) / 8.0, (y + j) / 8.0, 2) > 0.4; + } + for(int i = 0; i < width; i++) { // Skip ruined bits - if(prefab.isRuin() && SimplexNoise.noise2(seed, (x + i) / 8.0, (y + j) / 8.0, 2) > 0.4) { + if(ruinMask[i]) { continue; } @@ -844,12 +1007,38 @@ public void placePrefab(Prefab prefab, int x, int y, Random random, boolean mirr } // Try to place rubble - if(decay && frontItem.isWhole() && !isBlockOccupied(x + i, y + j - 1, Layer.FRONT) && random.nextDouble() <= 0.2) { + if(decay && frontItem.isWhole() && !isBlockOccupied(x + i, y + j - 1, Layer.FRONT) + && random.nextDouble() <= 0.4 && findBlock(x + i, y + j - 1, b -> !b.getFrontItem().isAir()) == null) { + // Find the width of the surface available to place the rubble + int maxRubbleWidth = 1; + for(int currentWidth = 2; currentWidth <= 3; currentWidth++) { + int currentBoundsX = i + currentWidth - 1; + if(!areCoordinatesInBounds(x + currentBoundsX, y + j - 1)) break; + if(currentBoundsX >= width) break; + int prefabX = mirrored ? width - currentBoundsX - 1 : currentBoundsX; + Block belowBlock = blocks[width * j + prefabX]; + if( + ruinMask[currentBoundsX] + || isBlockOccupied(x + currentBoundsX, y + j - 1, Layer.FRONT) + || !belowBlock.getFrontItem().isWhole() + || findBlock(x + currentBoundsX, y + j - 1, b -> !b.getFrontItem().isAir()) != null + ) break; + maxRubbleWidth = currentWidth; + } + + // Find the rubble items that fit the available surface RubbleType[] types = RubbleType.values(); RubbleType type = types[random.nextInt(types.length)]; - String[] itemIds = type.getItemIds(); - Item item = ItemRegistry.getItem(itemIds[random.nextInt(itemIds.length)]); - updateBlock(x + i, y + j - 1, Layer.FRONT, item); + final int filterMaxRubbleWidth = maxRubbleWidth; + List items = Arrays.stream(type.getItemIds()).filter(id -> {{ + Item item = ItemRegistry.getItem(id); + return item != null && item.getBlockWidth() <= filterMaxRubbleWidth; + } + }).collect(Collectors.toList()); + if(!items.isEmpty()) { + Item item = ItemRegistry.getItem(items.get(random.nextInt(items.size()))); + updateBlock(x + i, y + j - 1, Layer.FRONT, item); + } } int offset = mirrored ? -(frontItem.getBlockWidth() - 1) : 0; @@ -870,6 +1059,14 @@ public void placePrefab(Prefab prefab, int x, int y, Random random, boolean mirr addGuardianEntities(metadata, frontItem.getGuardLevel(), y + j, random); guardBlocks++; } + + entityManager.updateRevenantDish(x, y, true); + } + + if(dungeonId != null && frontItem.hasId("mechanical/spawner-brain")) { + metadata.put("@", dungeonId); + // dungeonType = DungeonType.morePrior(dungeonType, DungeonType.EVOKER); + guardBlocks++; } // Determine lootability for containers @@ -917,12 +1114,16 @@ public void placePrefab(Prefab prefab, int x, int y, Random random, boolean mirr // Index dungeon if there are any guard blocks present if(guardBlocks > 0) { dungeons.put(dungeonId, guardBlocks); + dungeonTypes.put(dungeonId, dungeonType); } } private void indexDungeons() { - List guardBlocks = getMetaBlocksWithUse(ItemUseType.GUARD); - + List guardBlocks = getMetaBlocks(m -> + m.getItem().hasUse(ItemUseType.GUARD) + || m.getItem().hasId("mechanical/spawner-brain") + ); + for(MetaBlock metaBlock : guardBlocks) { Map metadata = metaBlock.getMetadata(); String dungeonId = MapHelper.getString(metadata, "@"); @@ -932,6 +1133,8 @@ private void indexDungeons() { int numGuardBlocks = dungeons.getOrDefault(dungeonId, 0); numGuardBlocks++; dungeons.put(dungeonId, numGuardBlocks); + // dungeonTypes.merge(dungeonId, DungeonType.fromMetaBlock(metaBlock), DungeonType::morePrior); + dungeonTypes.put(dungeonId, DungeonType.PUZZLE); } } } @@ -951,11 +1154,11 @@ private void addGuardianEntities(Map metadata, int guardLevel, i } else { int effectiveGuardLevel = guardLevel + depth / 200; String[][] groups = { - {"creatures/bat-auto", "creatures/bat-auto"}, + {"brains/tiny-crawler", "brains/tiny-crawler"}, {"brains/small"}, - {"brains/small", "creatures/bat-auto"}, - {"brains/small", "creatures/bat-auto"}, - {"brains/small", "creatures/bat-auto"}, + {"brains/small", "brains/tiny-crawler"}, + {"brains/small", "brains/tiny-crawler"}, + {"brains/small", "brains/tiny-crawler"}, {"brains/medium"}, {"brains/medium"}, {"brains/medium", "brains/small"}, @@ -963,14 +1166,19 @@ private void addGuardianEntities(Map metadata, int guardLevel, i {"brains/medium-dire", "brains/small"}, {"brains/medium-dire", "brains/small"}, }; - + int max = Math.max(1, groups.length - 6); - String[] group = Stream.of(groups) + String[] groupArr = Stream.of(groups) .skip(Math.min(effectiveGuardLevel, groups.length - max)) .limit(max) .collect(Collectors.toList()) .get(random.nextInt(max)); - guardians.addAll(Arrays.asList(group)); + List group = Arrays.asList(groupArr); + if(massSpawnerConfiguration.getDifficulty() < 3) { + guardians.addAll(group.subList(1, group.size())); + } else { + guardians.addAll(group); + } } metadata.put("!", guardians); @@ -981,12 +1189,16 @@ public void destroyGuardBlock(String dungeonId, Player destroyer) { if(dungeons.containsKey(dungeonId)) { int guardBlocks = dungeons.get(dungeonId); guardBlocks--; - + if(guardBlocks <= 0) { + DungeonType dungeonType = getDungeonType(dungeonId); dungeons.remove(dungeonId); - destroyer.getStatistics().trackDungeonRaided(); - destroyer.notify("You raided a dungeon!", NotificationType.ACCOMPLISHMENT); - destroyer.notifyPeers(String.format("%s raided a dungeon.", destroyer.getName()), NotificationType.SYSTEM); + if(destroyer != null) { + destroyer.getStatistics().trackDungeonRaided(dungeonType); + QuestEvents.handleRaid(destroyer); + destroyer.notify(dungeonType.getSelfRaidMessage(), NotificationType.ACCOMPLISHMENT); + destroyer.notifyPeers(String.format(dungeonType.getPeerRaidMessage(), destroyer.getName()), NotificationType.SYSTEM); + } } else { dungeons.put(dungeonId, guardBlocks); } @@ -996,6 +1208,10 @@ public void destroyGuardBlock(String dungeonId, Player destroyer) { public boolean isDungeonIntact(String id) { return dungeons.containsKey(id); } + + public DungeonType getDungeonType(String id) { + return id == null ? DungeonType.PUZZLE : dungeonTypes.getOrDefault(id, DungeonType.PUZZLE); + } public boolean digBlock(int x, int y) { if(!areCoordinatesInBounds(x, y)) { @@ -1192,6 +1408,15 @@ public Block getBlock(int x, int y) { return null; } + + public Block getBlockSafe(int x, int y) { + if(areCoordinatesInBounds(x, y)) { + Chunk chunk = getChunk(x, y); + return chunk != null ? chunk.getBlock(x, y) : null; + } + + return null; + } public void removeMetaBlock(int x, int y) { setMetaBlock(x, y, 0); @@ -1455,10 +1680,34 @@ public Collection getDiscoveredParts(EcologicalMachine machine) { public Map> getDiscoveredParts() { return machineManager.getDiscoveredParts(); } - + + public boolean hasMassTeleporter() { + return machineManager.hasMassTeleporter(); + } + + public boolean hasMassSpawner() { + return machineManager.hasMassSpawner(); + } + + public EntityManager getEntityManager() { + return entityManager; + } + public MachineManager getMachineManager() { return machineManager; } + + public LiquidManager getLiquidManager() { + return liquidManager; + } + + public SteamManager getSteamManager() { + return steamManager; + } + + public DynamicsManager getDynamicsManager() { + return dynamicsManager; + } public void recordActionTime(String name) { actionHistory.put(name.toLowerCase(), OffsetDateTime.now()); @@ -1471,14 +1720,18 @@ public boolean isActionOnCooldown(String name, long cooldown, TemporalUnit unit) public Map getActionHistory() { return Collections.unmodifiableMap(actionHistory); } - + + public int getGroundHeight() { + return biome == Biome.DEEP ? -1000 : 200; + } + /** * @return The specified coordinates in a player-readable format * For example, {@code x: 200 y: 300} in a plain biome becomes {@code 800 west, 100 below} */ public String getReadableCoordinates(int x, int y) { int center = width / 2; - int surface = biome == Biome.DEEP ? -1000 : 200; + int surface = this.getGroundHeight(); String directionX = x < center ? "west" : x > center ? "east" : "central"; String directionY = y > surface ? "below" : "above"; String coordX = String.format("%s %s", Math.abs(x - center), directionX); @@ -1504,7 +1757,11 @@ protected void onChunkLoaded(Chunk chunk) { for(int y = chunkY; y < chunkY + chunk.getHeight(); y++) { // Spawn block-related entities - entityManager.trySpawnBlockEntity(x, y); + if(chunk.getBlock(x, y).getFrontItem().hasUse(ItemUseType.REVENANT_DISH)) { + entityManager.updateRevenantDish(x, y, true); + } else { + entityManager.trySpawnBlockEntity(x, y); + } Block block = chunk.getBlock(x, y); // Index front item @@ -1586,6 +1843,10 @@ public Collection getLoadedChunks() { public ChunkManager getChunkManager() { return chunkManager; } + + public GrowthManager getGrowthManager() { + return growthManager; + } public WeatherManager getWeatherManager() { return weatherManager; @@ -1679,13 +1940,16 @@ public boolean exploreArea(int x, int y, Player explorer) { if(chunksExplored[chunkIndex]) { return false; } - - if(explorer != null && y - y % chunkHeight >= surface[x - x % chunkWidth]) { + + if(explorer != null && AnticheatManager.getConfig().getExploration().shouldTrackStats(this, x, y)) { explorer.getStatistics().trackAreaExplored(); } chunksExploredCount++; - sendMessage(new ZoneExploredMessage(chunkIndex)); + if(isChunkUndergroundXY(x, y)) { + undergroundChunksExploredCount++; + } + sendMessage(new ZoneExploredMessage(chunkIndex, getExplorationProgress())); return chunksExplored[chunkIndex] = true; } @@ -1705,7 +1969,12 @@ public File getDirectory() { * @return A float between 0 and 1, where 0 is completely unexplored and 1 is fully explored. */ public float getExplorationProgress() { - return (float)getChunksExploredCount() / (numChunksWidth * numChunksHeight); + Exploration explorationRules = AnticheatManager.getConfig().getExploration(); + if(explorationRules.isIncluded(this)) { + if(explorationRules.getWorldExplorationPercent() == Exploration.Region.UNDERGROUND) return getUndergroundExplorationProgress(); + if(explorationRules.getWorldExplorationPercent() == Exploration.Region.SKY) return getSkyExplorationProgress(); + } + return getOverallExplorationProgress(); } public boolean[] getChunksExplored() { @@ -1715,13 +1984,53 @@ public boolean[] getChunksExplored() { public int getChunksExploredCount() { return chunksExploredCount; } + + public boolean isChunkUndergroundXY(int x, int y) { + return isChunkUndergroundIJ(x / chunkWidth, y / chunkHeight); + } + + private boolean isChunkUndergroundIJ(int i, int j) { + // Top middle block of chunk + return isUnderground(i * chunkWidth + chunkWidth / 2, j * chunkHeight); + } + + private float getUndergroundExplorationProgress() { + return (float)getUndergroundChunksExploredCount() / totalUndergroundChunks; + } + + private float getSkyExplorationProgress() { + return (float)(getChunksExploredCount() - getUndergroundChunksExploredCount()) + / (numChunksWidth * numChunksHeight - totalUndergroundChunks); + } + + public int getUndergroundChunksExploredCount() { + return undergroundChunksExploredCount; + } + + private float getOverallExplorationProgress() { + return (float)getChunksExploredCount() / (numChunksWidth * numChunksHeight); + } - private void recalculateChunksExploredCount() { + public void recalculateChunksExploredCount() { chunksExploredCount = 0; - - for(boolean explored : chunksExplored) { - if(explored) { - chunksExploredCount++; + undergroundChunksExploredCount = 0; + totalUndergroundChunks = 0; + + int cw = getNumChunksWidth(); + int ch = getNumChunksHeight(); + + int c = 0; + for(int j = 0; j < ch; j++) { + for(int i = 0; i < cw; i++) { + if(isChunkUndergroundIJ(i, j)) { + totalUndergroundChunks++; + } + if(chunksExplored[c++]) { + chunksExploredCount++; + if(isChunkUndergroundIJ(i, j)) { + undergroundChunksExploredCount++; + } + } } } } @@ -1795,14 +2104,24 @@ public void setAcidity(float acidity) { public float getAcidity() { return acidity; } - + + public ZoneActivity getActivity() { + return tutorial ? ZoneActivity.TUTORIAL : market ? ZoneActivity.MARKET : ZoneActivity.NONE; + } + public void setPrivate(boolean value) { this.isPrivate = value; kickAllPlayers("Accessibility status changed.", true); // The login handler will kick non-members out of the zone if the world is made private } public boolean canJoin(Player player) { - return player.isGodMode() || isPublic() || isOwner(player) || isMember(player); + return isTicking() + && ( player.isGodMode() + || (isPublic() && (!isTutorial() || player.getAchievements().isEmpty())) + || isOwner(player) + || isMember(player) + || temporarilyAllowedPlayers.contains(player.getDocumentId()) + ); } public boolean isPublic() { @@ -1834,7 +2153,51 @@ public void setPvp(boolean pvp) { public boolean isPvp() { return pvp; } - + + public void setMarket(boolean market) { + this.market = market; + } + + public boolean isMarket() { + return market; + } + + public boolean isTutorial() { + return tutorial; + } + + public MassSpawnerConfiguration getMassSpawnerConfiguration() { + return massSpawnerConfiguration; + } + + public MassTeleporterConfiguration getMassTeleporterConfiguration() { + return massTeleporterConfiguration; + } + + public WeatherMachineConfiguration getWeatherMachineConfiguration() { + return weatherMachineConfiguration; + } + + public HolographConfiguration getHolographConfiguration() { + return holographConfiguration; + } + + public ZoneRules getRules() { + return rules; + } + + public void setRules(ZoneRules rules) { + if(rules == null) { + if(isPrivate() && isOwned()) { + this.rules = ZoneRules.getPrivateDefaults(); + } else { + this.rules = new ZoneRules(); + } + } else { + this.rules = rules; + } + } + protected void setEntryCode(String entryCode) { this.entryCode = entryCode; } @@ -1856,7 +2219,7 @@ public boolean isOwned() { } public void setOwner(Player player) { - this.owner = player.getDocumentId(); + this.owner = player != null ? player.getDocumentId() : null; // Update spawn teleporter ownership for(MetaBlock block : getMetaBlocksWithItem("mechanical/zone-teleporter")) { @@ -1890,6 +2253,14 @@ public void removeMember(Player player) { } } } + + public void giveTemporaryAccess(Player player) { + temporarilyAllowedPlayers.add(player.getDocumentId()); + } + + public void removeTemporaryAccess(Player player) { + temporarilyAllowedPlayers.remove(player.getDocumentId()); + } public boolean isMember(Player player) { return members.contains(player.getDocumentId()); @@ -1922,7 +2293,28 @@ public void setModified(boolean modified) { public boolean isModified() { return modified; } - + + // Bunch of methods that exploits the fact that multiple zone updates within one zone are not concurrent, used to mitigate farming. + public double getXpMultiplier() { + return xpMultiplier; + } + + public void setXpMultiplier(double xpMultiplier) { + this.xpMultiplier = xpMultiplier; + } + + public boolean entityShouldDrop() { + return entityShouldDrop; + } + + public void setEntityShouldDrop(boolean entityShouldDrop) { + this.entityShouldDrop = entityShouldDrop; + } + + public boolean isTicking() { + return !frozen; + } + /** * @return A {@link Map} containing all the data necessary for use in {@link ConfigurationMessage}. */ @@ -1936,6 +2328,7 @@ public Map getClientConfig(Player player) { config.put("surface", surface); config.put("chunks_explored", chunksExplored); config.put("chunks_explored_count", getChunksExploredCount()); + config.put("chunks_explored_percent", getExplorationProgress()); config.put("private", isPrivate); config.put("protected", isProtected(player)); config.put("protected_player", isProtected(player)); diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivity.java b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivity.java new file mode 100644 index 00000000..4d6537e1 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivity.java @@ -0,0 +1,10 @@ +package brainwine.gameserver.zone; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; + +public enum ZoneActivity { + @JsonEnumDefaultValue + NONE, + MARKET, + TUTORIAL, +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivityConfiguration.java b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivityConfiguration.java new file mode 100644 index 00000000..1d79838b --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivityConfiguration.java @@ -0,0 +1,27 @@ +package brainwine.gameserver.zone; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.HashMap; +import java.util.Map; + +public class ZoneActivityConfiguration { + @JsonProperty("primary_zone") + private String primaryZone = null; + @JsonProperty("player_item_limits") + private Map playerItemLimits = new HashMap<>(); + @JsonProperty("player_inventory_limits") + private Map playerInventoryLimits = new HashMap<>(); + + public String getPrimaryZone() { + return primaryZone; + } + + public Map getPlayerItemLimits() { + return playerItemLimits; + } + + public Map getPlayerInventoryLimits() { + return playerInventoryLimits; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivityManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivityManager.java new file mode 100644 index 00000000..a155b0a0 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneActivityManager.java @@ -0,0 +1,64 @@ +package brainwine.gameserver.zone; + +import brainwine.gameserver.GameServer; +import brainwine.gameserver.resource.ResourceFinder; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.net.URL; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static brainwine.shared.LogMarkers.SERVER_MARKER; + +public class ZoneActivityManager { + private static final Logger logger = LogManager.getLogger(); + + private Map configs = new HashMap<>(); + + private void loadConfiguration() { + try { + logger.info(SERVER_MARKER, "Loading zone activity configuration..."); + URL url = ResourceFinder.getResourceUrl("activities.json"); + Map loot = JsonHelper.readValue(url, new TypeReference>(){}); + configs.putAll(loot); + } catch (IOException e) { + logger.error(SERVER_MARKER, "Failed to load zone activity configuration", e); + } + } + + public ZoneActivityManager() { + loadConfiguration(); + } + + public Zone getPrimaryZone(ZoneActivity activity) { + ZoneActivityConfiguration config = configs.get(activity); + + if(config == null) return null; + + if(config.getPrimaryZone() == null) return null; + + return GameServer.getInstance().getZoneManager().getZone(config.getPrimaryZone()); + } + + public Map getPlayerItemLimits(ZoneActivity activity) { + ZoneActivityConfiguration config = configs.get(activity); + + if(config == null) return Collections.emptyMap(); + + return config.getPlayerItemLimits(); + } + + public Map getPlayerInventoryLimits(ZoneActivity activity) { + ZoneActivityConfiguration config = configs.get(activity); + + if(config == null) return Collections.emptyMap(); + + return config.getPlayerInventoryLimits(); + } + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneConfigFile.java b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneConfigFile.java index f45cddb9..e98f406a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneConfigFile.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneConfigFile.java @@ -31,42 +31,68 @@ public class ZoneConfigFile { @JsonSetter(nulls = Nulls.SKIP) private float acidity = 1.0F; - + @JsonSetter(value = "private") private boolean isPrivate; - + @JsonSetter(value = "protected") private boolean isProtected; - + @JsonSetter(nulls = Nulls.SKIP) private boolean pvp; + + @JsonSetter(nulls = Nulls.SKIP) + private boolean market; + + @JsonSetter(nulls = Nulls.SKIP) + private boolean tutorial; @JsonSetter(nulls = Nulls.SKIP) private String entryCode; @JsonSetter(nulls = Nulls.SKIP) private String owner; - + @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) private List members = new ArrayList<>(); - + @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) private Map> discoveredParts = new HashMap<>(); @JsonSetter(nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) private Map actionHistory = new HashMap<>(); - + @JsonSetter(nulls = Nulls.SKIP) private OffsetDateTime creationDate = OffsetDateTime.now(); @JsonSetter(nulls = Nulls.SKIP) private OffsetDateTime lastActiveDate = OffsetDateTime.now(); - + + @JsonSetter(nulls = Nulls.SKIP) + private MassSpawnerConfiguration massSpawnerConfiguration = new MassSpawnerConfiguration(); + + @JsonSetter(nulls = Nulls.SKIP) + private MassTeleporterConfiguration massTeleporterConfiguration = new MassTeleporterConfiguration(); + + @JsonSetter(nulls = Nulls.SKIP) + private WeatherMachineConfiguration weatherMachineConfiguration = new WeatherMachineConfiguration(); + + @JsonSetter(nulls = Nulls.SKIP) + private HolographConfiguration holographConfiguration = new HolographConfiguration(); + + @JsonSetter(nulls = Nulls.SKIP) + private ZoneRules rules = null; + @JsonCreator private ZoneConfigFile(@JsonProperty(value = "name", required = true) String name, + @JsonProperty("tutorial") boolean tutorial, + @JsonProperty("market") boolean market, + @JsonProperty("activity") ZoneActivity activity, @JsonProperty(value = "width", required = true) int width, @JsonProperty(value = "height", required = true) int height) { this.name = name; + this.tutorial = tutorial || activity == ZoneActivity.TUTORIAL; + this.market = market || activity == ZoneActivity.MARKET; this.width = width; this.height = height; } @@ -80,14 +106,21 @@ public ZoneConfigFile(Zone zone) { this.isPrivate = zone.isPrivate(); this.isProtected = zone.isProtected(); this.pvp = zone.isPvp(); + this.market = zone.isMarket(); + this.tutorial = zone.isTutorial(); this.entryCode = zone.getEntryCode(); this.owner = zone.getOwner(); this.members = zone.getMembers(); this.discoveredParts = zone.getDiscoveredParts(); this.actionHistory = zone.getActionHistory(); this.creationDate = zone.getCreationDate(); + this.massSpawnerConfiguration = zone.getMassSpawnerConfiguration(); + this.massTeleporterConfiguration = zone.getMassTeleporterConfiguration(); + this.weatherMachineConfiguration = zone.getWeatherMachineConfiguration(); + this.holographConfiguration = zone.getHolographConfiguration(); + this.rules = zone.getRules(); } - + public String getName() { return name; } @@ -107,11 +140,11 @@ public int getHeight() { public float getAcidity() { return acidity; } - + public boolean isPrivate() { return isPrivate; } - + public boolean isProtected() { return isProtected; } @@ -119,6 +152,14 @@ public boolean isProtected() { public boolean isPvp() { return pvp; } + + public boolean isMarket() { + return market; + } + + public boolean isTutorial() { + return tutorial; + } public String getEntryCode() { return entryCode; @@ -127,11 +168,11 @@ public String getEntryCode() { public String getOwner() { return owner; } - + public List getMembers() { return members; } - + public Map> getDiscoveredParts() { return discoveredParts; } @@ -139,7 +180,7 @@ public Map> getDiscoveredParts() { public Map getActionHistory() { return actionHistory; } - + public OffsetDateTime getCreationDate() { return creationDate; } @@ -147,4 +188,24 @@ public OffsetDateTime getCreationDate() { public OffsetDateTime getLastActiveDate() { return lastActiveDate; } + + public MassSpawnerConfiguration getMassSpawnerConfiguration() { + return massSpawnerConfiguration; + } + + public MassTeleporterConfiguration getMassTeleporterConfiguration() { + return massTeleporterConfiguration; + } + + public WeatherMachineConfiguration getWeatherMachineConfiguration() { + return weatherMachineConfiguration; + } + + public HolographConfiguration getHolographConfiguration() { + return holographConfiguration; + } + + public ZoneRules getRules() { + return rules; + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneManager.java index afa056d7..e53e1d0a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneManager.java @@ -1,5 +1,6 @@ package brainwine.gameserver.zone; +import static brainwine.gameserver.player.NotificationType.SYSTEM; import static brainwine.shared.LogMarkers.SERVER_MARKER; import java.io.File; @@ -8,15 +9,22 @@ import java.time.OffsetDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; +import brainwine.gameserver.Fake; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MathUtils; +import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.msgpack.jackson.dataformat.MessagePackFactory; @@ -32,7 +40,17 @@ import brainwine.shared.TokenGenerator; public class ZoneManager { - + private final double ZONE_EXPLORATION_THRESHOLD = 0.25; + private final OffsetDateTime ZONE_EXPLORATION_CUTOFF_TIME = OffsetDateTime.of(2025, 10, 25, 2, 15, 0, 0, OffsetDateTime.now().getOffset()); + private final double UNEXPLORED_ZONES_AT_A_TIME = 1; + // zero players interval has to be greater than the min generation interval + final double MIN_GENERATION_INTERVAL_SECONDS = 30 * 60; + final double GENERATION_INTERVAL_ZERO_PLAYERS_SECONDS = 30 * 60; + // player count influence has to be positive and a greater value means + // more players are needed for a given increase in generation rate + final double PLAYER_COUNT_INFLUENCE = 16; + final int UNEXPLORED_XL_PLAYERS = 8; + private static final Logger logger = LogManager.getLogger(); private final ObjectMapper mapper = new ObjectMapper(new MessagePackFactory()) .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); @@ -40,9 +58,12 @@ public class ZoneManager { private Map zones = new HashMap<>(); private Map zonesByName = new HashMap<>(); private Map entryCodes = new HashMap<>(); - private long lastZoneGenerationTime = System.currentTimeMillis(); + private OffsetDateTime unexploredHardBiomeTrackingStart = OffsetDateTime.now().minusMinutes(30); + private long lastZoneGenerationTime = System.currentTimeMillis() - (long)(GENERATION_INTERVAL_ZERO_PLAYERS_SECONDS * 1000); private boolean generatingZone = false; - + private Set unexploredZones = new HashSet<>(); + private Biome lastGeneratedBiome = Biome.PLAIN; + public ZoneManager() { logger.info(SERVER_MARKER, "Loading zone data ..."); dataDir.mkdirs(); @@ -75,49 +96,123 @@ public void tryGenerateDefaultZone() { public void tick(float deltaTime) { for(Zone zone : getZones()) { - zone.tick(deltaTime); + if(zone.isTicking()) zone.tick(deltaTime); } - long timeSinceLastGeneration = (System.currentTimeMillis() - lastZoneGenerationTime) / 1000; + tryGenerateUnexploredZone(); + } + + public void tryGenerateUnexploredZone() { + // Return if a zone is already being generated + if(generatingZone) return; + + long currentTime = System.currentTimeMillis(); + double timeSinceLastGeneration = (currentTime - lastZoneGenerationTime) / 1000.0; - // zero players interval has to be greater than the min generation interval - final long MIN_GENERATION_INTERVAL_SECONDS = 10 * 60; - final long GENERATION_INTERVAL_ZERO_PLAYERS_SECONDS = 30 * 60; - // player count influence has to be positive and a greater value means - // more players are needed for a given increase in generation rate - final long PLAYER_COUNT_INFLUENCE = 16; + // Check if sufficient time has passed since last generation + if(timeSinceLastGeneration < MIN_GENERATION_INTERVAL_SECONDS) return; - if (!generatingZone && timeSinceLastGeneration > MIN_GENERATION_INTERVAL_SECONDS) { - int playerCount = GameServer.getInstance().getPlayerManager().getOnlinePlayerCount(); - long requiredInterval = Math.max( + int playerCount = GameServer.getInstance().getPlayerManager().getOnlinePlayerCount(); + double requiredInterval = Math.max(MIN_GENERATION_INTERVAL_SECONDS, MathUtils.lerp( + GENERATION_INTERVAL_ZERO_PLAYERS_SECONDS, MIN_GENERATION_INTERVAL_SECONDS, - GENERATION_INTERVAL_ZERO_PLAYERS_SECONDS - (playerCount - 1) * (GENERATION_INTERVAL_ZERO_PLAYERS_SECONDS - MIN_GENERATION_INTERVAL_SECONDS) / PLAYER_COUNT_INFLUENCE - ); - - if (timeSinceLastGeneration > requiredInterval) { - if (shouldGenerateUnexploredZone() && !generatingZone) { - generatingZone = true; - Biome biome = Biome.getRandomBiome(); - ZoneGenerator generator = ZoneGenerator.getZoneGenerator(biome); - generator.generateZoneAsync(biome, zone -> { - if (zone != null) { - this.addZone(zone); - lastZoneGenerationTime = System.currentTimeMillis(); - } else { - logger.warn(SERVER_MARKER, "Automatic zone generation failed. See the previous logs for more information."); - } - generatingZone = false; - }); - } + (playerCount - 1) / PLAYER_COUNT_INFLUENCE)); + + if(timeSinceLastGeneration < requiredInterval) return; + + boolean xl = shouldGenerateXlUnexploredZone(); + if(shouldGenerateUnexploredZone()) { + List biomeOptions = Arrays.stream(Biome.values()).collect(Collectors.toList()); + + biomeOptions.remove(lastGeneratedBiome); + if(lastGeneratedBiome == Biome.HELL || lastGeneratedBiome == Biome.DEEP) { + biomeOptions.remove(Biome.HELL); + biomeOptions.remove(Biome.DEEP); + } + + Biome biome = biomeOptions.get((int)(biomeOptions.size() * Math.random())); + lastGeneratedBiome = biome; + + int width, height; + + if(xl) { + width = biome == Biome.DEEP ? 1800 : 3000; + height = biome == Biome.DEEP ? 1500 : 800; + } else { + width = biome == Biome.DEEP ? 1200 : 2000; + height = biome == Biome.DEEP ? 1000 : 600; } + + ZoneGenerator generator = ZoneGenerator.getZoneGenerator(biome); + generatingZone = true; + lastZoneGenerationTime = System.currentTimeMillis(); + generator.generateZoneAsync(biome, width, height, zone -> { + if (zone != null) { + this.addZone(zone); + GameServer.getInstance().getPusher().handleZoneDiscovered(zone); + if(GameServer.getInstance().getPlayerManager() != null) for(Player player : GameServer.getInstance().getPlayerManager().getPlayers()) { + player.notify(String.format("A new zone has been discovered! Check out \"%s\"!", zone.getName()), SYSTEM); + } + } else { + logger.warn(SERVER_MARKER, "Automatic zone generation failed. See the previous logs for more information."); + } + generatingZone = false; + }); } } - + + /** + * Have too many people explored the last zone so we should generate an XL zone next? + * + * @return {@code true} if 8 or more people have explored the last zone, otherwise {@code false}. + */ + public boolean shouldGenerateXlUnexploredZone() { + return unexploredZones.stream().anyMatch( + id -> getZone(id).getEntityManager().getCurrentMaxPlayers() >= UNEXPLORED_XL_PLAYERS + ); + } + + /** + * Should the automatic zone generator generate a new zone? + * + * @return {@code true} if all unowned worlds are at least 40% explored, otherwise {@code false}. + */ + public boolean shouldGenerateUnexploredZone() { + unexploredZones.removeIf(zone -> (!shouldTrackExplorationOfZone(getZone(zone))) || checkExplorationOfZone(getZone(zone))); + + return unexploredZones.size() < UNEXPLORED_ZONES_AT_A_TIME; + } + + public boolean checkExplorationOfZone(Zone zone) { + return zone.getCreationDate().isBefore(ZONE_EXPLORATION_CUTOFF_TIME) || zone.getExplorationProgress() >= ZONE_EXPLORATION_THRESHOLD; + } + + public boolean shouldTrackExplorationOfZone(Zone zone) { + return zone != null + && !zone.isPrivate() + && !zone.isOwned() + && ( + zone.getCreationDate().isAfter(unexploredHardBiomeTrackingStart) + || zone.getBiome() != Biome.HELL && zone.getBiome() != Biome.DEEP + ); + } + + public Set getUnexploredZones() { + return unexploredZones; + } + public void onShutdown() { for(Zone zone : zones.values()) { saveZone(zone); zone.getChunkManager().closeStream(); } + + logger.info("Deleting any marked zones..."); + for(Zone zone : zones.values()) { + if(zone.getRules().isDeleted()) { + deleteZone(zone); + } + } } private void loadZone(File file) { @@ -192,20 +287,44 @@ public void addZone(Zone zone) { zones.put(id, zone); zonesByName.put(name.toLowerCase(), zone); - + if(shouldTrackExplorationOfZone(zone) && !checkExplorationOfZone(zone)) { + unexploredZones.add(zone.getDocumentId()); + } + if(zone.hasEntryCode()) { entryCodes.put(zone.getEntryCode(), zone); } } - /** - * Should the automatic zone generator generate a new zone? - * TODO could be slow, it might be a better idea to just check the most recently auto-generated zone instead. - * - * @return {@code true} if all unowned worlds are at least 40% explored, otherwise {@code false}. - */ - public boolean shouldGenerateUnexploredZone() { - return getZones().stream().filter(zone -> !zone.isOwned()).allMatch(zone -> zone.getExplorationProgress() >= 0.4); + public static void markZoneForDeletion(Zone zone, Player executor) { + zone.getRules().setDeleted(true); + + List members = new ArrayList<>(zone.getMembers()); + for(String memberId : members) { + Player member = GameServer.getInstance().getPlayerManager().getPlayerById(memberId); + if(member != null) zone.removeMember(member); + } + zone.setOwner(null); + + zone.setPrivate(true); + if(executor != null) { + logger.info("Zone " + zone.getName() + " is marked for deletion by " + executor.getName() + "."); + } else { + logger.info("Zone " + zone.getName() + " is marked for deletion."); + } + } + + public void deleteZone(Zone zone) { + zone.freeze("This zone is being deleted."); + + File folder = new File(dataDir, zone.getDocumentId()); + if(folder.isDirectory()) { + try { + FileUtils.deleteDirectory(folder); + } catch(IOException e) { + logger.warn("Failed to delete deleted zone " + zone.getName() + "'s folder.", e); + } + } } /** @@ -230,26 +349,26 @@ public boolean renameZone(Zone zone, String name) { /** * Generates a new entry code for the specified zone and re-indexes it. - * + * * @return {@code true} if the entry code was generated successfully, otherwise {@code false}. */ public boolean issueEntryCode(Zone zone) { String entryCode = String.format("z%s", TokenGenerator.generateToken(6, entryCodes::containsKey)); String currentCode = zone.getEntryCode(); - + if(entryCode == null) { return false; } - + if(currentCode != null && !entryCodes.remove(currentCode, zone)) { logger.warn(SERVER_MARKER, "Could not unindex entry code {} for zone {}", currentCode, zone.getDocumentId()); } - + zone.setEntryCode(entryCode); entryCodes.put(entryCode, zone); return true; } - + public Zone getZone(String id) { return zones.get(id); } @@ -265,13 +384,25 @@ public Zone getZoneByName(String name) { public Zone getZoneByEntryCode(String entryCode) { return entryCodes.get(entryCode); } - + + /** + * @return The primary tutorial zone configured in activities.json. + */ + public Zone findTutorialZone() { + Zone zone = GameServer.getInstance().getZoneActivityManager().getPrimaryZone(ZoneActivity.TUTORIAL); + return zone != null ? zone : findBeginnerZone(); + } + /** * @return A public, non-owned, recently-generated temperate world (with players if possible) or {@code null} if no such world exists. */ public Zone findBeginnerZone() { + List unexplored = new ArrayList<>(unexploredZones); + if(!unexplored.isEmpty()) { + return getZone(Fake.pickFromList(unexplored)); + } return zones.values().stream() - .filter(zone -> zone.isPublic() && !zone.isOwned() && zone.isUnexplored() && zone.getBiome() == Biome.PLAIN) + .filter(zone -> zone.isPublic() && !zone.isOwned() && zone.isUnexplored() && zone.getBiome() == Biome.PLAIN && zone.getActivity() != ZoneActivity.TUTORIAL) .sorted((a, b) -> b.getCreationDate().compareTo(a.getCreationDate())) .limit(50) .sorted((a, b) -> Integer.compare(b.getPlayerCount(), a.getPlayerCount())) diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/ZoneRules.java b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneRules.java new file mode 100644 index 00000000..4b9e60ff --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/ZoneRules.java @@ -0,0 +1,51 @@ +package brainwine.gameserver.zone; + +import brainwine.gameserver.util.RuleRecord; + +public class ZoneRules extends RuleRecord { + @Rule("purgeable") + private boolean purgeable = true; + @Rule("auto-clean") + private boolean autoCleanEnabled = true; + @Rule(value="auto-clean-duration", minValue=500, maxValue=120000) + private int autoCleanDuration = 60000; + @Rule("do-hostile-entity-spawns") + private boolean hostileEntitySpawnsEnabled = false; + @Rule(value="do-peaceful-entity-spawns", adminOnly=true) + private boolean peacefulEntitySpawnsEnabled = false; + @Rule(value="deleted", adminOnly = true) + private boolean deleted = false; + + public static ZoneRules getPrivateDefaults() { + ZoneRules rules = new ZoneRules(); + + rules.autoCleanEnabled = false; + rules.purgeable = false; + + return rules; + } + + public boolean isAutoCleanEnabled() { + return autoCleanEnabled; + } + + public int getAutoCleanDuration() { + return autoCleanDuration; + } + + public boolean isHostileEntitySpawnsEnabled() { + return hostileEntitySpawnsEnabled; + } + + public boolean isPeacefulEntitySpawnsEnabled() { + return peacefulEntitySpawnsEnabled; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/EvokerInvasion.java b/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/EvokerInvasion.java new file mode 100644 index 00000000..45c1ad38 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/EvokerInvasion.java @@ -0,0 +1,45 @@ +package brainwine.gameserver.zone.dynamics; + +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.util.WeightedMap; +import brainwine.gameserver.zone.Zone; + +import java.util.Arrays; +import java.util.List; + +public class EvokerInvasion extends Invasion { + private Player target; + + private static WeightedMap getInvaderTable(int difficulty) { + if(difficulty > 3) { + return new WeightedMap<>(MapHelper.map( + String.class, Double.class, + "brains/small", 15.0, + "brains/medium", 2.0, + "brains/medium-dire", 1.0 + )); + } else { + return new WeightedMap<>(MapHelper.map( + String.class, Double.class, + "brains/small", 15.0 + )); + } + } + + public EvokerInvasion(Zone zone, Player target, int difficulty, int totalWaves) { + super(zone, getInvaderTable(difficulty), totalWaves); + this.target = target; + } + + @Override + public List getTargets() { + return Arrays.asList(target); + } + + @Override + public int maxInstances() { + return 1; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/Invasion.java b/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/Invasion.java new file mode 100644 index 00000000..f2d1af42 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/Invasion.java @@ -0,0 +1,109 @@ +package brainwine.gameserver.zone.dynamics; + +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.entity.npc.Npc; +import brainwine.gameserver.item.Layer; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.util.MathUtils; +import brainwine.gameserver.util.Vector2i; +import brainwine.gameserver.util.WeightedMap; +import brainwine.gameserver.zone.Zone; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class Invasion extends ZoneDynamic { + private long lastInvasionWaveAt = System.currentTimeMillis(); + private long timeUntilNextInvasionWave; + private int currentInvasionWave = 0; + private int totalWaves = 4; + private final List invaders = new ArrayList<>(); + private WeightedMap invaderTable; + + public Invasion(Zone zone, WeightedMap invaderTable, int totalWaves) { + super(zone); + this.invaderTable = invaderTable; + this.totalWaves = totalWaves; + } + + public List getTargets() { + return new ArrayList<>(); + } + + public int getNumInvaders() { + return currentInvasionWave >= 3 && Math.random() < 0.5 ? 2 : 1; + } + + @Override + public void tick(float deltaTime) { + if(currentInvasionWave >= totalWaves) return; + currentInvasionWave = Math.max(0, currentInvasionWave); + + List targets = getTargets().stream() + .filter(p -> (p.getZone() == zone && (!p.isPlayer() || ((Player)p).isOnline() && !((Player)p).isGodMode()))) + .collect(Collectors.toList()); + if(targets.isEmpty()) { + currentInvasionWave = totalWaves; + return; + } + + if(lastInvasionWaveAt + timeUntilNextInvasionWave < System.currentTimeMillis()) { + int numInvaders = getNumInvaders(); + + List eligiblePositions = new ArrayList<>(8); + for(Entity currentInvasionTarget : targets) { + if(currentInvasionTarget.isDead()) continue; + + boolean found = false; + for(int x = -1; x <= 1; x++) { + for(int y = -1; y <= 1; y++) { + if(x == 0 && y == 0) continue; + int blockX = currentInvasionTarget.getBlockX() + x; + int blockY = currentInvasionTarget.getBlockY() + y; + if(zone.areCoordinatesInBounds(blockX, blockY) && !zone.isBlockOccupied(blockX, blockY, Layer.FRONT)) { + eligiblePositions.add(new Vector2i(blockX, blockY)); + found = true; + } + } + } + if(!found) { + eligiblePositions.add(new Vector2i(currentInvasionTarget.getBlockX(), currentInvasionTarget.getBlockY())); + } + } + + if(!eligiblePositions.isEmpty()) for(int i = 0; i < numInvaders; i++) { + Vector2i pos = eligiblePositions.get((int)(Math.random() * eligiblePositions.size())); + Npc npc = zone.spawnEntity(invaderTable.next(), pos.getX(), pos.getY()); + this.invaders.add(npc.getId()); + } + + // Determine interval until next wave + double minInterval = new double[] {3000, 1000, 500, 0}[Math.min(3, currentInvasionWave)]; + double maxInterval = new double[] {4000, 2000, 1500, 1000}[Math.min(3, currentInvasionWave)]; + timeUntilNextInvasionWave = (long) MathUtils.lerp(minInterval, maxInterval, Math.random()); + + currentInvasionWave++; + lastInvasionWaveAt = System.currentTimeMillis(); + } + } + + @Override + public boolean isFinished() { + return currentInvasionWave >= totalWaves && ( + zone.getPlayers().isEmpty() + || invaders.stream().allMatch(id -> zone.getEntity(id) == null || zone.getEntity(id).isDead()) + ); + } + + @Override + public void close() { + for(int entityId : invaders) { + Entity e = zone.getEntity(entityId); + if(e != null) { + zone.spawnEffect(e.getX(), e.getY(), "bomb-teleport", 4); + e.setHealth(0.0f); + } + } + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/SummonedInvasion.java b/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/SummonedInvasion.java new file mode 100644 index 00000000..6fd70a48 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/SummonedInvasion.java @@ -0,0 +1,46 @@ +package brainwine.gameserver.zone.dynamics; + +import brainwine.gameserver.entity.Entity; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.util.WeightedMap; +import brainwine.gameserver.zone.Zone; + +import java.util.List; + +public class SummonedInvasion extends Invasion { + List targets; + + private static WeightedMap getInvaderTable(int difficulty) { + if(difficulty > 3) { + return new WeightedMap<>(MapHelper.map( + String.class, Double.class, + "revenant-lord", 1.0 + )); + } else { + return new WeightedMap<>(MapHelper.map( + String.class, Double.class, + "revenant", 1.0 + )); + } + } + + public SummonedInvasion(Zone zone, List targets, int difficulty, int totalWaves) { + super(zone, getInvaderTable(difficulty), totalWaves); + this.targets = targets; + } + + @Override + public List getTargets() { + return targets; + } + + @Override + public int maxInstances() { + return 0; + } + + @Override + public boolean isFinished() { + return startTime + 600_000 < System.currentTimeMillis(); + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/ZoneDynamic.java b/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/ZoneDynamic.java new file mode 100644 index 00000000..dfdde7e5 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/dynamics/ZoneDynamic.java @@ -0,0 +1,35 @@ +package brainwine.gameserver.zone.dynamics; + +import brainwine.gameserver.zone.Zone; + +public class ZoneDynamic { + protected long startTime; + protected Zone zone; + + public ZoneDynamic(Zone zone) { + startTime = System.currentTimeMillis(); + this.zone = zone; + } + + /**Called every frame.*/ + public void tick(float deltaTime) {} + + /**Start time estimated by calling System.currentTimeMillis()*/ + public long getStartTime() { + return startTime; + } + + /**Return true if the invasion has finished so the zone can stop tracking it.*/ + public boolean isFinished() { + return true; + } + + /**Return a number greater than 0 if there can be only a given number of ongoing dynamics of type.*/ + public int maxInstances() { + return 1; + } + + /**Called immediately after the dynamic is stopped tracking.*/ + public void close() {} + +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorConfig.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorConfig.java index 9fbbd197..c751874b 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorConfig.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorConfig.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import brainwine.gameserver.zone.gen.sky.SkyDecorator; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSetter; @@ -43,6 +44,9 @@ public class GeneratorConfig { private OreDeposit[] oreDeposits = {}; private List globalSurfaceDecorators = new ArrayList<>(); private List globalCaveDecorators = new ArrayList<>(); + private List globalSkyDecorators = new ArrayList<>(); + private double skyDecorationDistance = 35.0; + private int skyChunkWidth = 200; private WeightedMap surfaceRegionTypes = new WeightedMap<>(); private List caveTypes = new ArrayList<>(); @@ -131,6 +135,21 @@ public List getGlobalSurfaceDecorators() { public List getGlobalCaveDecorators() { return globalCaveDecorators; } + + @JsonSetter(value = "global_sky_decorators", nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) + public List getGlobalSkyDecorators() { + return globalSkyDecorators; + } + + @JsonSetter(value = "sky_decoration_distance", nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) + public double getSkyDecorationDistance() { + return skyDecorationDistance; + } + + @JsonSetter(value = "sky_chunk_width", nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) + public int getSkyChunkWidth() { + return skyChunkWidth; + } @JsonSetter(value = "surface_region_types", nulls = Nulls.SKIP, contentNulls = Nulls.SKIP) private void setSurfaceRegionTypes(Map surfaceRegionTypes) { diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorContext.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorContext.java index dac32a47..25aa92ab 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorContext.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/GeneratorContext.java @@ -85,7 +85,8 @@ public boolean placePrefabSurface(Prefab prefab, int x) { } // Shitty foundation check, doesn't take potential replacements into account. Oh well! - if(prefab.getBlocks()[(height - 1) * width + x1].getFrontItem().isWhole()) { + int groundLevel = height - prefab.getSinking() - 1; + if(prefab.getBlocks()[groundLevel * width + x1].getFrontItem().isWhole()) { if(startX == -1) { startX = x + x1; highestPoint = lowestPoint = surface[startX]; @@ -115,15 +116,36 @@ public boolean placePrefabSurface(Prefab prefab, int x) { // Compromise around the middle! int y = highestPoint - height + (lowestPoint - highestPoint) / 2; y = Math.max(1, Math.min(y, getHeight() - prefab.getHeight() - 3)); + + // Sink the prefab into the ground. + y += prefab.getSinking(); // Place the prefab and generate scaffolding if successful if(placePrefab(prefab, x, y)) { + if(prefab.getSinking() > 0) { + placeScaffoldingAroundBasement(prefab, startX, y, endX - startX + 1, prefab.isRuin()); + } placeScaffolding(startX, y + height, endX - startX + 1, prefab.isRuin()); return true; } return false; } + + private void placeScaffoldingAroundBasement(Prefab prefab, int startX, int y, int width, boolean ruin) { + int groundLevel = y + prefab.getHeight() - prefab.getSinking(); + for(int i = startX; i < startX + width; i++) { + int j = Math.min(y + prefab.getHeight() - 1, getSurface(i) - 1); + // Place scaffolding all the way up until it hits the prefab + while(j >= groundLevel && isAir(i, j, Layer.BASE) && isAir(i, j, Layer.BACK) && !isWhole(i, j, Layer.FRONT)) { + if(!ruin || SimplexNoise.noise2(seed, i / 8.0, j / 8.0, 2) <= 0.4) { + updateBlock(i, j, Layer.BACK, "back/scaffold-decayed"); + } + + j--; + } + } + } private void placeScaffolding(int x, int y, int width, boolean ruin) { for(int i = x; i < x + width; i++) { diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/caves/ItemCaveDecorator.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/caves/ItemCaveDecorator.java index 9eb1d1b9..44ca9f7f 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/caves/ItemCaveDecorator.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/caves/ItemCaveDecorator.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.Map; +import brainwine.gameserver.item.Layer; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -10,6 +11,7 @@ import brainwine.gameserver.item.ModType; import brainwine.gameserver.util.Vector2i; import brainwine.gameserver.util.WeightedMap; +import brainwine.gameserver.zone.Block; import brainwine.gameserver.zone.gen.GeneratorContext; public class ItemCaveDecorator extends CaveDecorator { @@ -90,8 +92,34 @@ public void decorate(GeneratorContext ctx, Cave cave) { mod = ctx.isSolid(x - 1, y) ? 1 : 3; } } - + + // Clear anything that might cover up the item + for(int i = 0; i < item.getBlockWidth(); i++) { + for(int j = 0; j < item.getBlockHeight(); j++) { + ctx.updateBlock(x + i, y - j, Layer.FRONT, Item.AIR); + } + } + ctx.updateBlock(block.getX(), block.getY(), item.getLayer(), item, mod); + + // Get the supporting block and repeat it towards the right if the decoration is floating + if(this.floor && item.getBlockWidth() > 1) { + Block bottomLeft = ctx.getBlock(x, y + 1); + Block bottomRight = ctx.getBlock(x + item.getBlockWidth() - 1, y + 1); + Item toRepeat = bottomLeft != null && bottomLeft.isSolid() + ? bottomLeft.getFrontItem() + : bottomRight != null ? bottomRight.getFrontItem() : Item.get(512); + for(int i = 0; i < item.getBlockWidth(); i++) { + if(ctx.inBounds(i + x, y + 1)) { + if(ctx.isSolid(i + x, y + 1)) { + toRepeat = ctx.getBlock(i + x, y + 1).getFrontItem(); + } else { + ctx.updateBlock(i + x, y + 1, Layer.FRONT, toRepeat); + } + } + } + } + itemCount++; // Stop if we've reached the maximum amount of items diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/sky/SkyDecorator.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/sky/SkyDecorator.java new file mode 100644 index 00000000..4da0d24c --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/sky/SkyDecorator.java @@ -0,0 +1,53 @@ +package brainwine.gameserver.zone.gen.sky; + +import brainwine.gameserver.zone.gen.GeneratorContext; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(name = "structure", value = StructureSkyDecorator.class), +}) +public abstract class SkyDecorator { + @JsonProperty("chance") + private double chance = 1.0; + + @JsonProperty("min_depth") + private double minDepth = 0.0; + + @JsonProperty("max_depth") + private double maxDepth = 1.0; + + @JsonProperty("min_surface_clearance") + private int minSurfaceClearance = 0; + + @JsonProperty("max_surface_clearance") + private int maxSurfaceClearance = Integer.MAX_VALUE; + + @JsonCreator + protected SkyDecorator() {} + + public abstract void decorate(GeneratorContext ctx, int x, int y); + + public double getChance() { + return chance; + } + + public double getMinDepth() { + return minDepth; + } + + public double getMaxDepth() { + return maxDepth; + } + + public int getMinSurfaceClearance() { + return minSurfaceClearance; + } + + public int getMaxSurfaceClearance() { + return maxSurfaceClearance; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/sky/StructureSkyDecorator.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/sky/StructureSkyDecorator.java new file mode 100644 index 00000000..cc690aef --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/sky/StructureSkyDecorator.java @@ -0,0 +1,38 @@ +package brainwine.gameserver.zone.gen.sky; + +import brainwine.gameserver.prefab.Prefab; +import brainwine.gameserver.util.WeightedMap; +import brainwine.gameserver.zone.gen.GeneratorContext; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class StructureSkyDecorator extends SkyDecorator { + @JsonProperty("prefabs") + protected WeightedMap prefabs = new WeightedMap<>(); + + @JsonCreator + protected StructureSkyDecorator() {} + + @Override + public void decorate(GeneratorContext ctx, int x, int y) { + if(!prefabs.isEmpty()) { + place(prefabs.next(ctx.getRandom()), ctx, x, y); + } + } + + public static void place(Prefab prefab, GeneratorContext ctx, int x, int y) { + if(prefab == null) return; + int clearance = 2; + int minX = Math.max(clearance, x - prefab.getWidth() + 1); + int maxX = Math.min(ctx.getWidth() - prefab.getWidth() - clearance, x); + int minY = Math.max(clearance, y - prefab.getHeight() + 1); + int maxY = Math.min(ctx.getHeight() - prefab.getHeight() - clearance, y); + x = minX + (int)(ctx.nextDouble() * (maxX - minX)); + y = minY + (int)(ctx.nextDouble() * (maxY - minY)); + ctx.placePrefab(prefab, x, y); + } + + public WeightedMap getPrefabs() { + return prefabs; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/StructureGeneratorTask.java b/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/StructureGeneratorTask.java index 4384e25a..9574bad0 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/StructureGeneratorTask.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/gen/tasks/StructureGeneratorTask.java @@ -3,13 +3,17 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.stream.Stream; import java.util.stream.Collectors; import brainwine.gameserver.item.Item; import brainwine.gameserver.item.Layer; import brainwine.gameserver.prefab.Prefab; +import brainwine.gameserver.util.MathUtils; import brainwine.gameserver.util.Vector2i; import brainwine.gameserver.util.WeightedMap; import brainwine.gameserver.zone.Biome; @@ -23,6 +27,8 @@ import brainwine.gameserver.zone.gen.caves.StructureCaveDecorator; import brainwine.gameserver.zone.gen.models.SpecialStructure; import brainwine.gameserver.zone.gen.models.TerrainType; +import brainwine.gameserver.zone.gen.sky.SkyDecorator; +import brainwine.gameserver.zone.gen.sky.StructureSkyDecorator; import brainwine.gameserver.zone.gen.surface.StructureSurfaceDecorator; import brainwine.gameserver.zone.gen.surface.SurfaceDecorator; import brainwine.gameserver.zone.gen.surface.SurfaceRegion; @@ -37,6 +43,9 @@ public class StructureGeneratorTask implements GeneratorTask { private final SpecialStructure[] specialStructures; private final List globalSurfaceDecorators; private final List globalCaveDecorators; + private final List globalSkyDecorators; + private final double skyDecorationDistance; + private final int skyChunkWidth; public StructureGeneratorTask(GeneratorConfig config) { filled = config.getTerrainType() == TerrainType.FILLED; @@ -47,6 +56,9 @@ public StructureGeneratorTask(GeneratorConfig config) { specialStructures = config.getSpecialStructures(); globalSurfaceDecorators = config.getGlobalSurfaceDecorators(); globalCaveDecorators = config.getGlobalCaveDecorators(); + globalSkyDecorators = config.getGlobalSkyDecorators(); + skyDecorationDistance = config.getSkyDecorationDistance(); + skyChunkWidth = config.getSkyChunkWidth(); } @Override @@ -136,20 +148,106 @@ public void generate(GeneratorContext ctx) { } } } + + // Decorate the sky + if(!globalSkyDecorators.isEmpty()) { + double yIncrement = skyDecorationDistance * Math.sqrt(3); + Map> lastPositions = new HashMap<>(); + for(int startX = 0; startX < ctx.getWidth(); startX += skyChunkWidth) { + List candidateDecorators = new ArrayList<>(); + for(SkyDecorator decorator : globalSkyDecorators) { + if(decorator instanceof StructureSkyDecorator && ctx.nextDouble() < decorator.getChance()) { + candidateDecorators.add((StructureSkyDecorator)decorator); + } + } + + double xOffset = 0; + for(double y = skyDecorationDistance * ctx.nextDouble(); y < ctx.getHeight(); y += yIncrement) { + final int surface = ctx.getSurface(MathUtils.clamp(startX + skyChunkWidth / 2, 0, ctx.getWidth())); + if(y > surface + skyDecorationDistance) break; + final double finalY = y; + Map selection = new HashMap<>(); + candidateDecorators.forEach(skyDecorator -> { + double minY = ctx.getHeight() * skyDecorator.getMinDepth(); + if(skyDecorator.getMaxSurfaceClearance() != Integer.MAX_VALUE) { + minY = Math.max(minY, surface - skyDecorator.getMaxSurfaceClearance()); + } + double maxY = Math.min(surface - skyDecorator.getMinSurfaceClearance(), ctx.getHeight() * skyDecorator.getMaxDepth()); + if(finalY >= minY && finalY <= maxY) { + Map originalEntries = skyDecorator.getPrefabs().getEntries(); + for(Prefab prefab : originalEntries.keySet()) { + selection.merge(prefab, skyDecorator.getChance() * originalEntries.get(prefab), Double::sum); + } + } + }); + + if(!selection.isEmpty()) for(double x = 0; x < skyChunkWidth + skyDecorationDistance; x += 2 * skyDecorationDistance) { + Map allowed = new HashMap<>(selection); + int finalX = (int)(x + startX + xOffset); + for(Prefab prefab : selection.keySet()) { + List lastPrefabPositions = lastPositions.getOrDefault(prefab, Collections.emptyList()); + for(Vector2i lastPosition : lastPrefabPositions) { + if(MathUtils.inRange(lastPosition.getX(), lastPosition.getY(), finalX, finalY, 0.6 * selection.size() * skyDecorationDistance)) { + allowed.remove(prefab); + break; + } + } + } + if(allowed.isEmpty()) { + allowed = selection; + } + Prefab prefab = new WeightedMap<>(allowed).next(ctx.getRandom()); + Vector2i position = new Vector2i(finalX, (int)y); + StructureSkyDecorator.place(prefab, ctx, position.getX(), position.getY()); + lastPositions.computeIfAbsent(prefab, k -> new ArrayList<>()).add(position); + } + + // This will create an isometric grid pattern + xOffset = xOffset == 0 ? skyDecorationDistance : 0; + } + + // Keep only rightmost structure positions + for(Prefab prefab : new ArrayList<>(lastPositions.keySet())) { + Vector2i rightmost = null; + List filtered = new ArrayList<>(); + for(Vector2i pos : lastPositions.get(prefab)) { + if(rightmost == null || pos.getY() - rightmost.getY() > 5 || pos.getX() > rightmost.getX()) { + rightmost = pos; + filtered.add(pos); + } + } + lastPositions.put(prefab, filtered); + } + } + } + + final List ecoContainers = Stream.of( + "containers/crate-large", + "furniture/crate-broken-large", + "containers/crate-industrial-large", + "containers/sack-large" + ).collect(Collectors.toList()); // Fetch list of replaceable containers List containers = ctx.getZone().getMetaBlocks(metaBlock -> ctx.isUnderground(metaBlock.getX(), metaBlock.getY()) - && (metaBlock.getItem().hasId("containers/chest-mechanical-large") - || (metaBlock.getItem().hasId("containers/chest") && !metaBlock.getMetadata().containsKey("@")))); + && (ecoContainers.contains(metaBlock.getItem().getId())) && !metaBlock.getMetadata().containsKey("@")); Collections.shuffle(containers, ctx.getRandom()); // TODO placeComponentChests(ctx, containers); - placeBrokenTeleporters(ctx, containers); + + List mechPositions = ctx.getZone().getMetaBlocks(metaBlock + -> ctx.isUnderground(metaBlock.getX(), metaBlock.getY()) + && (metaBlock.getItem().hasId("containers/chest-mechanical-large"))); + Collections.shuffle(mechPositions, ctx.getRandom()); + + // TODO + placeBrokenTeleporters(ctx, mechPositions); if(ctx.getZone().getBiome() == Biome.HELL) { - placeInfernalProtectors(ctx, containers); + Collections.shuffle(mechPositions, ctx.getRandom()); + placeInfernalProtectors(ctx, mechPositions); } } @@ -162,12 +260,14 @@ private void placeComponentChests(GeneratorContext ctx, List containe switch(biome) { case PLAIN: machines.add(EcologicalMachine.PURIFIER); - machines.add(ctx.nextDouble() < 0.5 ? EcologicalMachine.RECYCLER : EcologicalMachine.COMPOSTER); + machines.add(EcologicalMachine.COMPOSTER); break; case ARCTIC: + machines.add(EcologicalMachine.PURIFIER); machines.add(EcologicalMachine.RECYCLER); break; case HELL: + machines.add(EcologicalMachine.PURIFIER); machines.add(EcologicalMachine.EXPIATOR); break; case DESERT: @@ -176,6 +276,15 @@ private void placeComponentChests(GeneratorContext ctx, List containe break; case DEEP: machines.add(EcologicalMachine.PURIFIER); + machines.add(EcologicalMachine.COMPOSTER); + break; + case BRAIN: + machines.add(EcologicalMachine.PURIFIER); + machines.add(EcologicalMachine.RECYCLER); + break; + case SPACE: + machines.add(EcologicalMachine.PURIFIER); + machines.add(EcologicalMachine.RECYCLER); break; default: break; @@ -232,11 +341,9 @@ private void placeInfernalProtectors(GeneratorContext ctx, List conta int y = container.getY(); int offset = getContainerOffset(ctx, x, y); - // Replace with large chest if container is a small chest - if(container.getItem().hasId("containers/chest")) { - ctx.updateBlock(x, y, Layer.FRONT, 0); - ctx.updateBlock(x + offset, y, Layer.FRONT, "containers/chest-large", 1, container.getMetadata()); - } + // Replace the container with large chest + ctx.updateBlock(x, y, Layer.FRONT, 0); + ctx.updateBlock(x + offset, y, Layer.FRONT, "containers/chest-large", 1, container.getMetadata()); // Place dish on top of the container ctx.updateBlock(x + offset, y - 1, Layer.FRONT, "hell/dish"); diff --git a/gameserver/src/main/resources/activities.json b/gameserver/src/main/resources/activities.json new file mode 100644 index 00000000..5c2208d1 --- /dev/null +++ b/gameserver/src/main/resources/activities.json @@ -0,0 +1,37 @@ +{ + "market": { + "player_item_limits": { + "mechanical/dish-micro": 5, + "mechanical/dish": 2, + "mechanical/dish-large": 1, + "mechanical/dish-mega": 0, + "mechanical/dish-giga": 0 + } + }, + "tutorial": { + "primary_zone": "00000000-0000-0000-0000-000000000000", + "player_inventory_limits": { + "building/wood": 10, + "ground/earth": 50, + "ground/copper": 10, + "ground/zinc": 10, + "ground/iron": 10, + "ground/quartz": 10, + "ground/sulfur": 10, + "ground/lead": 10, + "ground/copper-ore": 10, + "ground/zinc-ore": 10, + "ground/iron-ore": 10, + "ground/quartz-ore": 10, + "ground/sulfur-ore": 10, + "ground/lead-ore": 10, + "ground/crystal-blue-1": 1, + "accessories/jetpack": 1, + "consumables/jerky": 5, + "tools/shovel": 1, + "tools/pickaxe": 1, + "tools/pistol": 1, + "building/iron": 2 + } + } +} diff --git a/gameserver/src/main/resources/android-shop.json b/gameserver/src/main/resources/android-shop.json new file mode 100644 index 00000000..dac6c160 --- /dev/null +++ b/gameserver/src/main/resources/android-shop.json @@ -0,0 +1,64 @@ +{ + "sections": { + "protectors": { + "name": "Protectors", + "icon": "inventory/mechanical/dish-micro", + "items": { + "mechanical/dish-giga": 20000, + "mechanical/dish-mega": 10000, + "mechanical/dish-large": 5000, + "mechanical/dish": 2500, + "mechanical/dish-micro": 1000, + "mechanical/tesla-coil": 1000 + }, + "quantity_per_day": { + "mechanical/dish-micro": 1 + } + } + }, + "sell_price_adjustment": { + "1": 1.0, + "2": 0.98, + "3": 0.96, + "4": 0.94, + "5": 0.92, + "6": 0.90, + "7": 0.88, + "8": 0.86, + "9": 0.84, + "10": 0.82, + "11": 0.80, + "12": 0.78, + "13": 0.76 + }, + "buy_price_adjustment": { + "1": 1.00, + "2": 1.01, + "3": 1.02, + "4": 1.03, + "5": 1.04, + "6": 1.05, + "7": 1.06, + "8": 1.07, + "9": 1.08, + "10": 1.09, + "11": 1.10, + "12": 1.11, + "13": 1.12 + }, + "max_price": { + "1": 25, + "2": 50, + "3": 75, + "4": 100, + "5": 250, + "6": 500, + "7": 750, + "8": 1000, + "9": 1500, + "10": 2000, + "11": 2500, + "12": 3000, + "13": 5000 + } +} diff --git a/gameserver/src/main/resources/anticheat.json b/gameserver/src/main/resources/anticheat.json new file mode 100644 index 00000000..367e5794 --- /dev/null +++ b/gameserver/src/main/resources/anticheat.json @@ -0,0 +1,7 @@ +{ + "exploder_farm": { + "enabled": false, + "xp_factor": 0.1, + "loot_counter_max": 10 + } +} diff --git a/gameserver/src/main/resources/fake.json b/gameserver/src/main/resources/fake.json new file mode 100644 index 00000000..d9a81be7 --- /dev/null +++ b/gameserver/src/main/resources/fake.json @@ -0,0 +1,362 @@ +{ + "fake": { + "react": [ + "What.", + "Huh?", + "Sure.", + "I'm not sure what you want.", + "Does not compute.", + "Excuse moi?", + "That tickles!", + "I don't understand.", + "Not sure what you mean.", + "Okay.", + "You too, human.", + "Interesting.", + "Perhaps I misjudged you.", + "You are a human, I am an android.", + "Yes?", + "What is it?", + "I can't help you right now.", + "Unavailable for comment.", + "100100101101110101", + "100100100111", + "101010", + "Right.", + "No thank you.", + "I'm not accepting proposals at this time.", + "I am in working order.", + "Reporting for duty." + ], + "salutations": { + "neutral": [ + "Hello.", + "Hey.", + "Hi.", + "Salutations.", + "Greetings.", + "Acknowledgements, stranger.", + "Up, what is.", + "High five, stranger.", + "How do you do.", + "Hail, stranger.", + "Ah, a human!", + "A human!", + "Interesting biological specimen, you are.", + "A survivor!", + "Hello, survivor person.", + "Salutations, human being.", + "A human. Very interesting.", + "Another human. Interesting.", + "Another survivor, are you.", + "Welcome, biological being.", + "Why hello there.", + "Howdy, stranger.", + "G'day, stranger.", + "Hello, stranger.", + "Good day.", + "Fair day.", + "Fair day to you.", + "Hello human.", + "Neutral greeting.", + "Your presence is neutral to me.", + "I am indifferent to your presence." + ], + "friendly": [ + "Nice to see you again.", + "How are you, friend!", + "Greetings!", + "My friend, hello!", + "Warm welcome to you!", + "Pleased to see you.", + "Pleased to view you again.", + "Golly gee hello!", + "Pleasant greeting, creature." + ], + "unfriendly": [ + "...", + "Leave me, please.", + "Go away.", + "Your presence is most distressing.", + "You should go.", + "You are not wanted in this immediate vicinity.", + "Depart immediately.", + "You scoundrel.", + "Get lost." + ] + }, + "laughter": [ + "LOL", + "Hahaha!", + "Hehe!", + "Heh.", + "Funny, right?", + "Lolskiez.", + "Ha.", + "Mwahaha!", + "Hilarious.", + "Very humorous.", + "Funny.", + "Lolz.", + "ROTFL" + ], + "companion-cube": [ + "You're doing a great job!", + "You look swell today.", + "I'm loving your outfit!", + "You've been working out, haven't you!", + "You sure are glowing today.", + "I'm so glad we're friends.", + "You're the best!" + ], + "butler-denial": [ + "You are not my operator.", + "I don't know you.", + "Unknown director.", + "Error: invalid operator.", + "I am not authorized to assist you." + ], + "butler-direct": [ + "It shall be so.", + "I am prepared to $$.", + "I shall $$.", + "$$ mode enabled.", + "We shall $$ together.", + "$$ program loaded." + ], + "butler-shutdown": [ + "It's been a pleasure.", + "Good bye!", + "Bye bye.", + "K thx bai.", + "I'm out.", + "Seeya.", + "Till next time.", + "Initiating deactivation procedure.", + "Executing deactivation program." + ], + "butler-mine": [ + "I think I've got the black lung.", + "Chk chk chk", + "I wish I had a pickaxe.", + "Ouch.", + "At least I'm getting a workout.", + "Ah, sweet resources!", + "Finally, something to trade for a crystal.", + "ONYX! ONYX! Just kidding." + ], + "butler-blast": [ + "BOOM!", + "Boom shakalaka!", + "Big bada boom!", + "Commence destruction!", + "Energize!", + "Feel my wrath!", + "Hadouuuuken!" + ], + "butler-empty": [ + "Fresh out of $$.", + "All out of $$.", + "We need more $$.", + "I could use some more $$.", + "Some $$ would be helpful.", + "Have any more $$?", + "Apologies, but I've used up your $$.", + "No more $$.", + "$$ is all gone.", + "The $$ situation is not ideal." + ], + "butler-no-item": [ + "No item specified.", + "Which item do you want me to use?", + "Please specify an item." + ], + "butler-place": [ + "Building is fun!", + "Whistle while you work...", + "This stuff is heavy!", + "I love tedious activities!", + "Glad we're building together!", + "Looking good, don't you think!", + "This is coming along.", + "Hmmmmm. Interesting aesthetic.", + "Definitely an improvement." + ], + "butler-drain": [ + "I'm super-absorbent!", + "Best vacuum ever.", + "I'm so thirsty!", + "Down the hatch!", + "Slurp slurp slurp" + ], + "butler-dump": [ + "Releasing fluids.", + "Opening the floodgates!", + "Open the hatch!", + "I'm a lean, mean, watering machine.", + "Sheesh, I'm making a mess!" + ], + "butler-dump-empty": [ + "All outta juice.", + "No more liquid.", + "Reserves have run dry." + ], + "butler-low-level": [ + "I am not advanced enough.", + "This is beyond my abilities.", + "Maybe if you upgraded me...", + "I require additional fancyness to do this." + ], + "butler-excavate": [ + "Diggin' dig diggity dig.", + "I'm an archaeologist!", + "That's a lot of dirt.", + "Almost to the Mines of Moria!", + "Dig Dug's got nothin' on me.", + "Almost through!", + "Tunnelin' fun!" + ], + "jokes": [ + "Sorry, my humor circuit is out of order.", + "I would laugh at my jokes if I was programmed with emotions.", + "Why did the terrapus cross the road? To get to the other squid.", + "What did the brain name her band? Lady Cerebellum.", + "How many androids does it take to screw in a lightbulb? 1001.", + "How many scammers does it take to wallpaper a bathroom? Only one, but you have to slice him really thin.", + "What do you call a pile of one thousand dead crows? A respectable start.", + "What is the point of a sledgehammer? There is no point; a sledgehammer is flat on both ends.", + "What happens if you disagree with the devs? They remove the disagree button.", + "What is a Deepworlder's favorite dessert? Terra-pie.", + "Why did the android trade for an android memory unit? He lost his mind.", + "Why did the player love to use a hatchet? He was over axe-cited!", + "Why did the angry Deepworlder leave the world? Because he ran out of steam!", + "What do you call a Leap Quest with a tiny hole cut in the middle? Peep Quest.", + "Where do Terrapi go to have a picnic? The terra-park.", + "How do you prepare a bunny steak? Medium hare.", + "A is for Amy, who fell in some acid. B is for Basil, left near a big bomb that blasted.", + "What do you call a terrapus father? A terra-pa.", + "Why did the devs cross the road? To go to Chipotle.", + "C is for Cerys who met her demise. D is for Dustin who ate too many terra-pies.", + "What do brides wear on wedding day? Clearvales.", + "E is for Errol, who fell on some spikes. F is for Farley, who spammed the forums for likes.", + "How do you clean a terrapus' teeth? Open its maw!", + "What's Tesla's favorite mob? The electric eel.", + "I is for Irwin, who followed a brain. J is for Janice, who stood in the rain.", + "What do you get if you divide the circumference of one terrapus by the diameter of another? Terra Pi.", + "Why is there a brain world? Because they are too smart for other worlds!", + "K is for Kenny, drowned in a bidet. L is for Laura, who ran out of filets.", + "What's the rule when playing cards with toilets? A flush beats a full house.", + "Why couldn't the new kid collect water in the desert? He thought a door was a jar.", + "Who is the best butterfly-catcher in the game? Annette.", + "M is for Mina who ate the wrong shroom. N is for Nelly who fell into her loom.", + "Who is the only person who can tell off a brain and get away with it? A mother brain!", + "Why do cats come in crates? Their mothers lay robotic eggs.", + "What do you get when you cross a terrapus with a robotic cat? A terrapuss.", + "Why did so many Deepworlders die at the in-game party? The punch was spiked.", + "Who should you bet on at the insect races? The revvin' ant.", + "What do you get when you cross a griefer and a terrapus? A trollerpus.", + "Why did the terrapus help the mushroom across the street? Because he was an elder mushroom.", + "Don't trust atoms - they make up everything.", + "Why did the android run away from the terrapus? Because it was terra-fying!", + "Did you hear about the hungry clock? It went back four seconds.", + "What did the terrapus say after the punchline? That's my terra-pun!", + "What do you get when you drop a piano down a mineshaft? A flat minor.", + "U is for Una who killed too many crows. V is for Vincent who forgot his pros.", + "Why did the ButlerBot hate tennis? Because he always had to serve!", + "What are androids' favorite painting? Smasherdroid.", + "W is for Walter who woke up in griefer hell. X is for Xavier who ate a morel.", + "Why was the revenant such a bad liar? Because everybody could see through him.", + "What do you call an old Deepworlder with lots of resin? Amber.", + "Did you see what that brain was doing? Frankly, I was quite shocked.", + "Y is for Ydith who yelled out her true age. Z is for Zachary who quit the app in a rage.", + "Yo brain is so fat, he couldn't even fit through the dungeon doors!", + "Where did the ship-in-a-bottle dock? At a daven-port.", + "What is a brain's favorite drink? Earl Grey Matter Tea.", + "Whats a crow's favourite drink? Caw-fee.", + "What do you call a brain with dyslexia? Brian.", + "There are 10 kinds of people in this world; those who know binary, and those who don't.", + "Why did the crow sit on the telephone wire? It was going to make a long-distance caw.", + "Deepworld: where people are just mining their own business.", + "Why did the scarecrow win a Nobel prize? He was outstanding in his field.", + "What do you call a religious terrapus? Terra-pious.", + "Where did the brain stay when it went to college? At the hippocampus.", + "Why can't brains fly through trap doors? Because they don't think outside the dungeon.", + "Why did the terrapus cross the cave? To get to the other maw.", + "O is for Oswin, done in by a bot. P is for Priscilla, whose jetpack got shot.", + "What does Beethoven say when he enters a world? Dun dun dun dun ... geon!", + "E is for Ein, too close to his mine. F is for Francois, whose hearts depleted by nine.", + "S is for Sophia, picked clean by vultures. T is for Tobias, ground into compost by multchers.", + "Knock knock. Who's there? Amanita. Amanita who? Amanita better joke to get a laugh out of this one.", + "If I could rearrange the alphabet, I would put 'U' and 'I' together. And it would stand for 'user interface.'", + "Is it hot in here, or is it just me? Wait, that is me. Please remain while I change my coolant.", + "Knock knock. Who's there? Zinc. Zinc who? Zinc that last knock-knock was bad? This one is even worse.", + "Knock knock. Who's there? Conch. Conch who? Gesundheit!", + "What did the temperate world say to the ice world? Go to hell.", + "What did the terrapus say to its mom? Maw, I got a stomach ache.", + "Why did the Deepworlder get kicked out of the hipster party? Because the companion cube she brought was a total square.", + "I'd tell you some window jokes, but they are a pane to listen to.", + "How do you brainwash a brain? With a bathtub.", + "What do you call a landscape on its side? A portrait.", + "What was the jetpack's anger management advice? Blow off some steam.", + "Why do some items appear as question marks in inventory? Because every once in a while, Quinn draws a blank.", + "What do you get when you put a brain and a human together? Entertainment.", + "What's a bats favorite holiday? Fangsgiving.", + "What do you call a stunt surfer that cant do tricks? A painting.", + "Don't put all your terrapus eggs in one basket.", + "What do you call a terrapus with no legs? Anything you want, it can't hurt you.", + "Why was the terrapus afried of the brain? Because he was a terrawuss!", + "A is for Amy who went to the World's Fair. B is for Borwell who kicked a spike on a dare.", + "O is for Oliver who wandered into PvP. P is for Percy who took a musket shot to the knee.", + "Q is for Quincy who ate the giblets, though they weren't jerky. R is for Rupert who drank the water, though it was murky.", + "Why was the android crazy? He had a few screws loose.", + "This is not the android you're looking for.", + "Why did the android clean the toilet? Because it was his doody!", + "Why do terrapi crawl inside toilets? To terra-pee.", + "What's the difference between an exo headset and an android? One is headgear and the other is a gearhead.", + "Why doesn't tea do anything? Because someone forgot the quali-tea check.", + "U is for Uni who fell in a maw. V is for Vivian who broke her jaw.", + "What did the maw come to his doctor for? He had a bit of a plug up.", + "What plant always falls over? A tumbleweed.", + "What was the baby terrapi's first word? Mawmaw.", + "*player*, I am not your father.", + "May the force shields be with you.", + "I used to be an explorer like you, but then I took a bullet to the steam pack.", + "My programmer always said life was like a mechanical chest. You never know what you’re gonna get.", + "What is the difference between a scammer and a terrapus? One lives in dark holes and tries to leach away your life, and the other is a terrapus.", + "A brain goes into a bar and the bartender says, 'Hey, we have a wine named after you!' The brain looks surprised and says, 'You have a wine named Bob?'", + "Why couldn't the Deepworlder tip his hat? He hadn't learned the emote yet.", + "What do you get when you cross a scarecrow and a crow? A scarecrowcrow.", + "What do you give a sick bird? Tweetment.", + "What did one terrapus egg say to the other terrapus egg? Let's get crackin'!", + "How do crows stick together when flying? They use vel-crow.", + "Why did the android's operating system crash in the arctic world? It froze up.", + "Why do we play Deepworld? Because we dig it!", + "Why did the terrapus fall into the bidet? Because he was pissed off.", + "Which cellular network do metalworkers use to keep in touch? A forge-y network.", + "Why did the newb use the steam cannon on the brazier? He thought it was a fire extinguisher.", + "Yo mama is so lazy to she waited to find a wardrobe in a dungeon instead of taking it out of her inventory.", + "What do you call it when you run out of metal blocks? A pain in the brass.", + "Where did all of the scientists go to party? The Tesla Club.", + "What do you use when you lose count of something? An abacus.", + "What's black and white and green all over? A skunk in acid.", + "How do you kill a brain? I don't know but these jokes are killing mine.", + "How do brains reproduce? Brainary fission.", + "What do you call a cactus that can be used as a weapon? A whacktus.", + "What was the brain's favorite book? Fifty Shades of Grey Matter.", + "Why couldn't the terrapus fly? Because someone stole its jetpack.", + "What do you call it when you have a lot of tea in your inventory? A monstrosi-tea.", + "Knock knock. Who's there? Acid. Acid who? Acid I wouldn't tell any more bad jokes, but allied.", + "What's an armadillo's favorite dessert? Strawberrylium shortcake.", + "Which type of bot really scares you? A boo-tler bot.", + "What do you call an android in a boat? A row-bot.", + "What did the hatchet say to the sledgehammer? Can I axe you a question?", + "What do you call it when you spill acid on someone? An acid-dent.", + "How does the terrapus travel from one world to another? It uses a terra-port.", + "Why did the bunny go to the lost and found? Because a player stole his foot.", + "My old master always said, our brains are the most powerful weapons. Seems he was right, I saw his brain floating around today, shooting at people.", + "Which player is the tallest player in Deepworld? Every player.", + "What do you get when you cross lava and a terrapus? Deep-fried calamari.", + "There are 10 kinds of people in this world; those who know binary, and those who don't." + ] + } +} \ No newline at end of file diff --git a/gameserver/src/main/resources/hardcoded-quests.json b/gameserver/src/main/resources/hardcoded-quests.json new file mode 100644 index 00000000..5ca09191 --- /dev/null +++ b/gameserver/src/main/resources/hardcoded-quests.json @@ -0,0 +1,7 @@ +{ + "hardcoded_quests": { + "Say Hello": { + "quest": "survival_say_hello" + } + } +} \ No newline at end of file diff --git a/gameserver/src/main/resources/prefabs/airship/medium/blocks.dat b/gameserver/src/main/resources/prefabs/airship/medium/blocks.dat new file mode 100644 index 00000000..2268e59b Binary files /dev/null and b/gameserver/src/main/resources/prefabs/airship/medium/blocks.dat differ diff --git a/gameserver/src/main/resources/prefabs/airship/medium/config.json b/gameserver/src/main/resources/prefabs/airship/medium/config.json new file mode 100644 index 00000000..6e97472a --- /dev/null +++ b/gameserver/src/main/resources/prefabs/airship/medium/config.json @@ -0,0 +1,10 @@ +{ + "dungeon": false, + "ruin": false, + "loot": false, + "decay": false, + "mirrorable": false, + "replace": {}, + "corresponding_replace": {}, + "metadata": {} +} \ No newline at end of file diff --git a/gameserver/src/main/resources/random-quests.json b/gameserver/src/main/resources/random-quests.json new file mode 100644 index 00000000..c52c3ba0 --- /dev/null +++ b/gameserver/src/main/resources/random-quests.json @@ -0,0 +1,207 @@ +{ + "daily_quest_interval": "24h", + "daily_quest_count": 5, + "strings": { + "kill_description": [ + "Today you've gotta kill some critters!", + "Alright stop. It's hammer time!", + "Blow them up for some crowns!", + "There's nothing better for stress relief by putting your weapons to good use!" + ], + "collect_description": [ + "Time to do some scavenging.", + "The shelves in your father's thrift store are empty. Help him stock it back up!", + "Head out into the wastes and see what's lying around.", + "Time to fill your pockets with loot from the wastes!" + ], + "craft_description": [ + "Time to do some crafting!", + "Put those crafting skills to good use by crafting these items!", + "Craft these items for some crowns!", + "The mayor of Cakeland needs you to craft these items!" + ] + }, + "quests": [ + { + "frequency": 10, + "type": "kill", + "categories": [ "terrapus" ], + "quantity": { "random_between": [ 5, 10 ] }, + "reward": { "crowns": 20 } + }, + { + "frequency": 10, + "type": "kill", + "categories": ["brains"], + "task_description": "Stop the brain uprising! Kill {QUANTITY} brains of any type.", + "quantity": { "random_between": [3, 5] }, + "reward": { "crowns": 20 } + }, + { + "frequency": 50, + "type": "kill", + "entities": [ + "creatures/crow", + "creatures/crow-auto", + "creatures/armadillo", + "creatures/rat", + "creatures/skunk", + "creatures/roach", + "creatures/roach-large", + "creatures/bunny-ice" + ], + "task_description": "Get rid of the small animals! Kill {QUANTITY} small animals of any kind!", + "quantity": { "random_between": [3, 5] }, + "reward": { "crowns": 20 } + }, + { + "frequency": 50, + "type": "collect", + "tasks": [ + { + "items": { + "choices": [ + "mechanical/pipe", + "containers/barrel", + "containers/barrel-tall", + "containers/crate-small", + "containers/crate-large", + "containers/crate-industrial-small", + "containers/crate-industrial-large", + "building/staircase", + "building/iron", + "building/copper", + "rubble/gravestone" + ] + }, + "count": { "random_between": [5, 15] } + } + ], + "reward": { "crowns": 20 } + }, + { + "frequency": 50, + "type": "collect", + "tasks": [ + { + "items": { + "choices": [ + "lighting/candle", + "lighting/lantern", + "lighting/candlelabra", + "furniture/hatrack", + "vegetation/mushrooms-tiny", + "vegetation/mushroom-portabella", + "vegetation/mushroom-oyster", + "vegetation/mushroom-porcini", + "vegetation/mushroom-willow-tall", + "vegetation/mushroom-amanita-tall", + "vegetation/mushroom-chanterelle", + "vegetation/mushroom-acid", + "vegetation/mushroom-lava", + "vegetation/mushroom-apostate", + "vegetation/mushroom-shade", + "vegetation/tumbleweed", + "rubble/skeleton" + ] + }, + "count": { "random_between": [5, 15] } + } + ], + "reward": { "crowns": 20 } + }, + { + "frequency": 40, + "type": "collect", + "tasks": [ + { + "items": { + "choices": [ + "arctic/ice-brick", + "arctic/ice", + "arctic/snow", + "containers/wine-bottle", + "furniture/chair-plush", + "ammo/bullets", + "ammo/gunpowder", + "containers/crate-large", + "containers/crate-small", + "containers/sack-large", + "containers/sack-small", + "containers/barrel-tall", + "containers/barrel", + "industrial/vat", + "industrial/conveyor", + "industrial/conveyor-space" + ] + }, + "count": { "random_between": [5, 15] } + } + ], + "reward": { "crowns": 20 } + }, + { + "frequency": 40, + "type": "collect", + "tasks": [ + { + "items": { + "choices": [ + "ground/crystal-blue-small", + "ground/crystal-purple-1", + "ground/crystal-red-1", + "ground/crystal-orange-small", + "ground/yellow-crystal-small", + "ground/crystal-green-small", + "ground/crystal-white-small" + ] + }, + "count": { "random_between": [1, 5] } + } + ], + "reward": { "crowns": 20 } + }, + { + "frequency": 100, + "type": "craft", + "tasks": [ + { + "items": { + "choices": [ + "furniture/snowman", + "building/tiles-checkered", + "back/checkered", + "furniture/bed", + "furniture/bed-covered", + "furniture/bidet", + "furniture/bathtub", + "furniture/fridge", + "furniture/book-stand", + "furniture/umbrella", + "mechanical/bomb", + "mechanical/bomb-incendiary", + "mechanical/bomb-electric", + "tools/pistol", + "tools/musket", + "consumables/jerky-power", + "containers/chest", + "building/door-brass", + "mechanical/door-beefy-closed-copper", + "mechanical/door-beefy-closed-iron", + "tools/cane", + "tools/pickaxe-fine", + "building/mailbox", + "containers/wine-bottle", + "building/plug", + "containers/crate-industrial-small", + "containers/crate-industrial-large", + "building/scarecrow", + "building/tinman" + ] + } + } + ], + "reward": { "crowns": 20 } + } + ] +} diff --git a/gameserver/src/main/resources/shop.json b/gameserver/src/main/resources/shop.json index e803cbd7..b1482a85 100644 --- a/gameserver/src/main/resources/shop.json +++ b/gameserver/src/main/resources/shop.json @@ -1,13 +1,16 @@ { "sections": { - "utilities": { - "name": "Utilities", + "Accessories, Skills and Equipment": { + "name": "Accessories, Skills and Equipment", "icon": "icon-profile-equipment", "products": [ - "brain-wine", + "makeup-kit", "skill-book", + "brain-wine", "skill-reset", - "name-change" + "teleporter", + "stealth-pack", + "power-up-pack" ] }, "protectors": { @@ -66,60 +69,83 @@ } }, "products": { - "brain-wine": { + "power-up-pack": { "type": "item", - "name": "Brain Wine", - "cost": 75, - "description": "Upgrade any skill by one point with this carefully brewed essence of brain! Note: each skill can only be upgraded once via brain wine, and only to Level 10.", - "image": [ - { "sprite": "inventory/consumables/brain-wine" }, - { "sprite": "shop/banner", "color": "AAFF66" }, - { "sprite": "shop/banner-text-sale" } - ], + "name": "Jerky Pack", + "description": "Grab a quick bite to eat and keep your place in battle with this stash of extra jerky!", + "image": "inventory/consumables/jerky", + "cost": 10, "items": { - "consumables/brain-wine": 1 + "consumables/jerky": 10, + "consumables/jerky-power": 5 } }, - "skill-book": { + "stealth-pack": { "type": "item", - "name": "Skill Book", - "cost": 75, - "description": "Upgrade any skill by one point with this lovely tome of knowledge! Note: each skill can only be upgraded once via books, and only to Level 10.", - "image": [ - { "sprite": "inventory/consumables/book-skill" }, - { "sprite": "shop/banner", "color": "AAFF66" }, - { "sprite": "shop/banner-text-sale" } - ], + "name": "Stealth Pack", + "description": "Sneak around like a pro with this collection of stealth cloaks. Those brains won't know you were ever there!", + "image": "inventory/consumables/cloak", + "cost": 30, "items": { - "consumables/book-skill": 1 + "consumables/cloak": 5 } }, "skill-reset": { "type": "item", "name": "Skill Reset", - "cost": 100, - "description": "Go back in time a little with this handy skill reset! Reclaim all of your skill points and re-apply them however you like.", + "description": "Go back in time with this handy skill reset! Reclaim all of your skill points from each skill and re-apply them however you like.", "image": "inventory/consumables/skill-reset", + "cost": 100, "items": { "consumables/skill-reset": 1 } }, - "name-change": { + "skill-book": { "type": "item", - "name": "Name Change", - "cost": 125, - "description": "Want to change your player name? Grab this handy name tag and get creative!", - "image": "inventory/consumables/name-change", + "name": "Skill Book", + "description": "Upgrade any skill by one point with this lovely tome of knowledge! Note: each skill can only be upgraded once via books, and only to Level 10.", + "image": "inventory/consumables/book-skill", + "cost": 250, + "items": { + "consumables/book-skill": 1 + } + }, + "brain-wine": { + "type": "item", + "name": "Brain Wine", + "description": "Upgrade any skill by one point with this carefully brewed essence of brain! Note: each skill can only be upgraded once via brain wine, and only to Level 10.", + "image": "inventory/consumables/brain-wine", + "cost": 250, + "items": { + "consumables/brain-wine": 1 + } + }, + "makeup-kit": { + "type": "item", + "name": "Makeup Kit", + "description": "Revolutionize your look with all new skin tones and hair colors. Your friends will be green with envy once you're actually green!", + "image": "inventory/accessories/makeup", + "cost": 400, + "items": { + "accessories/makeup": 1 + } + }, + "teleporter": { + "type": "item", + "name": "Teleporter", + "description": "Drop a teleporter to allow quick access to your home or anywhere else.", + "image": "inventory/mechanical/teleporter", + "cost": 40, "items": { - "consumables/name-change": 1 + "mechanical/teleporter": 1 } }, "giga-protector": { "type": "item", "name": "Giga Protector", - "cost": 225, "description": "Protect your creations and valuables from being mined by other players with this giga protector! When placed, it will emit a protective field that protects blocks within a 120 block radius.", "image": "inventory/mechanical/dish-giga", + "cost": 500, "items": { "mechanical/dish-giga": 1 } @@ -127,9 +153,9 @@ "mega-protector": { "type": "item", "name": "Mega Protector", - "cost": 125, "description": "Protect your creations and valuables from being mined by other players with this mega protector! When placed, it will emit a protective field that protects blocks within a 60 block radius.", "image": "inventory/mechanical/dish-mega", + "cost": 250, "items": { "mechanical/dish-mega": 1 } @@ -137,9 +163,9 @@ "large-protector": { "type": "item", "name": "Large Protector", - "cost": 50, "description": "Protect your creations and valuables from being mined by other players with this large protector! When placed, it will emit a protective field that protects blocks within a 30 block radius.", "image": "inventory/mechanical/dish-large", + "cost": 100, "items": { "mechanical/dish-large": 1 } @@ -147,21 +173,21 @@ "small-protector": { "type": "item", "name": "Small Protector", - "cost": 20, "description": "Protect your creations and valuables from being mined by other players with this small protector! When placed, it will emit a protective field that protects blocks within a 15 block radius.", "image": "inventory/mechanical/dish", + "cost": 50, "items": { "mechanical/dish": 1 } }, "micro-protector-pack": { "type": "item", - "name": "Micro Protector Pack", - "cost": 20, - "description": "Protect your creations and valuables from being mined by other players with these micro protectors! When placed, each dish will emit a protective field that protects blocks within a 5 block radius.", + "name": "Micro Protector", + "description": "Protect your creations and valuables from being mined by other players with this micro protector! When placed, it will emit a protective field that protects blocks within a 5 block radius.", "image": "inventory/mechanical/dish-micro", + "cost": 25, "items": { - "mechanical/dish-micro": 4 + "mechanical/dish-micro": 1 } }, "brass-exo-headset": { diff --git a/src/main/java/brainwine/DirectDataFetcher.java b/src/main/java/brainwine/DirectDataFetcher.java index 5b089878..0448286c 100644 --- a/src/main/java/brainwine/DirectDataFetcher.java +++ b/src/main/java/brainwine/DirectDataFetcher.java @@ -2,16 +2,33 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import brainwine.api.DataFetcher; +import brainwine.api.models.PlayerInfo; +import brainwine.api.models.PlayerInfoSummary; import brainwine.api.models.ZoneInfo; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemGroup; +import brainwine.gameserver.item.ItemRegistry; +import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.player.Player; import brainwine.gameserver.player.PlayerManager; +import brainwine.gameserver.util.MapHelper; +import brainwine.gameserver.zone.MetaBlock; import brainwine.gameserver.zone.Zone; +import brainwine.gameserver.zone.ZoneActivity; import brainwine.gameserver.zone.ZoneManager; +import brainwine.shared.JsonHelper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; public class DirectDataFetcher implements DataFetcher { @@ -54,7 +71,97 @@ public String fetchPlayerId(String apiToken) { public boolean verifyAuthToken(String name, String token) { return playerManager.verifyAuthToken(name, token); } - + + @Override + public PlayerInfo getPlayerInfo(String nameOrId) { + Player player = playerManager.getPlayer(nameOrId); + + if(player == null) { + player = playerManager.getPlayerById(nameOrId); + } + + return player == null ? null : createPlayerInfo(player); + } + + @Override + public Collection fetchPlayerInfo() { + return playerManager.getPlayers().stream() + .filter(Objects::nonNull) + .map(DirectDataFetcher::createPlayerInfoSummary) + .collect(Collectors.toCollection(ArrayList::new)); + } + + private static PlayerInfoSummary createPlayerInfoSummary(Player player) { + return new PlayerInfoSummary( + player.getName(), + player.getLevel(), + player.getLevelFromExperience(player.getExperience()), + player.getStatistics().getDeaths(), + player.getStatistics().getTotalItemsMined(), + player.getStatistics().getTotalItemsScavenged(), + player.getStatistics().getItemsPlaced(), + player.getStatistics().getTotalItemsCrafted() + ); + } + + private static PlayerInfo createPlayerInfo(Player player) { + Map appearance = new HashMap<>(); + for(Map.Entry entry : player.getAppearance().entrySet()) { + if(entry.getKey() == null || entry.getValue() == null) continue; + if(entry.getKey().contains("*")) { + appearance.put(entry.getKey(), Objects.toString(entry.getValue())); + } else { + if(entry.getValue() instanceof Integer) { + appearance.put(entry.getKey(), ItemRegistry.getItem((int) entry.getValue()).getId()); + } + } + } + + // TODO: include hover and propel accessories. + Item flyAccessory = player.getInventory().findAccessoryWithUse(ItemUseType.FLY); + if(!flyAccessory.isAir()) { + appearance.put("u", flyAccessory.getId()); + } + + String[] includedStats = { "discoveries", "kills", "assists", "play_time", "areas_explored", "containers_looted", + "dungeons_raided", "maws_plugged", "undertakings", "deliverances", "deaths", "landmarks_upvoted", "landmark_votes_received" }; + + // TODO: this serializes the items mined and scavenged for no reason. + Map stats = new HashMap<>(); + try { + Map allStats = JsonHelper.readValue(player.getStatistics(), new TypeReference>() {}); + for(String key : includedStats) { + stats.put(key, allStats.get(key)); + } + + int treesMined = 0; + int mineralsMined = 0; + for(Map.Entry entry : player.getStatistics().getItemsScavenged().entrySet()) { + if(entry.getKey().getGroup() == ItemGroup.TREE) treesMined += entry.getValue(); + if(entry.getKey().getGroup() == ItemGroup.MINERAL) mineralsMined += entry.getValue(); + } + + stats.put("trees_mined", treesMined); + stats.put("minerals_mined", mineralsMined); + } catch(JsonProcessingException e) { + stats = null; + } + + return new PlayerInfo( + player.getName(), + player.getLevel(), + player.getSkills().values().stream().collect(Collectors.summingInt(x -> x - 1)), + player.getStatistics().getDeaths(), + player.getStatistics().getTotalItemsMined(), + player.getStatistics().getTotalItemsScavenged(), + player.getStatistics().getItemsPlaced(), + player.getStatistics().getTotalItemsCrafted(), + player.getApiToken(), + appearance, + stats + ); + } + @Override public ZoneInfo getZoneInfo(String nameOrId) { Zone zone = zoneManager.getZoneByName(nameOrId); @@ -100,21 +207,66 @@ private List createZoneInfo(Collection zoneIds) { .collect(Collectors.toCollection(ArrayList::new)); } - private static ZoneInfo createZoneInfo(Zone zone) { - return new ZoneInfo(zone.getName(), - zone.getBiome().getId(), - null, + public static ZoneInfo createZoneInfo(Zone zone) { + return new ZoneInfo(zone.getDocumentId(), + zone.getName(), + zone.getBiome().getId(), + zone.getActivity() == null || zone.getActivity() == ZoneActivity.NONE ? null : zone.getActivity().toString().toLowerCase(), zone.isPvp(), + zone.isMarket(), + zone.isTutorial(), false, zone.isPrivate(), zone.isProtected(), - zone.getPlayers().size(), + zone.getPlayerCount(), zone.getWidth(), zone.getHeight(), zone.getSurface(), zone.getExplorationProgress(), zone.getCreationDate(), zone.getOwner(), - zone.getMembers()); + zone.getMembers(), + null + ); + } + + public List> getZoneMetaBlocks(String documentId) { + Zone zone = zoneManager.getZone(documentId); + if(zone == null) return null; + return zone.getGlobalMetaBlocks().stream() + .filter(b -> b.getItem().hasUse(ItemUseType.ZONE_TELEPORT) + || b.getItem().hasUse(ItemUseType.TELEPORT) + || b.getItem().getId().contains("sign") + ) + .map(DirectDataFetcher::createMetaBlockData) + .collect(Collectors.toList()); + } + + private static final Set includedMetablockKeys = Stream.of( "n", "t1", "t2", "t3", "vc" ).collect(Collectors.toCollection(HashSet::new)); + private static Map createMetaBlockData(MetaBlock m) { + Map metadata = new HashMap<>(); + for(Map.Entry entry : m.getMetadata().entrySet()) { + if(includedMetablockKeys.contains(entry.getKey())) { + metadata.put(entry.getKey(), entry.getValue()); + } + } + + Map data = MapHelper.map( + String.class, Object.class, + "x", m.getX(), + "y", m.getY(), + "item", m.getItem().getId(), + "metadata", metadata + ); + + Player owner = m.getOwner(); + if(owner != null) { + data.put("owner", MapHelper.map( + String.class, Object.class, + "name", owner.getName() + )); + } + + return data; } } diff --git a/src/main/java/brainwine/DirectPusher.java b/src/main/java/brainwine/DirectPusher.java new file mode 100644 index 00000000..332506cb --- /dev/null +++ b/src/main/java/brainwine/DirectPusher.java @@ -0,0 +1,45 @@ +package brainwine; + +import brainwine.api.Api; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.server.Pusher; +import brainwine.gameserver.zone.Zone; + +import java.util.HashMap; +import java.util.Map; + +public class DirectPusher implements Pusher { + Api api; + + public DirectPusher(Api api) { + this.api = api; + } + + private void broadcast(String type, Object data) { + if(api != null) api.broadcast(type, data); + } + + @Override + public void handlePlayerJoin(Player player) { + broadcast("player_joined", player.getStatusConfig()); + } + + @Override + public void handlePlayerLeave(Player player) { + broadcast("player_left", player.getStatusConfig()); + } + + @Override + public void handleZoneDiscovered(Zone zone) { + broadcast("zone_discovered", DirectDataFetcher.createZoneInfo(zone)); + } + + // Open to discussion + @Override + public void handlePlayerMessage(Player player, String message) { + Map msg = new HashMap<>(); + msg.put("player", player.getStatusConfig()); + msg.put("message", message); + broadcast("player_message", msg); + } +} diff --git a/src/main/java/brainwine/ServerThread.java b/src/main/java/brainwine/ServerThread.java index 3b7279b0..aa968555 100644 --- a/src/main/java/brainwine/ServerThread.java +++ b/src/main/java/brainwine/ServerThread.java @@ -30,6 +30,8 @@ public void run() { logger.info(SERVER_MARKER, "Starting server ..."); gameServer = new GameServer(); api = new Api(new DirectDataFetcher(gameServer.getPlayerManager(), gameServer.getZoneManager())); + DirectPusher pusher = new DirectPusher(api); + gameServer.setPusher(pusher); TickLoop tickLoop = new TickLoop(8, () -> { gameServer.tick(); });