diff --git a/gameserver/src/main/java/brainwine/gameserver/GameServer.java b/gameserver/src/main/java/brainwine/gameserver/GameServer.java index e29fdf38..27365f8a 100644 --- a/gameserver/src/main/java/brainwine/gameserver/GameServer.java +++ b/gameserver/src/main/java/brainwine/gameserver/GameServer.java @@ -16,6 +16,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; @@ -50,6 +51,7 @@ public GameServer() { EntityRegistry.init(); EntityManager.loadEntitySpawns(); GrowthManager.loadGrowthData(); + Quests.loadQuests(); lootManager = new LootManager(); prefabManager = new PrefabManager(); ZoneGenerator.init(); 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..28a9e92b --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/QuestsCommand.java @@ -0,0 +1,29 @@ +package brainwine.gameserver.command; + +import brainwine.gameserver.player.NotificationType; +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.PlayerQuestDialog; + +@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); + } + + PlayerQuestDialog.showPlayerQuests((Player) executor); + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/quests"; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor instanceof Player; + } + +} 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/entity/Entity.java b/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java index 8bb3e79c..f96a9230 100644 --- a/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java +++ b/gameserver/src/main/java/brainwine/gameserver/entity/Entity.java @@ -11,6 +11,7 @@ import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.item.Layer; 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; @@ -101,12 +102,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; } @@ -114,6 +125,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 fdb43f88..6848fd92 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; @@ -26,6 +27,7 @@ public class EntityConfig { private final String name; private final int type; + private String title = "Unknown"; private int experienceYield; private float maxHealth = Entity.DEFAULT_HEALTH; private float baseSpeed = 3; @@ -68,6 +70,18 @@ public String getName() { public int getType() { return type; } + + 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() { 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/player/Player.java b/gameserver/src/main/java/brainwine/gameserver/player/Player.java index 9c8760d2..c405f1b1 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/Player.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/Player.java @@ -18,8 +18,10 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import brainwine.gameserver.quest.DailyQuests; +import brainwine.gameserver.quest.Quest; +import brainwine.gameserver.util.ValueWithExpiry; import com.fasterxml.jackson.annotation.JsonCreator; - import brainwine.gameserver.GameConfiguration; import brainwine.gameserver.GameServer; import brainwine.gameserver.Timer; @@ -43,6 +45,8 @@ import brainwine.gameserver.item.MiningBonus; import brainwine.gameserver.item.consumables.Consumable; import brainwine.gameserver.loot.Loot; +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; @@ -116,6 +120,8 @@ public class Player extends Entity implements CommandExecutor { private Map skills; private Map> bumpedSkills; private Map appearance; + private Map questProgresses = new HashMap<>(); + private ValueWithExpiry> dailyQuest = ValueWithExpiry.getExpired(); private final Map settings = new HashMap<>(); private final Set activeChunks = new HashSet<>(); private final Map> dialogs = new HashMap<>(); @@ -143,9 +149,10 @@ public class Player extends Entity implements CommandExecutor { private long lastHeartbeat; private long lastTrackedEntityUpdate; private long lastLandmarkVoteAt; + private long lastQuestTimeMessageAt; private Zone nextZone; private Connection connection; - + protected Player(String documentId, PlayerConfigFile config) { super(config.getCurrentZone()); this.documentId = documentId; @@ -171,6 +178,8 @@ protected Player(String documentId, PlayerConfigFile config) { this.skills = config.getSkills(); this.bumpedSkills = config.getBumpedSkills(); this.appearance = config.getAppearance(); + this.questProgresses = config.getQuestProgresses(); + this.dailyQuest = config.getDailyQuest(); health = getMaxHealth(); inventory.setPlayer(this); statistics.setPlayer(this); @@ -239,6 +248,15 @@ public void tick(float deltaTime) { sendMessage(new EntityPositionMessage(trackedEntities)); lastTrackedEntityUpdate = now; } + + 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 @@ -317,10 +335,10 @@ public void applyBreath(float deltaTime) { } } } - + public void applyThirst(float deltaTime) { long now = System.currentTimeMillis(); - + // Update thirst stat if(isGodMode()) { thirst = 0.0; @@ -329,16 +347,16 @@ public void applyThirst(float deltaTime) { 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); @@ -347,7 +365,7 @@ 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) { attack(null, null, 0.25F, DamageType.FIRE, true); // Apply as true damage @@ -355,10 +373,10 @@ public void applyThirst(float deltaTime) { } } } - + public void applyFreeze(float deltaTime) { long now = System.currentTimeMillis(); - + // Update freeze stat if(isGodMode()) { cold = 0.0; @@ -367,23 +385,23 @@ public void applyFreeze(float deltaTime) { 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) { 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; @@ -514,10 +532,11 @@ public void onZoneChanged() { 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)); sendMessage(new EventMessage("socialInfoReady", null)); - + // Misc stuff updateAchievementProgress(JourneymanAchievement.class); checkRegistration(); + QuestEvents.handleEnterZone(this, zone); } /** @@ -980,60 +999,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); } @@ -1325,12 +1344,25 @@ public void randomizeAppearance() { public void updateAppearance(Map appearance) { this.appearance.putAll(appearance); zone.sendMessage(new EntityChangeMessage(id, appearance)); + QuestEvents.handleAppearance(this, appearance); } public Map getAppearance() { return Collections.unmodifiableMap(appearance); } - + + public Map getQuestProgresses() { + return questProgresses; + } + + public ValueWithExpiry> getDailyQuest() { + return dailyQuest; + } + + public void setDailyQuest(ValueWithExpiry> dailyQuest) { + this.dailyQuest = dailyQuest; + } + public void setSkillLevel(Skill skill, int level) { skills.put(skill, level); sendMessage(new SkillMessage(skill, level)); @@ -1422,6 +1454,7 @@ public void awardLoot(Loot loot, DialogType dialogType) { 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))); diff --git a/gameserver/src/main/java/brainwine/gameserver/player/PlayerConfigFile.java b/gameserver/src/main/java/brainwine/gameserver/player/PlayerConfigFile.java index 4437fddb..fb56ccc4 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/PlayerConfigFile.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/PlayerConfigFile.java @@ -1,12 +1,9 @@ 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.util.*; +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 +11,7 @@ import brainwine.gameserver.achievement.Achievement; import brainwine.gameserver.item.Item; +import brainwine.gameserver.quest.QuestProgress; import brainwine.gameserver.zone.Zone; @JsonIgnoreProperties(ignoreUnknown = true) @@ -42,6 +40,8 @@ public class PlayerConfigFile { 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(); public PlayerConfigFile(Player player) { this.name = player.getName(); @@ -67,6 +67,8 @@ public PlayerConfigFile(Player player) { this.skills = player.getSkills(); this.bumpedSkills = player.getBumpedSkills(); this.appearance = player.getAppearance(); + this.questProgresses = player.getQuestProgresses(); + this.dailyQuest = player.getDailyQuest(); } @JsonCreator @@ -178,4 +180,13 @@ 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; + } } 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..dc30368e --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/DailyQuests.java @@ -0,0 +1,73 @@ +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, 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 #" + i); + newQuest.setGroup("Daily Quests"); + } + + if(currentV.getValue() != null) { + for(Quest current : currentV.getValue()) { + String oldQuestId = current.getId(); + PlayerQuests.cancelQuest(player, oldQuestId); + + 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; + + quest.clearDetailsCache(); + 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..d4445887 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/PlayerQuestDialog.java @@ -0,0 +1,111 @@ +package brainwine.gameserver.quest; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogHelper; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.player.Player; + +public class PlayerQuestDialog { + private PlayerQuestDialog() {} + + public static Dialog questOffersDialogGet(List quests) { + Dialog result = new Dialog().setTitle("My Quest Offers"); + + 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(Object[] ans) { + if(ans.length == 0) return null; + if("cancel".equals(ans[0])) return null; + + else return Quests.get((String) ans[0]); + } + + public static Dialog confirmBeginQuestDialogGet(Quest quest) { + Dialog result = new Dialog().setTitle(quest.getTitle()); + + 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()); + } + + public static Dialog playerQuestsDialogGet(Player player) { + Dialog result = new Dialog().setTitle("Your Quests"); + + List all = getPlayerQuestsSection(player, false); + + for(DialogSection section : all) { + result.addSection(section); + } + + return result; + } + + public static void playerQuestsDialogHandle(Player player, 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]); + } + } + } + + public static List getPlayerQuestsSection(Player player, boolean canFinishQuest) { + List result = new ArrayList<>(); + for(QuestProgress questProgress : player.getQuestProgresses().values()) { + if(!questProgress.isComplete()) { + result.addAll(questProgress.getDialogSection(player, canFinishQuest)); + } + } + + return result; + } + + /* DRIVER FUNCTIONS */ + + public static void offerQuests(Player player, List quests, Consumer onSelect) { + player.showDialog(questOffersDialogGet(quests), ans -> { + Quest quest = questOffersDialogGetSelectedOffer(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) { + player.showDialog(playerQuestsDialogGet(player), ans -> playerQuestsDialogHandle(player, 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..06b39b6b --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/PlayerQuests.java @@ -0,0 +1,180 @@ +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.player.Player; +import brainwine.gameserver.server.messages.QuestMessage; + +public class PlayerQuests { + 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); + + 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); + } + + public static boolean canFinishQuest(Player player, Quest quest) { + 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(!task.checkComplete(player, currentQuantity) || !task.checkCollectInventory(player)) { + return false; + } + } + + return true; + } + + public static void cancelQuest(Player player, String questId) { + QuestProgress progress = player.getQuestProgresses().get(questId); + + if(progress == null || progress.isComplete()) return; + + String reason = progress.tryCancelOtherwiseReason(player); + if(reason != null) { + player.showDialog(DialogHelper.messageDialog("Cannot Cancel Quest", reason)); + return; + } + + 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(QuestTask task : quest.getTasks()) { + if(task.getCollectInventory() != null) { + task.getCollectInventory().removeFromPlayer(player); + } + } + + for(int i = 0; i < quest.getTasks().size(); i++) { + progress.getTaskProgresses().set(i, quest.getTasks().get(i).getQuantity()); + } + + quest.getReward().reward(player); + progress.markAsComplete(); + QuestEvents.handleCompleteQuest(player); + sendPlayerQuestMessage(player, progress); + } + + public static void performAction(Player player, Quest quest, QuestAction.Type actionType) { + if(quest.getActions() == null) return; + + List actions = quest.getActions().get(actionType); + + if(actions == null) return; + + for(QuestAction action : actions) { + action.performAction(player); + } + } + + public static void sendInitialPlayerQuestMessages(Player player) { + Map progresses = player.getQuestProgresses(); + + if(progresses == null) return; + + List dailyQuests = player.getDailyQuest() == null ? null : player.getDailyQuest().getValue(); + if(player.getDailyQuest() != null && !player.getDailyQuest().isExpired() && dailyQuests != null) { + for(Quest dailyQuest : dailyQuests) { + QuestProgress progress = player.getQuestProgresses().get(dailyQuest.getId()); + if(progress != null) sendPlayerQuestMessage(player, progress); + } + } + + for(QuestProgress progress : progresses.values()) { + sendPlayerQuestMessage(player, progress); + } + } + + 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 + if(player.isV3()) { + player.sendMessage(new QuestMessage(quest.getPcDetails(), progress.getClientStatus(player))); + } else { + player.sendMessage(new QuestMessage(quest.getMobileDetails(), 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..855cb152 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/Quest.java @@ -0,0 +1,186 @@ +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; + + @JsonIgnore + private Map pcDetails = null; + + @JsonIgnore + private Map mobileDetails = null; + + 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; + } + + private void computeClientDetailsIfAbsent() { + if(pcDetails == null || mobileDetails == null) { + pcDetails = new HashMap<>(); + mobileDetails = pcDetails; + + 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); + + if(getDescriptionMobile() != null) { + mobileDetails = new HashMap<>(pcDetails); + + mobileDetails.put("desc", getDescriptionMobile()); + } + } + } + + public void clearDetailsCache() { + pcDetails = null; + mobileDetails = null; + } + + @JsonIgnore + public Map getPcDetails() { + computeClientDetailsIfAbsent(); + return pcDetails; + } + + @JsonIgnore + public Map getMobileDetails() { + computeClientDetailsIfAbsent(); + 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..7748423a --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestAction.java @@ -0,0 +1,114 @@ +package brainwine.gameserver.quest; + +import java.lang.IllegalArgumentException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +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 void performAction(Player player) { + try{ + switch(getMethod()) { + case "gift_items!": + for(Object object : getParams()) { + Map items = JsonHelper.readValue(object, new TypeReference>() {}); + for(String k : items.keySet()) { + Item item = Item.get(k); + if(item == null) 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); + } + + if(params.size() >= 2) { + if(!(params.get(0) instanceof String)) throw new IllegalArgumentException(); + title = (String) params.get(0); + } + + if(title == null) { + player.showDialog(DialogHelper.messageDialog(body)); + } else { + player.showDialog(DialogHelper.messageDialog(title, body)); + } + break; + case "add_xp": + 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())); + } + } + +} 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..50bf3f89 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestEvents.java @@ -0,0 +1,135 @@ +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(); + 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.getTaskProgresses().set(i, questProgress.getTaskProgress(i) + quantity); + + if(event.size() >= 3 && "interact".equals(event.get(0)) && "name".equals(event.get(1))) { + PlayerQuests.performAction(player, quest, QuestAction.Type.INTERACT); + } + + anyProgress = true; + break; + } + } catch(Exception e) { + e.printStackTrace(); + } + } + i++; + } + + if(anyProgress) { + PlayerQuests.sendPlayerQuestMessage(player, questProgress); + } + + if(PlayerQuests.canFinishQuest(player, quest)) { + PlayerQuests.finishQuest(player, quest); + PlayerQuests.performAction(player, quest, QuestAction.Type.DONE); + } + + } + } + + 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 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 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..e0073544 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestProgress.java @@ -0,0 +1,165 @@ +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.DialogListItem; +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 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 canFinishQuest) { + 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()); + + if(PlayerQuests.canFinishQuest(player, quest)) { + mainSection.addItem(new DialogListItem().setText("All tasks done. Visit a quester android to claim your reward!")); + } + + for(int i = 0; i < quest.getTasks().size(); i++) { + result.add(quest.getTasks().get(i).getDialogSection(player, getTaskProgress(i))); + } + + if(canFinishQuest) { + result.add(new DialogSection().setText("Finish Quest").setChoice(getActionChoice("finish"))); + } + + DialogSection cancelSection = new DialogSection().setChoice(getActionChoice("cancel")); + + if(player.isV3()) { + 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 tryCancelOtherwiseReason(Player player) { + if(player.isGodMode()) return null; + + Quest quest = getQuest(player); + if(quest == null) return null; + + String reason = null; + int count = 0; + for(QuestAction.Type action : new QuestAction.Type[] { QuestAction.Type.BEGIN, QuestAction.Type.INTERACT }) { + if(quest.getActions().containsKey(action)) { + if(reason == null) { + reason = action.toString(); + } else { + reason += ", " + action.toString(); + } + } + + count++; + } + + if(reason == null) { + return null; + } else { + return "Cannot cancel because the quest has the " + reason + (count == 1 ? " action." : " actions."); + } + } + +} 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..0da3b032 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestStory.java @@ -0,0 +1,48 @@ +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(); + } + } +} 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..d70ffff6 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestTask.java @@ -0,0 +1,200 @@ +package brainwine.gameserver.quest; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +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; + + 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 DialogSection getDialogSection(Player player, int taskProgress) { + DialogSection result = new DialogSection(); + + String title = getDescription() + (taskProgress >= 0 ? String.format(" (Progress: %d/%d)", taskProgress, getQuantity()) : ""); + if(player == null || player.isV3()) { + result.setTitle("" + title + ""); + } else { + result.setTitle(title).setTextColor("#00ffff"); + } + + if(getQualify() != null && !getQualify().isEmpty()) { + result.addItem(new DialogListItem().setText("Qualifications:")); + for(List qualification : getQualify()) { + result.addItem(new DialogListItem().setText(qualification.stream().map(Objects::toString).collect(Collectors.joining(" ")))); + } + } + + if(getEvents() != null && !getEvents().isEmpty()) { + result.addItem(new DialogListItem().setText("Do any of these to make progress:")); + for(List event : getEvents()) { + result.addItem(new DialogListItem().setText(event.stream().map(Objects::toString).collect(Collectors.joining(" ")))); + } + } + + if(getCollectInventory() != null && !getCollectInventory().getRequirements().isEmpty()) { + result.addItem(new DialogListItem().setText("Things To Collect:")); + + getCollectInventory().addDialogListItems(result); + } + + return result; + + } + +} 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..60727122 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/QuestTaskCollectInventory.java @@ -0,0 +1,69 @@ +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()); + } + } + + public void addDialogListItems(DialogSection section) { + for(Pair req : getRequirements()) { + Item item = ItemRegistry.getItem(req.getFirst()); + if(!item.isAir()) section.addItem( + new DialogListItem() + .setItem(item.getCode())) + .setText(item.getTitle()); + } + } + +} 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..e0136278 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/Quests.java @@ -0,0 +1,170 @@ +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.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 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..c540fb3e --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuest.java @@ -0,0 +1,66 @@ +package brainwine.gameserver.quest; + +import java.util.List; +import java.util.Random; + +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"), +}) +@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; + + 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; + } +} 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..7d10933c --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/RandomQuests.java @@ -0,0 +1,109 @@ +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.ConcretionFailureException; +import brainwine.gameserver.util.randomobject.ObjectMapperProvider; +import brainwine.gameserver.util.randomobject.RandomInteger; +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, int n) { + return generateRandomPlayerQuests(ThreadLocalRandom.current(), player, n); + } + + public static List generateRandomPlayerQuests(Random random, Player player, int n) { + if(configuration.randomQuests.isEmpty()) { + return null; + } + + int maxTier = getMaxTier(player); + List candidates = configuration.randomQuests.stream() + .filter(q -> q.getTier() <= maxTier) + .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); + + return list.get(random.nextInt(list.size())); + } +} 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..4fa54a73 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Collect.java @@ -0,0 +1,78 @@ +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.setTasks(tasks); + quest.setReward(RandomQuestReward.nextOrDefault(random, getReward())); + + 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..ae78a6d4 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Craft.java @@ -0,0 +1,70 @@ +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; + +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.setTasks(tasks); + quest.setReward(RandomQuestReward.nextOrDefault(random, getReward())); + + return quest; + } catch (ConcretionFailureException e) { + throw new IllegalStateException("Concretion failure!"); + } + } +} 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..88899d71 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/Kill.java @@ -0,0 +1,179 @@ +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); + + if(categories != null) { + List values = categories.next(random); + int quantity = defaultIfNull(categoryQuantity, this.quantity).next(random); + tasks.add(makeTaskForEntityCategories(actions, values, quantity)); + } + + 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)); + } + + 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)); + } + + quest.setTasks(tasks); + quest.setReward(RandomQuestReward.nextOrDefault(random, getReward())); + + 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..5e1334fe --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/quest/randomquests/MultiStep.java @@ -0,0 +1,64 @@ +package brainwine.gameserver.quest.randomquests; + +import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.Quest; +import brainwine.gameserver.quest.QuestTask; +import brainwine.gameserver.quest.RandomQuest; +import brainwine.gameserver.quest.RandomQuestReward; +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 description; + + @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() + .setDescription(defaultIfNull(description, toBeCopied.getDescription())) + .setTasks(tasks) + .setReward(RandomQuestReward.nextOrDefault(random, getReward(), altRandomQuestReward)); + } + + return new Quest() + .setDescription(description) + .setTasks(tasks) + .setReward(RandomQuestReward.nextOrDefault(random, getReward())); + + } catch(ConcretionFailureException e) { + throw new IllegalStateException("Concretion failure!"); + } + } +} 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/requests/BlockMineRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java index c9bf8c6d..e64f3364 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/BlockMineRequest.java @@ -15,6 +15,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; @@ -105,6 +106,7 @@ public void process(Player player) { if(digging) { zone.digBlock(x, y); + QuestEvents.handleDig(player); return; } @@ -197,9 +199,11 @@ public void process(Player player) { int quantity = 1; player.getStatistics().trackItemMined(item); - + + boolean trackQuest = false; if(block.isNatural()) { player.getStatistics().trackItemScavenged(item); + trackQuest = true; } zone.updateBlock(x, y, layer, 0, 0, player); @@ -223,6 +227,12 @@ public void process(Player player) { 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/CraftRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java index 618f8da1..73ace412 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/CraftRequest.java @@ -8,6 +8,7 @@ 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; @@ -100,5 +101,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/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/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/EntityManager.java b/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java index 0c2ff5cf..dbe96fcc 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/EntityManager.java @@ -30,6 +30,7 @@ import brainwine.gameserver.item.Layer; import brainwine.gameserver.item.ModType; import brainwine.gameserver.player.Player; +import brainwine.gameserver.quest.PlayerQuests; import brainwine.gameserver.resource.ResourceFinder; import brainwine.gameserver.server.messages.EntityPositionMessage; import brainwine.gameserver.server.messages.EntityStatusMessage; @@ -297,6 +298,8 @@ public void addEntity(Entity entity) { player.onZoneChanged(); players.put(entityId, player); playersByName.put(player.getName().toLowerCase(), player); + PlayerQuests.deleteUnknownQuestProgress(player); + PlayerQuests.sendInitialPlayerQuestMessages(player); player.sendMessageToPeers(new EntityStatusMessage(player, EntityStatus.ENTERING)); player.sendMessageToPeers(new EntityPositionMessage(player)); } else if(entity instanceof Npc) { diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java index 56be8bc2..c3826a5f 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java @@ -39,6 +39,7 @@ 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; @@ -289,6 +290,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); } @@ -507,6 +509,10 @@ 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); + } } } } @@ -926,6 +932,7 @@ public void destroyGuardBlock(String dungeonId, Player destroyer) { if(guardBlocks <= 0) { dungeons.remove(dungeonId); destroyer.getStatistics().trackDungeonRaided(); + QuestEvents.handleRaid(destroyer); destroyer.notify("You raided a dungeon!", NotificationType.ACCOMPLISHMENT); destroyer.notifyPeers(String.format("%s raided a dungeon.", destroyer.getName()), NotificationType.SYSTEM); } else { 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/random-quests.json b/gameserver/src/main/resources/random-quests.json new file mode 100644 index 00000000..3ac1bc51 --- /dev/null +++ b/gameserver/src/main/resources/random-quests.json @@ -0,0 +1,174 @@ +{ + "daily_quest_interval": "24h", + "daily_quest_count": 5, + "strings": { + "kill_description": [ + "Today you got to kill some critters!", + "What time is it? Uh, we need to keep the game child-friendly.", + "Blow them up for some crowns!", + "Nothing better than relieving some stress by putting your weapons in good use!" + ], + "collect_description": [ + "Time for your daily item collecting.", + "The shelves in your father's thrift store fell empty. Help them stock it back up!", + "You had been scammed by Graptik, but don't be afraid. Today is the day you will get your items back.", + "Hot stuff in your area, waiting for you to collect, within the next 24 hours@" + ], + "craft_description": [ + "Today you are tasked to craft the following items.", + "Time to be productive.", + "Craft these.", + "The mayor of Cake Land needs you to craft these items!" + ] + }, + "quests": [ + { + "frequency": 10, + "type": "kill", + "categories": [ "terrapus" ], + "quantity": { "random_between": [ 5, 10 ] }, + "reward": { + "xp": { "random_between": [ 50, 150, 10 ] }, + "crowns": 20 + } + }, + { + "frequency": 10, + "type": "kill", + "categories": [ "automata" ], + "task_description": "Stop the bot uprisal! Smash 3 androids of any kind!", + "quantity": 3, + "reward": { "crowns": 20 } + }, + { + "frequency": 10, + "type": "multistep", + "steps": [ + { "type": "collect", "tasks": [{ "items": ["building/wood"], "count": 3 }] }, + { "type": "collect", "tasks": [{ "items": ["building/brass"], "count": 6 }] }, + { "type": "collect", "tasks": [{ "items": ["building/iron"], "count": 3 }] }, + { "type": "craft", "tasks": [{ "items": ["building/door-wood"], "count": 3 }, { "items": ["building/door-brass"], "count": 3 }] } + ], + "reward": { "crowns": 20 } + }, + { + "frequency": 100, + "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 pests! Kill {QUANTITY} pests of any kind!", + "quantity": { "random_between": [3, 5] }, + "reward": { "crowns": 20 } + }, + { + "frequency": 70, + "type": "collect", + "tasks": [ + { + "items": { + "choices": [ + "mechanical/pipe", + "mechanical/pipecopper", + "mechanical/pipeiron", + "containers/barrel", + "containers/barrel-tall", + "containers/crate-small", + "containers/crate-large", + "containers/crate-industrial-small", + "containers/crate-industrial-large", + "lighting/floorlamp", + "building/staircase", + "building/staircase-copper", + "building/staircase-iron", + "building/iron", + "building/copper", + "building/brass", + "rubble/gravestone" + ] + }, + "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": 80, + "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 } + } + ] +}