diff --git a/README.md b/README.md index 5ef98cae..97fcf41c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ We will seek to implement CI/CD in the repository so that developers have a good - Simple Galaxy Gate: Supports ABG, Delta, Epsilon, Zeta, Hades, Kuiper, LoW, Invasion, Mimesis, EBG, GoP, Treacherous, DSE, Fiesta, Trinity and Voyagers. By @do-gamer - Autobuy: Automatically buys boosters and special items from the shop at a configured interval. By @do-gamer - Log Overlay: Displays the latest in-game log messages on the canvas (top-center, auto-fade after 5s). Built-in keyword whitelist limits the displayed lines to gains and errors so the canvas does not get cluttered. By @Halizeur +- DailyTaskAPI: Detects supported daily quests from DarkBot's typed quest data, executes multi-step NPC, map, collection, ore and opt-in player-combat objectives, applies configurable reward and safety policies before accepting them, and uses DarkBot's NPC metadata and PET Enemy Locator while hunting. By Berke ## Contributing diff --git a/build.gradle.kts b/build.gradle.kts index 67e36e84..3d102465 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,12 @@ description = "SharedPlugin" dependencies { api("eu.darkbot.DarkBotAPI", "darkbot-impl", "0.9.8") api("eu.darkbot", "DarkBot", "dc48506543") + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() } @@ -40,4 +46,4 @@ tasks.named("jar") { tasks.register("signFile") { dependsOn("build") commandLine("cmd", "/c", "sign.bat") -} \ No newline at end of file +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyNpcCombat.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyNpcCombat.java new file mode 100644 index 00000000..ed50ef6c --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyNpcCombat.java @@ -0,0 +1,93 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.PluginAPI; +import eu.darkbot.api.game.entities.Npc; +import eu.darkbot.shared.modules.LootModule; + +import java.util.Comparator; + +/** + * DarkBot's standard NPC combat engine with target selection restricted to + * the NPC required by the currently displayed daily quest. + * + *

The inherited implementation supplies the saved NPC radius, circle and + * run-configuration behaviour, ammunition selection, target validation and + * SafetyFinder logic used by NPC Kill and Collect. DailyTaskAPI remains the + * owner of quest and map selection.

+ */ +final class DailyNpcCombat extends LootModule { + private String targetDescription; + private int targetRadius = 590; + + DailyNpcCombat(PluginAPI api) { + super(api); + } + + void tick(String description, int attackRadius) { + targetDescription = description; + targetRadius = attackRadius; + super.onTickModule(); + } + + void stopCombat() { + attack.stopAttack(); + attack.setTarget(null); + targetDescription = null; + } + + void shutdownPet() { + stopCombat(); + if (pet.isEnabled()) pet.setEnabled(false); + } + + boolean isPetActive() { + return pet.isActive(); + } + + @Override + protected boolean checkMap() { + // DailyTaskAPI resolves and navigates to the quest's required map. + // Do not let the saved working-map setting override that choice. + return true; + } + + @Override + protected boolean findTarget() { + Npc current = attack.getTargetAs(Npc.class); + if (isUsableTarget(current)) return true; + + if (current != null) { + attack.stopAttack(); + attack.setTarget(null); + } + + Npc selected = npcs.stream() + .filter(this::isUsableTarget) + .min(Comparator.comparingDouble(hero::distanceTo)) + .orElse(null); + attack.setTarget(selected); + // MovementAPI.moveRandom follows the saved preferred-zone route for + // this map and falls back to a random map point when no route exists. + if (selected == null && !movement.isMoving()) { + movement.moveRandom(); + } + return selected != null; + } + + private boolean isUsableTarget(Npc npc) { + if (npc == null || targetDescription == null || + !npc.isValid() || !npc.isSelectable() || npc.isBlacklisted()) { + return false; + } + String name = npc.getInfo() == null ? null : npc.getInfo().getName(); + if (name == null || movement.getClosestDistance(npc) >= targetRadius) return false; + + String target = DailyTaskPlanner.normalizeNpcName(targetDescription); + String candidate = DailyTaskPlanner.normalizeNpcName(name); + boolean wantsBoss = target.contains("boss"); + boolean wantsUber = target.contains("uber"); + return !candidate.isBlank() && target.contains(candidate) && + wantsBoss == candidate.startsWith("boss ") && + wantsUber == candidate.startsWith("uber"); + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyNpcMapResolver.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyNpcMapResolver.java new file mode 100644 index 00000000..126251a3 --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyNpcMapResolver.java @@ -0,0 +1,63 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.config.ConfigSetting; +import eu.darkbot.api.config.types.NpcInfo; +import eu.darkbot.api.game.other.GameMap; +import eu.darkbot.api.managers.StarSystemAPI; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** Resolves NPC maps from DarkBot's own NPC metadata instead of a static list. */ +final class DailyNpcMapResolver { + private final ConfigSetting> npcInfos; + private final StarSystemAPI starSystem; + + DailyNpcMapResolver(ConfigSetting> npcInfos, StarSystemAPI starSystem) { + this.npcInfos = npcInfos; + this.starSystem = starSystem; + } + + Optional resolve(String description, String companyPrefix) { + try { + Map configured = npcInfos.getValue(); + if (configured == null || configured.isEmpty()) return Optional.empty(); + return resolve(description, companyPrefix, new ArrayList<>(configured.values())); + } catch (RuntimeException ignored) { + return Optional.empty(); + } + } + + private Optional resolve(String description, String companyPrefix, List configured) { + Optional target = configured.stream() + .filter(info -> info != null && DailyTaskPlanner.matchesNpcName(description, info.getName())) + .max(Comparator.comparingInt(info -> + DailyTaskPlanner.normalizeNpcName(info.getName()).length())); + if (target.isEmpty()) return Optional.empty(); + + Collection mapIds = target.get().getMapIds(); + if (mapIds == null || mapIds.isEmpty()) return Optional.empty(); + + GameMap current = starSystem.getCurrentMap(); + if (current != null && mapIds.contains(current.getId()) && starSystem.isAccessible(current)) { + return Optional.of(current.getName()); + } + + String prefix = companyPrefix != null && companyPrefix.matches("[1-3]") + ? companyPrefix + "-" + : ""; + return mapIds.stream() + .map(starSystem::findMap) + .flatMap(Optional::stream) + .filter(starSystem::isAccessible) + .filter(map -> !map.isGG()) + .min(Comparator + .comparingInt((GameMap map) -> map.getName().startsWith(prefix) ? 0 : 1) + .thenComparing(GameMap::getName)) + .map(GameMap::getName); + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyPlayerCombat.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyPlayerCombat.java new file mode 100644 index 00000000..dd487540 --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyPlayerCombat.java @@ -0,0 +1,159 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.PluginAPI; +import eu.darkbot.api.game.entities.Player; +import eu.darkbot.api.game.other.EntityInfo; +import eu.darkbot.api.managers.AttackAPI; +import eu.darkbot.api.managers.EntitiesAPI; +import eu.darkbot.api.managers.GroupAPI; +import eu.darkbot.api.managers.HeroAPI; +import eu.darkbot.api.managers.MovementAPI; + +import java.util.Comparator; + +/** Executes opt-in player-combat daily objectives with conservative filters. */ +final class DailyPlayerCombat { + private final EntitiesAPI entities; + private final GroupAPI group; + private final MovementAPI movement; + private final AttackAPI attack; + private final HeroAPI hero; + + private long searchStartedAt; + private long nextRoamAt; + private int deaths; + private boolean previouslyAlive = true; + private String status = "waiting"; + + DailyPlayerCombat(PluginAPI api) { + entities = api.requireAPI(EntitiesAPI.class); + group = api.requireAPI(GroupAPI.class); + movement = api.requireAPI(MovementAPI.class); + attack = api.requireAPI(AttackAPI.class); + hero = api.requireAPI(HeroAPI.class); + } + + void startQuest() { + searchStartedAt = 0L; + nextRoamAt = 0L; + deaths = 0; + previouslyAlive = hero.getHealth().hpPercent() > 0d; + status = "searching for an eligible player"; + } + + void tick(String objectiveDescription, DailyTaskConfig config) { + long now = System.currentTimeMillis(); + observeHeroState(); + if (hero.getHealth().hpPercent() <= 0d) { + stopAttackOnly(); + status = "waiting for ship recovery"; + return; + } + + Player target = attack.getTargetAs(Player.class); + if (!isEligible(target, objectiveDescription, config)) { + stopAttackOnly(); + target = entities.getPlayers().stream() + .filter(player -> isEligible(player, objectiveDescription, config)) + .min(playerComparator(config)) + .orElse(null); + attack.setTarget(target); + } + + if (target == null) { + if (searchStartedAt == 0L) searchStartedAt = now; + if (!movement.isMoving() || now >= nextRoamAt) { + movement.moveRandom(); + nextRoamAt = now + 2_500L; + } + status = "searching for an eligible enemy player"; + return; + } + + searchStartedAt = 0L; + double distance = hero.distanceTo(target); + if (distance > config.playerAttackRadius) { + hero.setRoamMode(); + movement.moveTo(target); + status = "approaching player at " + (int) Math.round(distance); + return; + } + + movement.stop(false); + attack.tryLockAndAttack(); + status = "attacking eligible enemy player"; + } + + boolean exceededSafetyLimits(DailyTaskConfig config) { + boolean deathLimit = config.maxPlayerDeaths > 0 && deaths >= config.maxPlayerDeaths; + boolean searchLimit = searchStartedAt > 0L && + System.currentTimeMillis() - searchStartedAt >= config.playerSearchTimeoutSeconds * 1_000L; + return deathLimit || searchLimit; + } + + void observeHeroState() { + observeDeath(); + } + + String failureReason(DailyTaskConfig config) { + if (config.maxPlayerDeaths > 0 && deaths >= config.maxPlayerDeaths) { + return "player-combat death limit reached (" + deaths + ")"; + } + return "no eligible player found before the search timeout"; + } + + String getStatus() { + return status + " | deaths: " + deaths; + } + + void stopCombat() { + stopAttackOnly(); + searchStartedAt = 0L; + } + + private void stopAttackOnly() { + if (attack.getTargetAs(Player.class) != null) { + attack.stopAttack(); + attack.setTarget(null); + } + } + + private void observeDeath() { + boolean alive = hero.getHealth().hpPercent() > 0d; + if (!alive && previouslyAlive) deaths++; + previouslyAlive = alive; + } + + private Comparator playerComparator(DailyTaskConfig config) { + Comparator distance = Comparator.comparingDouble(hero::distanceTo); + if (!config.prioritizePlayersAttackingMe) return distance; + return Comparator.comparing((Player player) -> !player.isAttacking(hero)).thenComparing(distance); + } + + private boolean isEligible(Player player, String objectiveDescription, DailyTaskConfig config) { + if (player == null || !player.isValid() || !player.isSelectable() || + player.isInvisible() || player.isBlacklisted() || player.getId() == hero.getId()) { + return false; + } + EntityInfo info = player.getEntityInfo(); + if (info == null) return false; + if (!matchesRequiredFaction(info, objectiveDescription)) return false; + if (config.avoidAlliedAndGroupPlayers && isProtected(player, info)) return false; + return !config.enemyPlayersOnly || info.isEnemy() || + info.getClanDiplomacy() == EntityInfo.Diplomacy.WAR; + } + + private boolean matchesRequiredFaction(EntityInfo info, String description) { + String normalized = DailyTaskPlanner.normalize(description); + if (normalized.contains("mmo")) return info.getFaction() == EntityInfo.Faction.MMO; + if (normalized.contains("eic")) return info.getFaction() == EntityInfo.Faction.EIC; + if (normalized.contains("vru")) return info.getFaction() == EntityInfo.Faction.VRU; + return true; + } + + private boolean isProtected(Player player, EntityInfo info) { + EntityInfo.Diplomacy diplomacy = info.getClanDiplomacy(); + return group.getMember(player) != null || diplomacy == EntityInfo.Diplomacy.ALLIED || + diplomacy == EntityInfo.Diplomacy.NOT_ATTACK_PACT; + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyQuestConditionEngine.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyQuestConditionEngine.java new file mode 100644 index 00000000..548d0a68 --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyQuestConditionEngine.java @@ -0,0 +1,371 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.game.other.Locatable; +import eu.darkbot.api.managers.OreAPI; +import eu.darkbot.api.managers.QuestAPI; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Builds an executable daily-quest plan from DarkBot's typed QuestAPI source. + * + *

Quest discovery stays in {@link DailyTaskAPI}, while this class translates + * enabled, unfinished requirements into map, coordinate, NPC, collection and + * ore actions. It has no dependency on DmPlugin and does not alter another + * module's saved configuration.

+ */ +final class DailyQuestConditionEngine { + private static final Pattern X_COORDINATE = Pattern.compile( + "\\bx(?:-koordinat[i\\x{0131}]?)?\\s*+(?:[:=]\\s*+)?(-?\\d{1,5}+)", + Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + private static final Pattern Y_COORDINATE = Pattern.compile( + "\\by(?:-koordinat[i\\x{0131}]?)?\\s*+(?:[:=]\\s*+)?(-?\\d{1,5}+)", + Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + private static final Pattern PAIRED_COORDINATES = Pattern.compile( + "(? steps; + + Plan(String targetMapName, ActiveObjective objective, + String primaryDescription, List steps) { + this.targetMapName = targetMapName; + this.targetNpcDescription = objective.targetNpcDescription; + this.targetPlayerDescription = objective.targetPlayerDescription; + this.oreToSell = objective.oreToSell; + this.collectBonus = objective.collectBonus; + this.collectCargo = objective.collectCargo; + this.targetCoordinates = objective.targetCoordinates; + this.primaryDescription = primaryDescription; + this.steps = List.copyOf(steps); + } + + String targetMapName() { + return targetMapName; + } + + String targetNpcDescription() { + return targetNpcDescription; + } + + String targetPlayerDescription() { + return targetPlayerDescription; + } + + OreAPI.Ore oreToSell() { + return oreToSell; + } + + boolean collectBonus() { + return collectBonus; + } + + boolean collectCargo() { + return collectCargo; + } + + Locatable targetCoordinates() { + return targetCoordinates; + } + + String primaryDescription() { + return primaryDescription; + } + + List steps() { + return steps; + } + + int priorityScore() { + if (targetPlayerDescription != null) return 4_000; + if (oreToSell != null) return 3_000; + if (collectBonus || collectCargo) return 2_000; + if (targetNpcDescription != null) return 1_000; + return 0; + } + + boolean executable() { + return targetMapName != null || targetNpcDescription != null || targetPlayerDescription != null || + oreToSell != null || + collectBonus || collectCargo || targetCoordinates != null; + } + + boolean coordinatesOnly() { + return targetCoordinates != null && targetNpcDescription == null && targetPlayerDescription == null && + oreToSell == null && + !collectBonus && !collectCargo; + } + } + + private static final class ActiveObjective { + private final String targetNpcDescription; + private final String targetPlayerDescription; + private final OreAPI.Ore oreToSell; + private final boolean collectBonus; + private final boolean collectCargo; + private final Locatable targetCoordinates; + + private ActiveObjective(String targetNpcDescription, String targetPlayerDescription, + OreAPI.Ore oreToSell, boolean collectBonus, boolean collectCargo, + Locatable targetCoordinates) { + this.targetNpcDescription = targetNpcDescription; + this.targetPlayerDescription = targetPlayerDescription; + this.oreToSell = oreToSell; + this.collectBonus = collectBonus; + this.collectCargo = collectCargo; + this.targetCoordinates = targetCoordinates; + } + } + + private DailyQuestConditionEngine() { + } + + static Plan build(QuestAPI.Quest quest) { + return build(quest, ignored -> Optional.empty()); + } + + static Plan build(QuestAPI.Quest quest, Function> npcMapResolver) { + List requirements = DailyTaskPlanner.actionable(quest); + List steps = buildSteps(requirements); + + String targetMap = requirements.stream() + .filter(DailyQuestConditionEngine::isMapCondition) + .map(QuestAPI.Requirement::getDescription) + .map(DailyTaskPlanner::findMap) + .flatMap(Optional::stream) + .findFirst() + .orElseGet(() -> requirements.stream() + .map(QuestAPI.Requirement::getDescription) + .map(DailyTaskPlanner::findMap) + .flatMap(Optional::stream) + .findFirst() + .orElse(null)); + + String targetNpc = requirements.stream() + .filter(DailyQuestConditionEngine::isNpcCondition) + .map(QuestAPI.Requirement::getDescription) + .filter(description -> description != null && !description.isBlank()) + .findFirst() + .orElse(null); + String targetPlayer = requirements.stream() + .filter(DailyQuestConditionEngine::isPlayerCondition) + .map(QuestAPI.Requirement::getDescription) + .filter(description -> description != null && !description.isBlank()) + .findFirst() + .orElse(null); + if (targetMap == null && targetNpc != null) { + targetMap = npcMapResolver.apply(targetNpc).orElse(null); + } + + OreAPI.Ore ore = requirements.stream() + .filter(requirement -> requirement.getRequirementType() == + QuestAPI.Requirement.RequirementType.SELL_ORE) + .map(QuestAPI.Requirement::getDescription) + .map(DailyTaskPlanner::findOre) + .flatMap(Optional::stream) + .findFirst() + .orElse(null); + + boolean collectBonus = requirements.stream().anyMatch(DailyQuestConditionEngine::isBonusCondition); + boolean collectCargo = requirements.stream().anyMatch(DailyQuestConditionEngine::isCargoCondition); + + Locatable coordinates = requirements.stream() + .filter(DailyQuestConditionEngine::isCoordinateCondition) + .map(QuestAPI.Requirement::getDescription) + .map(DailyQuestConditionEngine::findCoordinates) + .flatMap(Optional::stream) + .findFirst() + .orElse(null); + + StepType activeType = steps.stream() + .map(Step::type) + .filter(type -> type != StepType.MAP) + .findFirst() + .orElse(StepType.MAP); + if (activeType != StepType.NPC_COMBAT) targetNpc = null; + if (activeType != StepType.PLAYER_COMBAT) targetPlayer = null; + if (activeType != StepType.SELL_ORE) ore = null; + if (activeType != StepType.COLLECT_BONUS) collectBonus = false; + if (activeType != StepType.COLLECT_CARGO) collectCargo = false; + if (activeType != StepType.COORDINATES) coordinates = null; + + String primary = requirements.stream() + .filter(DailyQuestConditionEngine::isExecutableCondition) + .map(QuestAPI.Requirement::getDescription) + .filter(description -> description != null && !description.isBlank()) + .findFirst() + .orElseGet(() -> requirements.stream() + .map(QuestAPI.Requirement::getDescription) + .filter(description -> description != null && !description.isBlank()) + .findFirst() + .orElse("etkin hedef yok")); + + ActiveObjective objective = new ActiveObjective(targetNpc, targetPlayer, ore, + collectBonus, collectCargo, coordinates); + return new Plan(targetMap, objective, primary, steps); + } + + static boolean supports(QuestAPI.Quest quest) { + return supports(quest, false); + } + + static boolean supports(QuestAPI.Quest quest, boolean allowPlayerCombat) { + List requirements = DailyTaskPlanner.actionable(quest); + if (requirements.isEmpty()) return false; + for (QuestAPI.Requirement requirement : requirements) { + if (isPlayerCondition(requirement) && !allowPlayerCombat) return false; + if (!isExecutableCondition(requirement)) return false; + } + return build(quest).executable(); + } + + static boolean hasPlayerCombat(QuestAPI.Quest quest) { + return DailyTaskPlanner.actionable(quest).stream() + .anyMatch(DailyQuestConditionEngine::isPlayerCondition); + } + + static boolean isNpcCondition(QuestAPI.Requirement requirement) { + if (requirement == null) return false; + QuestAPI.Requirement.RequirementType type = requirement.getRequirementType(); + return type == QuestAPI.Requirement.RequirementType.KILL_NPC || + type == QuestAPI.Requirement.RequirementType.KILL_NPCS || + type == QuestAPI.Requirement.RequirementType.DAMAGE_NPCS || + type == QuestAPI.Requirement.RequirementType.DAMAGE || + type == QuestAPI.Requirement.RequirementType.RESTRICT_AMMUNITION_KILL_NPC; + } + + static boolean isPlayerCondition(QuestAPI.Requirement requirement) { + if (requirement == null) return false; + QuestAPI.Requirement.RequirementType type = requirement.getRequirementType(); + return type == QuestAPI.Requirement.RequirementType.KILL_PLAYERS || + type == QuestAPI.Requirement.RequirementType.DAMAGE_PLAYERS || + type == QuestAPI.Requirement.RequirementType.DAMAGE_ENEMY_PLAYERS || + type == QuestAPI.Requirement.RequirementType.RESTRICT_AMMUNITION_KILL_PLAYER; + } + + private static boolean isMapCondition(QuestAPI.Requirement requirement) { + if (requirement == null) return false; + QuestAPI.Requirement.RequirementType type = requirement.getRequirementType(); + return type == QuestAPI.Requirement.RequirementType.MAP || + type == QuestAPI.Requirement.RequirementType.MAP_DIVERSE || + type == QuestAPI.Requirement.RequirementType.TRAVEL || + type == QuestAPI.Requirement.RequirementType.VISIT_MAP || + type == QuestAPI.Requirement.RequirementType.VISIT_MULTIPLE_MAPS || + type == QuestAPI.Requirement.RequirementType.VISIT_JUMP_GATE_TO_MAP_TYPE || + type == QuestAPI.Requirement.RequirementType.VISIT_MAP_ASSET || + type == QuestAPI.Requirement.RequirementType.VISIT_DISRUPTION_ZONE; + } + + private static boolean isCoordinateCondition(QuestAPI.Requirement requirement) { + if (requirement == null) return false; + QuestAPI.Requirement.RequirementType type = requirement.getRequirementType(); + return type == QuestAPI.Requirement.RequirementType.COORDINATES || + type == QuestAPI.Requirement.RequirementType.PROXIMITY || + type == QuestAPI.Requirement.RequirementType.DISTANCE; + } + + private static boolean isBonusCondition(QuestAPI.Requirement requirement) { + if (requirement == null) return false; + QuestAPI.Requirement.RequirementType type = requirement.getRequirementType(); + return type == QuestAPI.Requirement.RequirementType.COLLECT_BONUS_BOX || + type == QuestAPI.Requirement.RequirementType.COLLECT_BONUS_BOX_TYPE; + } + + private static boolean isCargoCondition(QuestAPI.Requirement requirement) { + if (requirement == null) return false; + QuestAPI.Requirement.RequirementType type = requirement.getRequirementType(); + return type == QuestAPI.Requirement.RequirementType.SALVAGE || + type == QuestAPI.Requirement.RequirementType.COLLECT_LOOT || + type == QuestAPI.Requirement.RequirementType.CARGO || + type == QuestAPI.Requirement.RequirementType.COLLECT; + } + + private static boolean isExecutableCondition(QuestAPI.Requirement requirement) { + return isNpcCondition(requirement) || isPlayerCondition(requirement) || isMapCondition(requirement) || + isCoordinateCondition(requirement) || isBonusCondition(requirement) || + isCargoCondition(requirement) || + (requirement != null && requirement.getRequirementType() == + QuestAPI.Requirement.RequirementType.SELL_ORE); + } + + private static List buildSteps(List requirements) { + List steps = new ArrayList<>(); + for (QuestAPI.Requirement requirement : requirements) { + StepType type = stepType(requirement); + if (type != null) steps.add(new Step(type, requirement.getDescription())); + } + return steps; + } + + private static StepType stepType(QuestAPI.Requirement requirement) { + if (isPlayerCondition(requirement)) return StepType.PLAYER_COMBAT; + if (isNpcCondition(requirement)) return StepType.NPC_COMBAT; + if (isMapCondition(requirement)) return StepType.MAP; + if (isCoordinateCondition(requirement)) return StepType.COORDINATES; + if (isBonusCondition(requirement)) return StepType.COLLECT_BONUS; + if (isCargoCondition(requirement)) return StepType.COLLECT_CARGO; + if (requirement != null && requirement.getRequirementType() == + QuestAPI.Requirement.RequirementType.SELL_ORE) return StepType.SELL_ORE; + return null; + } + + static Optional findCoordinates(String description) { + if (description == null || description.isBlank()) return Optional.empty(); + String normalized = description.toLowerCase(Locale.ROOT); + Matcher labeledX = X_COORDINATE.matcher(normalized); + Matcher labeledY = Y_COORDINATE.matcher(normalized); + if (labeledX.find() && labeledY.find()) { + return coordinates(labeledX.group(1), labeledY.group(1)); + } + Matcher paired = PAIRED_COORDINATES.matcher(normalized); + if (paired.find()) return coordinates(paired.group(1), paired.group(2)); + return Optional.empty(); + } + + private static Optional coordinates(String rawX, String rawY) { + try { + double x = Double.parseDouble(rawX); + double y = Double.parseDouble(rawY); + if (!Double.isFinite(x) || !Double.isFinite(y)) return Optional.empty(); + return Optional.of(Locatable.of(x, y)); + } catch (NumberFormatException ignored) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyQuestPolicy.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyQuestPolicy.java new file mode 100644 index 00000000..d5421648 --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyQuestPolicy.java @@ -0,0 +1,83 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.managers.QuestAPI; + +import java.util.Collection; + +/** Central acceptance and execution policy for daily quests. */ +final class DailyQuestPolicy { + enum Decision { + ACCEPT("accepted"), + WAIT_FOR_REWARDS("reward data is not ready"), + SKIP_NO_URIDIUM("no Uridium reward"), + SKIP_TETRATHRIN_ONLY("Tetrathrin-only reward"), + SKIP_PROTEGIT("Protegit objective is disabled"), + SKIP_PLAYER_COMBAT("player-combat objectives are disabled"), + SKIP_UNSUPPORTED("unsupported objective"); + + private final String message; + + Decision(String message) { + this.message = message; + } + + String message() { + return message; + } + + boolean accepted() { + return this == ACCEPT; + } + } + + private DailyQuestPolicy() { + } + + static Decision evaluateOffer(QuestAPI.Quest quest, DailyTaskConfig config) { + Decision objectiveDecision = evaluateObjectives(quest, config); + if (!objectiveDecision.accepted()) return objectiveDecision; + + Collection rewards = quest == null ? null : quest.getRewards(); + if (rewards == null || rewards.isEmpty()) return Decision.WAIT_FOR_REWARDS; + if (config.skipTetrathrinOnly && DailyTaskPlanner.isOnlyTetrathrinReward(rewards)) { + return Decision.SKIP_TETRATHRIN_ONLY; + } + if (config.requireUridiumReward && !DailyTaskPlanner.hasUridiumReward(rewards)) { + return Decision.SKIP_NO_URIDIUM; + } + return Decision.ACCEPT; + } + + static Decision evaluateActive(QuestAPI.Quest quest, DailyTaskConfig config) { + return evaluateObjectives(quest, config); + } + + static int priority(QuestAPI.Quest quest, DailyTaskConfig config) { + DailyQuestConditionEngine.Plan plan = DailyQuestConditionEngine.build(quest); + int score = plan.priorityScore(); + if (config.preferShorterQuests) { + score += (int) Math.min(10_000d, Math.round(DailyTaskPlanner.remainingWork(quest))); + } + return score; + } + + private static Decision evaluateObjectives(QuestAPI.Quest quest, DailyTaskConfig config) { + if (quest == null) return Decision.SKIP_UNSUPPORTED; + if (config.skipProtegitQuests && containsProtegit(quest)) return Decision.SKIP_PROTEGIT; + if (!config.acceptPlayerKillQuests && DailyQuestConditionEngine.hasPlayerCombat(quest)) { + return Decision.SKIP_PLAYER_COMBAT; + } + if (!DailyQuestConditionEngine.supports(quest, config.acceptPlayerKillQuests)) { + return Decision.SKIP_UNSUPPORTED; + } + return Decision.ACCEPT; + } + + private static boolean containsProtegit(QuestAPI.Quest quest) { + return DailyTaskPlanner.actionable(quest).stream() + .filter(DailyQuestConditionEngine::isNpcCondition) + .map(QuestAPI.Requirement::getDescription) + .map(DailyTaskPlanner::normalizeNpcName) + .anyMatch(description -> description.contains("protegit")); + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskAPI.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskAPI.java new file mode 100644 index 00000000..84d72403 --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskAPI.java @@ -0,0 +1,1433 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.PluginAPI; +import eu.darkbot.api.config.ConfigSetting; +import eu.darkbot.api.extensions.Configurable; +import eu.darkbot.api.extensions.Feature; +import eu.darkbot.api.extensions.Module; +import eu.darkbot.api.extensions.selectors.GearSelector; +import eu.darkbot.api.extensions.selectors.PetGearSupplier; +import eu.darkbot.api.extensions.selectors.PrioritizedSupplier; +import eu.darkbot.api.game.entities.Box; +import eu.darkbot.api.game.entities.Portal; +import eu.darkbot.api.game.entities.Station; +import eu.darkbot.api.game.enums.PetGear; +import eu.darkbot.api.game.other.EntityInfo; +import eu.darkbot.api.game.other.GameMap; +import eu.darkbot.api.game.other.Gui; +import eu.darkbot.api.game.other.Locatable; +import eu.darkbot.api.managers.AttackAPI; +import eu.darkbot.api.managers.BotAPI; +import eu.darkbot.api.managers.ConfigAPI; +import eu.darkbot.api.managers.EntitiesAPI; +import eu.darkbot.api.managers.GameScreenAPI; +import eu.darkbot.api.managers.HeroAPI; +import eu.darkbot.api.managers.MovementAPI; +import eu.darkbot.api.managers.OreAPI; +import eu.darkbot.api.managers.PetAPI; +import eu.darkbot.api.managers.QuestAPI; +import eu.darkbot.api.managers.StarSystemAPI; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collection; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Standalone daily-quest module. + * + *

This class deliberately does not extend LootCollectorModule and never + * reads or writes the user's working-map, NPC-selection, box-selection or + * ore-selling settings. Selecting another DarkBot module therefore leaves + * that module's saved settings untouched.

+ */ +@Feature(name = "DailyTaskAPI", description = "Completes only verified 24-hour daily quests") +public final class DailyTaskAPI implements Module, Configurable, + GearSelector, PetGearSupplier { + private static final String PARK_AFTER_DAILY = "__PARK_AFTER_DAILY__"; + private static final double QUEST_STATION_DISTANCE = 300d; + private static final int MAX_SOURCE_RECOVERY_ATTEMPTS = 3; + private static final int MAX_GIVER_OPEN_ATTEMPTS = 30; + + private enum State { + STARTING, DISCOVERING, WAITING_FOR_DISCOVERY, + SELECTING, WAITING_FOR_SELECTION, RUNNING, + OPENING_GIVER, WAITING_GIVER_OPEN, + SELECTING_DAILY_TAB, WAITING_DAILY_TAB, + SELECTING_GIVER_ROW, WAITING_GIVER_ROW, + ACCEPTING_GIVER_QUEST, WAITING_GIVER_ACCEPT, + CLOSING_GIVER, PAUSED, DONE + } + + private static final class QuestChoice { + private final int id; + private final String title; + private final QuestMenuSource.Selector selector; + private final int priority; + + private QuestChoice(int id, String title, QuestMenuSource.Selector selector, int priority) { + this.id = id; + this.title = title; + this.selector = selector; + this.priority = priority; + } + + private int id() { + return id; + } + + private String title() { + return title; + } + + private QuestMenuSource.Selector selector() { + return selector; + } + + private int priority() { + return priority; + } + } + + private final QuestAPI quests; + private final BotAPI bot; + private final GameScreenAPI gameScreen; + private final StarSystemAPI starSystem; + private final OreAPI ores; + private final EntitiesAPI entities; + private final MovementAPI movement; + private final AttackAPI attack; + private final HeroAPI hero; + private final PetAPI pet; + private final Gui questGui; + private final QuestMenuSource questMenuSource; + private final QuestGiverSource questGiverSource; + private final DailyNpcCombat npcCombat; + private final DailyNpcMapResolver npcMapResolver; + private final DailyPlayerCombat playerCombat; + + private DailyTaskConfig settings = new DailyTaskConfig(); + private State state = State.STARTING; + private int selectionRetries; + private int sourceRecoveryAttempts; + private int selectorIndex; + private int questIdBeforeSelection = -1; + private int currentQuestId = -1; + private String currentQuestTitle = ""; + private long nextActionAt; + private long nextRoamAt; + private String status = "Starting"; + private String targetNpcDescription; + private String targetPlayerDescription; + private String targetMapName; + private OreAPI.Ore oreToSell; + private boolean collectBonus; + private boolean collectCargo; + private Locatable targetCoordinates; + private double lastTrackedProgress = -1d; + private long lastProgressAt; + private final Set completedQuestIds = new HashSet<>(); + private final Set skippedActiveQuestIds = new HashSet<>(); + private final Set knownDailyQuestIds = new HashSet<>(); + private final Set scannedQuestIds = new LinkedHashSet<>(); + private final Map dailyChoices = new LinkedHashMap<>(); + private List selectors = List.of(); + private QuestChoice pendingChoice; + private boolean offerScanNeeded = true; + private int giverOpenAttempts; + private int giverScrollPage; + private boolean giverPageHadNewId; + private int giverRowIndex = 1; + private int giverRowRetries; + private int selectedOfferId = -1; + private String selectedOfferTitle = ""; + private int acceptRetries; + private final Set reviewedOfferIds = new LinkedHashSet<>(); + private final Set seenGiverOfferIds = new LinkedHashSet<>(); + private final Set acceptedOfferIds = new LinkedHashSet<>(); + private final Map skippedOfferCounts = + new EnumMap<>(DailyQuestPolicy.Decision.class); + private boolean completionStopIssued; + + public DailyTaskAPI(PluginAPI api) { + this.quests = api.requireAPI(QuestAPI.class); + this.bot = api.requireAPI(BotAPI.class); + this.gameScreen = api.requireAPI(GameScreenAPI.class); + this.starSystem = api.requireAPI(StarSystemAPI.class); + this.ores = api.requireAPI(OreAPI.class); + this.entities = api.requireAPI(EntitiesAPI.class); + this.movement = api.requireAPI(MovementAPI.class); + this.attack = api.requireAPI(AttackAPI.class); + this.hero = api.requireAPI(HeroAPI.class); + this.pet = api.requireAPI(PetAPI.class); + this.questGui = gameScreen.getGui("quests"); + this.questMenuSource = new QuestMenuSource(questGui); + this.questGiverSource = new QuestGiverSource(gameScreen); + this.npcCombat = new DailyNpcCombat(api); + ConfigAPI config = api.requireAPI(ConfigAPI.class); + this.npcMapResolver = new DailyNpcMapResolver( + config.requireConfig("loot.npc_infos"), starSystem); + this.playerCombat = new DailyPlayerCombat(api); + } + + @Override + public void setConfig(ConfigSetting setting) { + DailyTaskConfig configured = setting.getValue(); + if (configured != null) settings = configured; + setting.addListener(updated -> { + if (updated != null) settings = updated; + }); + } + + @Override + public void onTickModule() { + long now = System.currentTimeMillis(); + if (handleCompletedState(now)) return; + initializeSession(now); + if (tickStateMachine(now)) return; + runDisplayedQuest(now); + } + + private boolean handleCompletedState(long now) { + if (state != State.DONE) return false; + if (completionStopIssued && bot.isRunning()) { + state = State.STARTING; + completionStopIssued = false; + return false; + } + if (nextActionAt <= now) { + state = State.STARTING; + return false; + } + npcCombat.shutdownPet(); + if (npcCombat.isPetActive() || now < nextRoamAt) { + status = completionSummary() + " | PET is shutting down"; + return true; + } + status = completionSummary() + " | PET off | Bot stopped"; + if (bot.isRunning()) { + completionStopIssued = true; + bot.setRunning(false); + } + return true; + } + + private void initializeSession(long now) { + if (state != State.STARTING) return; + resetSession(); + if (quests.isQuestGiverOpen()) { + questGiverSource.close(); + nextActionAt = now + settings.questSwitchDelayMs; + } + state = State.DISCOVERING; + if (nextActionAt < now) nextActionAt = now; + } + + private boolean tickStateMachine(long now) { + if (isGiverState(state)) { + if (ensureQuestStation() && now >= nextActionAt) tickQuestGiver(); + return true; + } + Runnable action = stateAction(); + if (action == null) return state == State.PAUSED; + if (now >= nextActionAt) action.run(); + return true; + } + + private Runnable stateAction() { + if (state == State.DISCOVERING) return this::discoverOrScanNext; + if (state == State.WAITING_FOR_DISCOVERY) return this::inspectDiscoveredSelector; + if (state == State.SELECTING) return this::selectPendingDaily; + if (state == State.WAITING_FOR_SELECTION) return this::inspectPendingSelection; + return null; + } + + private void runDisplayedQuest(long now) { + QuestAPI.Quest quest = quests.getDisplayedQuest(); + if (quest == null) { + waitForQuestSnapshot(); + return; + } + if (handleQuestChange(quest, now) || rejectNonDailyQuest(quest, now) || + rejectDisallowedQuest(quest, now)) return; + double progress = DailyTaskPlanner.progress(quest); + if (quest.isCompleted() || progress >= 0.9999d) { + completeQuest(quest, now); + return; + } + buildPlanFromSource(quest); + if (targetPlayerDescription != null) playerCombat.observeHeroState(); + if (recoverStagnantPlan(quest, progress, now)) return; + boolean standardNpcCombatOwnsSafety = targetNpcDescription != null && + (targetMapName == null || isOnMap(targetMapName)); + if (!standardNpcCombatOwnsSafety && runLocalSafety()) return; + executePlan(quest, progress); + } + + private void waitForQuestSnapshot() { + attack.stopAttack(); + movement.stop(false); + hero.setRoamMode(); + status = "Waiting for refreshed quest data; current plan retained"; + } + + private boolean handleQuestChange(QuestAPI.Quest quest, long now) { + if (currentQuestId < 0 || quest.getId() == currentQuestId) return false; + scheduleQuestMenuScan(now, false); + status = "Displayed quest changed before completion; safely rescanning the native quest menu"; + return true; + } + + private boolean rejectNonDailyQuest(QuestAPI.Quest quest, long now) { + if (isVerifiedDaily(quest)) return false; + scheduleQuestMenuScan(now, false); + status = "Displayed quest is not daily; rescanning the native quest menu"; + return true; + } + + private void completeQuest(QuestAPI.Quest quest, long now) { + completedQuestIds.add(quest.getId()); + status = "Completed: " + safeTitle(quest); + scheduleQuestMenuScan(now, true); + } + + private void scheduleQuestMenuScan(long now, boolean parkFirst) { + stopCurrentAction(); + currentQuestId = -1; + currentQuestTitle = parkFirst ? PARK_AFTER_DAILY : ""; + prepareNewScan(); + nextActionAt = now + settings.questSwitchDelayMs; + } + + private void resetSession() { + selectionRetries = 0; + sourceRecoveryAttempts = 0; + selectorIndex = 0; + questIdBeforeSelection = -1; + currentQuestId = -1; + currentQuestTitle = ""; + pendingChoice = null; + completedQuestIds.clear(); + skippedActiveQuestIds.clear(); + knownDailyQuestIds.clear(); + scannedQuestIds.clear(); + dailyChoices.clear(); + selectors = List.of(); + offerScanNeeded = true; + giverOpenAttempts = 0; + giverScrollPage = 0; + giverPageHadNewId = false; + giverRowIndex = 1; + giverRowRetries = 0; + selectedOfferId = -1; + selectedOfferTitle = ""; + acceptRetries = 0; + reviewedOfferIds.clear(); + seenGiverOfferIds.clear(); + acceptedOfferIds.clear(); + skippedOfferCounts.clear(); + completionStopIssued = false; + resetProgressTracking(); + clearPlan(); + status = "Scanning the native quest menu"; + } + + private void discoverOrScanNext() { + if (parkAfterCompletedQuest()) return; + if (!ensureQuestMenuVisible()) return; + if (!ensureQuestSelectors()) return; + scanNextQuestSelector(); + } + + private boolean parkAfterCompletedQuest() { + if (!PARK_AFTER_DAILY.equals(currentQuestTitle)) return false; + Optional portal = nearestPortal(); + if (portal.isEmpty()) { + attack.stopAttack(); + status = "Waiting for the nearest portal after quest completion"; + nextActionAt = System.currentTimeMillis() + 1_000L; + return true; + } + double distance = hero.distanceTo(portal.get()); + if (distance > 300d) { + moveToCompletionPortal(portal.get(), distance); + return true; + } + movement.stop(false); + hero.setRoamMode(); + currentQuestTitle = ""; + status = "Quest completed; parked safely at the nearest portal"; + return false; + } + + private Optional nearestPortal() { + return entities.getPortals().stream() + .filter(Portal::isValid) + .min(Comparator.comparingDouble(hero::distanceTo)); + } + + private void moveToCompletionPortal(Portal portal, double distance) { + attack.stopAttack(); + hero.setRunMode(); + movement.moveTo(portal); + status = "Quest completed; moving to the nearest portal: " + (int) Math.round(distance); + nextActionAt = System.currentTimeMillis() + 1_000L; + } + + private boolean ensureQuestMenuVisible() { + if (questGui == null) { + pauseSafely("DarkBot quest window is unavailable"); + return false; + } + if (questGui.isVisible()) return true; + if (!settings.autoOpenQuestWindow) { + status = "Open the quest window"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + return false; + } + questGui.setVisible(true); + status = "Opening the quest window"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + return false; + } + + private boolean ensureQuestSelectors() { + if (!selectors.isEmpty()) return true; + refreshDailyCatalog(); + selectors = questMenuSource.discoverSelectors(); + if (selectors.isEmpty()) { + retryMenuScan(questMenuSource.getLastError()); + return false; + } + sourceRecoveryAttempts = 0; + selectorIndex = 0; + selectionRetries = 0; + scannedQuestIds.clear(); + dailyChoices.clear(); + QuestAPI.Quest displayed = quests.getDisplayedQuest(); + recordQuest(displayed, null); + if (!isRunnableDaily(displayed)) return true; + startDaily(displayed); + return false; + } + + private boolean isRunnableDaily(QuestAPI.Quest quest) { + return quest != null && dailyChoices.containsKey(quest.getId()) && !isFinished(quest); + } + + private void scanNextQuestSelector() { + if (selectorIndex >= selectors.size()) { + finishMenuScan(); + return; + } + QuestMenuSource.Selector selector = selectors.get(selectorIndex); + QuestAPI.Quest before = quests.getDisplayedQuest(); + questIdBeforeSelection = before == null ? -1 : before.getId(); + questGui.click(selector.x(), selector.y()); + state = State.WAITING_FOR_DISCOVERY; + status = "Scanning quest menu: " + (selectorIndex + 1) + "/" + selectors.size(); + nextActionAt = System.currentTimeMillis() + 500L; + } + + private void inspectDiscoveredSelector() { + QuestAPI.Quest quest = quests.getDisplayedQuest(); + if (quest == null) { + retryDiscoveredSelector("Waiting for quest data from the server"); + return; + } + + QuestMenuSource.Selector selector = selectors.get(selectorIndex); + if (quest.getId() == questIdBeforeSelection) { + selectorIndex++; + selectionRetries = 0; + state = State.DISCOVERING; + status = "Quest row did not respond; continuing the single scan"; + nextActionAt = System.currentTimeMillis() + 250L; + return; + } + + recordQuest(quest, selector); + if (dailyChoices.containsKey(quest.getId()) && !isFinished(quest)) { + startDaily(quest); + return; + } + selectorIndex++; + selectionRetries = 0; + state = State.DISCOVERING; + nextActionAt = System.currentTimeMillis() + 250L; + } + + private void finishMenuScan() { + QuestAPI.Quest displayed = quests.getDisplayedQuest(); + recordQuest(displayed, selectorIndex > 0 && selectorIndex <= selectors.size() + ? selectors.get(selectorIndex - 1) : null); + + QuestChoice current = displayed == null ? null : dailyChoices.get(displayed.getId()); + if (current != null && !isFinished(displayed)) { + startDaily(displayed); + return; + } + + pendingChoice = dailyChoices.values().stream() + .filter(choice -> !completedQuestIds.contains(choice.id())) + .filter(choice -> choice.selector() != null) + .min(Comparator.comparingInt(QuestChoice::priority)) + .orElse(null); + if (pendingChoice != null) { + selectionRetries = 0; + state = State.SELECTING; + nextActionAt = System.currentTimeMillis() + 250L; + status = scanSummary() + " | Selecting daily: " + pendingChoice.title(); + return; + } + + if (offerScanNeeded) { + beginQuestOfferScan(); + return; + } + finishAll(); + } + + private boolean isGiverState(State value) { + return value == State.OPENING_GIVER || value == State.WAITING_GIVER_OPEN || + value == State.SELECTING_DAILY_TAB || value == State.WAITING_DAILY_TAB || + value == State.SELECTING_GIVER_ROW || value == State.WAITING_GIVER_ROW || + value == State.ACCEPTING_GIVER_QUEST || value == State.WAITING_GIVER_ACCEPT || + value == State.CLOSING_GIVER; + } + + private void beginQuestOfferScan() { + stopCurrentAction(); + if (questGui != null && questGui.isVisible()) questGui.setVisible(false); + // This set represents offers accepted by the current station scan. + // Keeping ids from an earlier scan made CLOSING_GIVER believe that new + // quests had been accepted forever, causing an active-menu scan loop. + acceptedOfferIds.clear(); + giverOpenAttempts = 0; + giverScrollPage = 0; + giverPageHadNewId = false; + giverRowIndex = 1; + giverRowRetries = 0; + selectedOfferId = -1; + selectedOfferTitle = ""; + acceptRetries = 0; + seenGiverOfferIds.clear(); + state = State.OPENING_GIVER; + nextActionAt = System.currentTimeMillis() + 500L; + status = "Scanning station daily offers from source data"; + } + + private void tickQuestGiver() { + switch (state) { + case CLOSING_GIVER: + tickClosingGiver(); + break; + case OPENING_GIVER: + case WAITING_GIVER_OPEN: + tickOpeningGiver(); + break; + default: + tickOpenGiverState(); + break; + } + } + + private void tickClosingGiver() { + if (quests.isQuestGiverOpen()) { + questGiverSource.close(); + status = "Closing quest giver"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + return; + } + if (acceptedOfferIds.isEmpty()) { + finishAll(); + return; + } + prepareNewScan(); + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + status = "Verifying accepted daily quests in the active quest menu"; + } + + private void tickOpeningGiver() { + if (quests.isQuestGiverOpen()) { + giverOpenAttempts = 0; + state = State.SELECTING_DAILY_TAB; + nextActionAt = System.currentTimeMillis() + 300L; + status = "Opening daily quest tab"; + return; + } + findQuestStation().filter(station -> hero.distanceTo(station) <= QUEST_STATION_DISTANCE) + .ifPresent(station -> station.trySelect(false)); + giverOpenAttempts++; + if (giverOpenAttempts >= MAX_GIVER_OPEN_ATTEMPTS) { + pauseSafely("Quest giver did not open after " + giverOpenAttempts + " attempts"); + return; + } + state = State.WAITING_GIVER_OPEN; + status = "Waiting for quest giver response (" + giverOpenAttempts + ")"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + } + + private void tickOpenGiverState() { + if (!quests.isQuestGiverOpen()) { + state = State.OPENING_GIVER; + nextActionAt = System.currentTimeMillis() + 500L; + status = "Quest giver closed; reopening"; + return; + } + switch (state) { + case SELECTING_DAILY_TAB: + selectDailyTab(); + break; + case WAITING_DAILY_TAB: + beginGiverRows(); + break; + case SELECTING_GIVER_ROW: + selectGiverRow(); + break; + case WAITING_GIVER_ROW: + inspectGiverRow(); + break; + case ACCEPTING_GIVER_QUEST: + acceptSelectedOffer(); + break; + case WAITING_GIVER_ACCEPT: + verifyAcceptedOffer(); + break; + default: + break; + } + } + + private void selectDailyTab() { + questGiverSource.selectDailyTab(); + state = State.WAITING_DAILY_TAB; + status = "Waiting for daily tab source data"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + } + + private void beginGiverRows() { + giverRowIndex = 1; + giverRowRetries = 0; + state = State.SELECTING_GIVER_ROW; + nextActionAt = System.currentTimeMillis() + 250L; + } + + private void selectGiverRow() { + if (giverRowIndex > QuestGiverSource.MAX_ROWS) { + advanceGiverPage(); + return; + } + questGiverSource.selectRow(giverRowIndex); + state = State.WAITING_GIVER_ROW; + status = "Reading daily offer row " + giverRowIndex; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + } + + private void advanceGiverPage() { + boolean exhausted = giverScrollPage > 0 && !giverPageHadNewId; + if (exhausted || giverScrollPage >= QuestGiverSource.MAX_SCROLL_PAGES) { + finishQuestOfferScan(); + return; + } + giverScrollPage++; + giverPageHadNewId = false; + giverRowIndex = 1; + giverRowRetries = 0; + questGiverSource.scrollDailyListDown(); + state = State.WAITING_DAILY_TAB; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + } + + private void inspectGiverRow() { + QuestAPI.QuestListItem info = quests.getSelectedQuestInfo(); + QuestAPI.Quest selected = quests.getSelectedQuest(); + boolean matchingSource = info != null && selected != null && info.getId() == selected.getId(); + if (!matchingSource) { + retryOrAdvanceGiverRow("Waiting for offer details from the server"); + return; + } + if (seenGiverOfferIds.add(info.getId())) giverPageHadNewId = true; + boolean dailyOffer = DailyTaskPlanner.isDailyType(info.getType()) || DailyTaskPlanner.isDaily(selected); + if (!dailyOffer) { + advanceGiverRow(); + return; + } + + int id = info.getId(); + if (reviewedOfferIds.contains(id) || info.isCompleted() || !info.isActivable()) { + reviewedOfferIds.add(id); + advanceGiverRow(); + return; + } + DailyQuestPolicy.Decision decision = DailyQuestPolicy.evaluateOffer(selected, settings); + if (decision == DailyQuestPolicy.Decision.WAIT_FOR_REWARDS) { + retryOrAdvanceGiverRow("Waiting for offer rewards from the source"); + return; + } + if (!decision.accepted()) { + reviewedOfferIds.add(id); + skippedOfferCounts.merge(decision, 1, Integer::sum); + status = "Skipped daily quest: " + decision.message() + " | " + safeTitle(selected); + advanceGiverRow(); + return; + } + + selectedOfferId = id; + selectedOfferTitle = safeTitle(selected); + acceptRetries = 0; + state = State.ACCEPTING_GIVER_QUEST; + nextActionAt = System.currentTimeMillis() + 250L; + status = "Source data verified; accepting daily: " + selectedOfferTitle; + } + + private void retryOrAdvanceGiverRow(String reason) { + giverRowRetries++; + if (giverRowRetries < Math.max(2, settings.maxSelectionRetries)) { + state = State.SELECTING_GIVER_ROW; + status = reason + " (" + giverRowRetries + ")"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + return; + } + advanceGiverRow(); + } + + private void advanceGiverRow() { + giverRowIndex++; + giverRowRetries = 0; + state = State.SELECTING_GIVER_ROW; + nextActionAt = System.currentTimeMillis() + 300L; + } + + private void acceptSelectedOffer() { + QuestAPI.QuestListItem info = quests.getSelectedQuestInfo(); + QuestAPI.Quest selected = quests.getSelectedQuest(); + if (!isSafeOfferSelection(info, selected)) { + skipSelectedOffer(info); + return; + } + questGiverSource.acceptSelected(); + state = State.WAITING_GIVER_ACCEPT; + status = "Waiting for server confirmation: " + selectedOfferTitle; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + } + + private boolean isSafeOfferSelection(QuestAPI.QuestListItem info, QuestAPI.Quest selected) { + if (info == null || selected == null) return false; + if (info.getId() != selectedOfferId || selected.getId() != selectedOfferId) return false; + if (!DailyTaskPlanner.isDailyType(info.getType()) && !DailyTaskPlanner.isDaily(selected)) return false; + if (!info.isActivable() || info.isCompleted()) return false; + return DailyQuestPolicy.evaluateOffer(selected, settings).accepted(); + } + + private void skipSelectedOffer(QuestAPI.QuestListItem info) { + int skippedId = selectedOfferId; + String skippedTitle = selectedOfferTitle; + if (skippedId > 0) reviewedOfferIds.add(skippedId); + selectedOfferId = -1; + selectedOfferTitle = ""; + if (info != null && info.getId() == skippedId && !info.isActivable()) { + status = "Offer is no longer activable; skipped: " + skippedTitle; + } else { + status = "Offer source changed during verification; skipped safely: " + skippedTitle; + } + advanceGiverRow(); + } + + private void verifyAcceptedOffer() { + QuestAPI.QuestListItem catalog = findQuestItem(selectedOfferId).orElse(null); + boolean accepted = catalog != null && !catalog.isActivable(); + if (accepted) { + acceptedOfferIds.add(selectedOfferId); + reviewedOfferIds.add(selectedOfferId); + status = "Daily quest accepted: " + selectedOfferTitle; + selectedOfferId = -1; + selectedOfferTitle = ""; + giverRowIndex = 1; + giverRowRetries = 0; + giverPageHadNewId = false; + state = quests.isQuestGiverOpen() ? State.SELECTING_GIVER_ROW : State.OPENING_GIVER; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + return; + } + if (acceptRetries++ < Math.max(2, settings.maxSelectionRetries)) { + state = State.ACCEPTING_GIVER_QUEST; + status = "Daily acceptance response delayed; reverifying source"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + return; + } + reviewedOfferIds.add(selectedOfferId); + status = "Could not verify daily acceptance; continuing safely: " + selectedOfferTitle; + selectedOfferId = -1; + selectedOfferTitle = ""; + advanceGiverRow(); + } + + private Optional findQuestItem(int id) { + try { + return quests.getCurrestQuests().stream() + .filter(item -> item != null && item.getId() == id) + .findFirst() + .map(item -> (QuestAPI.QuestListItem) item); + } catch (RuntimeException ignored) { + return Optional.empty(); + } + } + + private void finishQuestOfferScan() { + offerScanNeeded = false; + state = State.CLOSING_GIVER; + status = "Station scan completed | Accepted: " + acceptedOfferIds.size() + + " | Skipped by policy: " + skippedOfferCounts.values().stream() + .mapToInt(Integer::intValue).sum(); + nextActionAt = System.currentTimeMillis() + 300L; + } + + private void selectPendingDaily() { + if (pendingChoice == null || pendingChoice.selector() == null) { + prepareNewScan(); + return; + } + QuestAPI.Quest displayed = quests.getDisplayedQuest(); + if (displayed != null && displayed.getId() == pendingChoice.id() && isVerifiedDaily(displayed)) { + startDaily(displayed); + return; + } + questIdBeforeSelection = displayed == null ? -1 : displayed.getId(); + questGui.click(pendingChoice.selector().x(), pendingChoice.selector().y()); + state = State.WAITING_FOR_SELECTION; + status = "Selecting daily quest from source: " + pendingChoice.title(); + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + } + + private void inspectPendingSelection() { + QuestAPI.Quest quest = quests.getDisplayedQuest(); + if (quest != null && pendingChoice != null && quest.getId() == pendingChoice.id() && isVerifiedDaily(quest)) { + startDaily(quest); + return; + } + + selectionRetries++; + if (selectionRetries >= Math.max(1, settings.maxSelectionRetries)) { + prepareNewScan(); + status = "Daily selection not verified; rescanning quest menu"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + return; + } + state = State.SELECTING; + status = "Waiting for daily quest selection (" + selectionRetries + "/" + + settings.maxSelectionRetries + ")"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + } + + private void startDaily(QuestAPI.Quest quest) { + currentQuestId = quest.getId(); + currentQuestTitle = safeTitle(quest); + selectionRetries = 0; + resetProgressTracking(); + playerCombat.startQuest(); + state = State.RUNNING; + buildPlanFromSource(quest); + status = "Daily quest verified: " + currentQuestTitle; + } + + private void retryDiscoveredSelector(String reason) { + selectionRetries++; + if (selectionRetries >= Math.max(1, settings.maxSelectionRetries)) { + selectorIndex++; + selectionRetries = 0; + state = State.DISCOVERING; + status = reason + "; trying the next source selector"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + return; + } + state = State.DISCOVERING; + status = reason + " (" + selectionRetries + "/" + settings.maxSelectionRetries + ")"; + nextActionAt = System.currentTimeMillis() + settings.questSwitchDelayMs; + } + + private void retryMenuScan(String reason) { + sourceRecoveryAttempts++; + if (sourceRecoveryAttempts < MAX_SOURCE_RECOVERY_ATTEMPTS) { + selectors = List.of(); + status = reason + "; retrying source discovery (" + sourceRecoveryAttempts + "/" + + MAX_SOURCE_RECOVERY_ATTEMPTS + ")"; + nextActionAt = System.currentTimeMillis() + Math.max(750L, settings.questSwitchDelayMs); + return; + } + status = reason + "; source discovery unavailable after safe retries"; + if (offerScanNeeded) beginQuestOfferScan(); + else finishAll(); + } + + private void prepareNewScan() { + state = State.DISCOVERING; + sourceRecoveryAttempts = 0; + selectorIndex = 0; + selectionRetries = 0; + questIdBeforeSelection = -1; + pendingChoice = null; + selectors = List.of(); + scannedQuestIds.clear(); + dailyChoices.clear(); + skippedActiveQuestIds.clear(); + } + + private void refreshDailyCatalog() { + try { + for (QuestAPI.QuestListItem item : quests.getCurrestQuests()) { + if (item != null && DailyTaskPlanner.isDailyType(item.getType())) { + knownDailyQuestIds.add(item.getId()); + } + } + } catch (RuntimeException ignored) { + // The quest-giver catalog can be unavailable when its window has + // never been opened. The 24-hour requirement remains authoritative. + } + } + + private void recordQuest(QuestAPI.Quest quest, QuestMenuSource.Selector selector) { + if (quest == null || quest.getId() <= 0) return; + scannedQuestIds.add(quest.getId()); + if (!isVerifiedDaily(quest)) return; + if (isFinished(quest)) { + completedQuestIds.add(quest.getId()); + skippedActiveQuestIds.remove(quest.getId()); + return; + } + if (!DailyQuestPolicy.evaluateActive(quest, settings).accepted()) { + skippedActiveQuestIds.add(quest.getId()); + return; + } + skippedActiveQuestIds.remove(quest.getId()); + QuestChoice choice = new QuestChoice(quest.getId(), safeTitle(quest), selector, + DailyQuestPolicy.priority(quest, settings)); + QuestChoice existing = dailyChoices.get(quest.getId()); + if (existing == null || (existing.selector() == null && selector != null)) { + dailyChoices.put(quest.getId(), choice); + } + } + + private boolean isVerifiedDaily(QuestAPI.Quest quest) { + return quest != null && (acceptedOfferIds.contains(quest.getId()) || + knownDailyQuestIds.contains(quest.getId()) || + DailyTaskPlanner.isDaily(quest)); + } + + private boolean isFinished(QuestAPI.Quest quest) { + return quest == null || quest.isCompleted() || DailyTaskPlanner.progress(quest) >= 0.9999d; + } + + private String scanSummary() { + return "Menu: " + scannedQuestIds.size() + " quests, " + dailyChoices.size() + " daily"; + } + + private void buildPlanFromSource(QuestAPI.Quest quest) { + clearPlan(); + DailyQuestConditionEngine.Plan plan = DailyQuestConditionEngine.build( + quest, description -> npcMapResolver.resolve(description, companyPrefix())); + targetMapName = plan.targetMapName(); + targetNpcDescription = plan.targetNpcDescription(); + targetPlayerDescription = plan.targetPlayerDescription(); + oreToSell = plan.oreToSell(); + collectBonus = plan.collectBonus(); + collectCargo = plan.collectCargo(); + targetCoordinates = plan.targetCoordinates(); + + if (targetPlayerDescription != null && targetMapName == null && + settings.playerCombatMap != null && !settings.playerCombatMap.isBlank()) { + targetMapName = DailyTaskPlanner.findMap(settings.playerCombatMap).orElse(null); + } + + if (oreToSell != null && !ores.canSellOres() && targetMapName == null) { + targetMapName = homeBaseMapName(); + } + } + + private void executePlan(QuestAPI.Quest quest, double progress) { + if (travelToTargetMap(quest, progress)) return; + if (handleTargetCoordinates(quest, progress)) return; + if (handleOreSale(quest, progress)) return; + if (collectRequiredBox(quest, progress)) return; + if (huntRequiredNpc(quest, progress)) return; + if (fightRequiredPlayer(quest, progress)) return; + if (searchForRequiredBox(quest, progress)) return; + if (reportMapArrival(quest, progress)) return; + pauseSafely("Unsupported daily objective: " + firstRequirement(quest)); + } + + private boolean travelToTargetMap(QuestAPI.Quest quest, double progress) { + if (targetMapName == null || isOnMap(targetMapName)) return false; + navigateTo(targetMapName); + updateProgressStatus(quest, progress, "Travelling to map " + targetMapName); + return true; + } + + private boolean handleTargetCoordinates(QuestAPI.Quest quest, double progress) { + if (targetCoordinates == null) return false; + if (movement.getClosestDistance(targetCoordinates) > 120d) { + npcCombat.stopCombat(); + playerCombat.stopCombat(); + attack.stopAttack(); + hero.setRunMode(); + movement.moveTo(targetCoordinates); + updateProgressStatus(quest, progress, "Travelling to coordinates " + + targetCoordinates.x() + "/" + targetCoordinates.y()); + return true; + } + if (!isCoordinateOnlyPlan()) return false; + movement.stop(false); + hero.setRoamMode(); + updateProgressStatus(quest, progress, "Reached target coordinates"); + return true; + } + + private boolean isCoordinateOnlyPlan() { + return targetNpcDescription == null && targetPlayerDescription == null && oreToSell == null && + !collectBonus && !collectCargo; + } + + private boolean handleOreSale(QuestAPI.Quest quest, double progress) { + if (oreToSell == null) return false; + if (ores.canSellOres() && ores.getAmount(oreToSell) > 0) { + ores.sellOre(oreToSell); + updateProgressStatus(quest, progress, "Selling ore: " + oreToSell.getName()); + return true; + } + if (!ores.canSellOres()) { + String base = homeBaseMapName(); + if (base == null) { + updateProgressStatus(quest, progress, "Waiting for company data before travelling to base"); + return true; + } + if (!isOnMap(base)) navigateTo(base); + updateProgressStatus(quest, progress, "Travelling to base to sell ore"); + return true; + } + if (targetNpcDescription == null) { + targetNpcDescription = "Streuner"; + collectCargo = true; + } + return false; + } + + private boolean collectRequiredBox(QuestAPI.Quest quest, double progress) { + if ((!collectBonus && !collectCargo) || !tryCollectRequiredBox()) return false; + String action = collectBonus ? "Collecting bonus box" : "Collecting NPC cargo"; + updateProgressStatus(quest, progress, action); + return true; + } + + private boolean huntRequiredNpc(QuestAPI.Quest quest, double progress) { + if (targetNpcDescription == null) return false; + npcCombat.tick(targetNpcDescription, settings.attackRadius); + if (attack.getTarget() == null && !movement.isMoving()) movement.moveRandom(); + updateProgressStatus(quest, progress, "NPC Kill and Collect: " + npcCombat.getStatus() + + " | Target: " + cleanedRequirement(targetNpcDescription)); + return true; + } + + private boolean rejectDisallowedQuest(QuestAPI.Quest quest, long now) { + DailyQuestPolicy.Decision decision = DailyQuestPolicy.evaluateActive(quest, settings); + if (decision.accepted()) return false; + scheduleQuestMenuScan(now, false); + status = "Daily quest skipped by policy: " + decision.message(); + return true; + } + + private boolean fightRequiredPlayer(QuestAPI.Quest quest, double progress) { + if (targetPlayerDescription == null) return false; + if (playerCombat.exceededSafetyLimits(settings)) { + pauseSafely("Player combat stopped: " + playerCombat.failureReason(settings)); + return true; + } + playerCombat.tick(targetPlayerDescription, settings); + updateProgressStatus(quest, progress, "Player combat: " + playerCombat.getStatus()); + return true; + } + + private boolean searchForRequiredBox(QuestAPI.Quest quest, double progress) { + if (!collectBonus && !collectCargo) return false; + roamIfNeeded(); + updateProgressStatus(quest, progress, "Searching for required box"); + return true; + } + + private boolean reportMapArrival(QuestAPI.Quest quest, double progress) { + if (targetMapName == null || !isOnMap(targetMapName)) return false; + updateProgressStatus(quest, progress, "Reached target map"); + return true; + } + + private boolean runLocalSafety() { + double hp = hero.getHealth().hpPercent(); + boolean lowShieldInPlayerCombat = targetPlayerDescription != null && + hero.getHealth().shieldPercent() < settings.playerMinimumShieldPercent; + if (hp >= settings.minimumHpPercent && !lowShieldInPlayerCombat) return false; + attack.stopAttack(); + hero.setRunMode(); + entities.getPortals().stream() + .filter(Portal::isValid) + .min(Comparator.comparingDouble(hero::distanceTo)) + .ifPresent(portal -> movement.moveTo(portal)); + status = "Local safety: HP " + (int) Math.round(hp * 100d) + "% / shield " + + (int) Math.round(hero.getHealth().shieldPercent() * 100d) + "% ; waiting near portal"; + return true; + } + + private boolean ensureQuestStation() { + String questBaseMap = questBaseMapName(); + if (questBaseMap == null) { + attack.stopAttack(); + status = "Waiting for company data before locating the quest center"; + return false; + } + if (!isOnMap(questBaseMap)) { + if (!selectors.isEmpty() || state != State.DISCOVERING) prepareNewScan(); + if (questGui != null && questGui.isVisible()) questGui.setVisible(false); + navigateToQuestBase(questBaseMap); + return false; + } + + Optional station = findQuestStation(); + if (station.isEmpty()) { + attack.stopAttack(); + hero.setRoamMode(); + status = "Waiting for main-station data on " + questBaseMap; + return false; + } + + double distance = hero.distanceTo(station.get()); + if (distance > QUEST_STATION_DISTANCE) { + attack.stopAttack(); + hero.setRoamMode(); + movement.moveTo(station.get()); + status = "Approaching main quest station on " + questBaseMap + ": " + + (int) Math.round(distance); + return false; + } + + if (movement.isMoving()) movement.stop(false); + return true; + } + + private Optional findQuestStation() { + Optional questGiver = entities.getStations().stream() + .filter(Station::isValid) + .filter(station -> station instanceof Station.QuestGiver) + .min(Comparator.comparingDouble(hero::distanceTo)); + if (questGiver.isPresent()) return questGiver; + + Optional homeBase = entities.getStations().stream() + .filter(Station::isValid) + .filter(station -> station instanceof Station.HomeBase) + .min(Comparator.comparingDouble(hero::distanceTo)); + if (homeBase.isPresent()) return homeBase; + + return entities.getStations().stream() + .filter(Station::isValid) + .min(Comparator.comparingDouble(hero::distanceTo)); + } + + private void navigateToQuestBase(String questBaseMap) { + Optional destination = starSystem.findMap(questBaseMap); + if (destination.isEmpty() || !starSystem.isAccessible(destination.get())) { + attack.stopAttack(); + status = "Waiting for quest-center map data: " + questBaseMap; + return; + } + + Portal portal = starSystem.findNext(destination.get()); + if (portal == null || !portal.isValid()) { + // Map changes briefly clear the portal collection. This is a + // normal loading state, so wait for the next tick instead of + // entering a permanent safe pause. + attack.stopAttack(); + status = "Loading quest-center route: " + questBaseMap; + return; + } + + attack.stopAttack(); + hero.setRoamMode(); + if (hero.distanceTo(portal) > 220d) movement.moveTo(portal); + else movement.jumpPortal(portal); + status = "Travelling to " + questBaseMap + " for the quest center"; + } + + private void navigateTo(String mapName) { + if (mapName == null || mapName.isBlank()) { + status = "Waiting for destination map data"; + return; + } + Optional destination = starSystem.findMap(mapName); + if (destination.isEmpty() || !starSystem.isAccessible(destination.get())) { + // Map/route data can be temporarily unavailable while a portal jump is + // loading. Keep the daily plan intact and retry on the next tick. + attack.stopAttack(); + status = "Loading map route: " + mapName; + return; + } + Portal portal = starSystem.findNext(destination.get()); + if (portal == null || !portal.isValid()) { + // The portal collection is briefly empty during map transitions. This + // is not a permanent navigation failure and must not clear the plan. + attack.stopAttack(); + status = "Sonraki portal bekleniyor: " + mapName; + return; + } + attack.stopAttack(); + hero.setRoamMode(); + if (hero.distanceTo(portal) > 220d) movement.moveTo(portal); + else movement.jumpPortal(portal); + } + + private boolean tryCollectRequiredBox() { + Optional nearest = entities.getBoxes().stream() + .filter(Box::isValid) + .filter(box -> !box.isCollected()) + .filter(this::matchesRequiredBox) + .min(Comparator.comparingDouble(hero::distanceTo)); + if (nearest.isEmpty()) return false; + Box box = nearest.get(); + attack.stopAttack(); + hero.setRoamMode(); + if (hero.distanceTo(box) > 180d) movement.moveTo(box); + else box.tryCollect(); + return true; + } + + private boolean matchesRequiredBox(Box box) { + String type = ((box.getTypeName() == null ? "" : box.getTypeName()) + " " + + (box.getHash() == null ? "" : box.getHash())).toUpperCase(Locale.ROOT); + if (collectBonus && type.contains("BONUS")) return true; + return collectCargo && (type.contains("FROM_SHIP") || type.contains("CARGO")); + } + + private void roamIfNeeded() { + long now = System.currentTimeMillis(); + if (movement.isMoving() && now < nextRoamAt) return; + movement.moveRandom(); + nextRoamAt = now + 2500L; + } + + private boolean isOnMap(String mapName) { + GameMap current = starSystem.getCurrentMap(); + return current != null && current.getName() != null && + current.getName().equalsIgnoreCase(mapName); + } + + private String homeBaseMapName() { + return DailyTaskCompanyMap.homeBase(heroFaction(), currentMapName()); + } + + private String questBaseMapName() { + return DailyTaskCompanyMap.questBase(heroFaction(), currentMapName()); + } + + private String companyPrefix() { + return DailyTaskCompanyMap.prefix(heroFaction(), currentMapName()); + } + + private EntityInfo.Faction heroFaction() { + EntityInfo info = hero.getEntityInfo(); + return info == null ? null : info.getFaction(); + } + + private String currentMapName() { + GameMap current = starSystem.getCurrentMap(); + return current == null ? null : current.getName(); + } + + private boolean recoverStagnantPlan(QuestAPI.Quest quest, double progress, long now) { + if (lastProgressAt == 0L || lastTrackedProgress < 0d || progress > lastTrackedProgress + 0.000001d) { + lastTrackedProgress = progress; + lastProgressAt = now; + return false; + } + if (now - lastProgressAt < settings.stagnationTimeoutSeconds * 1_000L) return false; + + lastProgressAt = now; + npcCombat.stopCombat(); + playerCombat.stopCombat(); + attack.stopAttack(); + attack.setTarget(null); + if (targetMapName != null && !isOnMap(targetMapName)) { + navigateTo(targetMapName); + } else if (!movement.isMoving()) { + movement.moveRandom(); + } + updateProgressStatus(quest, progress, + "Quest Engine recovery: target and route reevaluated"); + return true; + } + + private void resetProgressTracking() { + lastTrackedProgress = -1d; + lastProgressAt = 0L; + } + + private void updateProgressStatus(QuestAPI.Quest quest, double progress, String action) { + int percent = (int) Math.round(progress * 100d); + status = "Daily quest | Completed: " + completedQuestIds.size() + + " | " + safeTitle(quest) + " | %" + percent + " | " + action; + } + + private void pauseSafely(String reason) { + stopCurrentAction(); + state = State.PAUSED; + status = "Safe pause: " + reason; + } + + private void finishAll() { + stopCurrentAction(); + npcCombat.shutdownPet(); + state = State.DONE; + ZoneId localZone = ZoneId.systemDefault(); + nextActionAt = LocalDate.now(localZone).plusDays(1) + .atStartOfDay(localZone) + .toInstant().toEpochMilli(); + // Keep DarkBot ticking briefly so PetManager can send and observe the + // actual PET deactivation before the bot's running flag is cleared. + nextRoamAt = System.currentTimeMillis() + 5_000L; + status = completionSummary() + " | PET is shutting down | Bot will stop afterwards"; + } + + private String completionSummary() { + if (skippedActiveQuestIds.isEmpty()) { + return "ALL SUPPORTED DAILY QUESTS COMPLETED | Completed: " + completedQuestIds.size(); + } + return "NO RUNNABLE DAILY QUESTS REMAIN | Completed: " + completedQuestIds.size() + + " | Skipped by settings or unsupported: " + skippedActiveQuestIds.size(); + } + + private void stopCurrentAction() { + npcCombat.stopCombat(); + playerCombat.stopCombat(); + attack.stopAttack(); + movement.stop(false); + hero.setRoamMode(); + clearPlan(); + } + + private void clearPlan() { + targetNpcDescription = null; + targetPlayerDescription = null; + targetMapName = null; + oreToSell = null; + collectBonus = false; + collectCargo = false; + targetCoordinates = null; + } + + private String getNpcLocatorTargetDescription() { + if (!bot.isRunning() || bot.getNonTemporalModule() != this) return null; + if (state != State.RUNNING || targetNpcDescription == null) return null; + if (targetMapName != null && !isOnMap(targetMapName)) return null; + return targetNpcDescription; + } + + @Override + public PetGearSupplier getGearSupplier() { + return this; + } + + @Override + public PrioritizedSupplier.Priority getPriority() { + return getNpcLocatorTargetDescription() == null + ? PrioritizedSupplier.Priority.LOWEST + : PrioritizedSupplier.Priority.HIGHEST; + } + + @Override + public PetGear get() { + if (getNpcLocatorTargetDescription() != null && pet.hasGear(PetGear.ENEMY_LOCATOR)) { + return PetGear.ENEMY_LOCATOR; + } + PetGear current = pet.getGear(); + return current == null ? PetGear.PASSIVE : current; + } + + @Override + public Boolean enablePet() { + return getNpcLocatorTargetDescription() == null ? null : Boolean.TRUE; + } + + @Override + public PetAPI.LocatorPick getNpcLocatorPick( + Collection available) { + String description = getNpcLocatorTargetDescription(); + if (description == null) return PetGearSupplier.super.getNpcLocatorPick(available); + return available.stream() + .filter(pick -> pick != null && + DailyTaskPlanner.matchesNpcName(description, pick.getName())) + .findFirst() + .orElse(null); + } + + @Override + public Integer getNpcPickPriority(PetAPI.LocatorPick pick) { + String description = getNpcLocatorTargetDescription(); + if (description == null) return PetGearSupplier.super.getNpcPickPriority(pick); + return pick != null && DailyTaskPlanner.matchesNpcName(description, pick.getName()) + ? 0 + : null; + } + + private String safeTitle(QuestAPI.Quest quest) { + String title = quest == null ? null : quest.getTitle(); + if (title == null || title.isBlank() || title.equalsIgnoreCase("ERROR")) { + return quest == null ? "Quest" : "Quest #" + quest.getId(); + } + return title; + } + + private String firstRequirement(QuestAPI.Quest quest) { + return DailyTaskPlanner.actionable(quest).stream() + .map(QuestAPI.Requirement::getDescription) + .findFirst() + .orElse("etkin hedef yok"); + } + + private String cleanedRequirement(String value) { + return value == null ? "" : value.replaceAll("\\s+", " ").trim(); + } + + @Override + public void onTickStopped() { + // DarkBot also invokes this callback during short disconnects and map-load + // gaps. Stop active movement/attack, but retain the selected quest and its + // plan so a slow server does not force another trip to the quest station. + npcCombat.stopCombat(); + playerCombat.stopCombat(); + attack.stopAttack(); + movement.stop(false); + hero.setRoamMode(); + if (state != State.PAUSED && state != State.DONE) { + status = "DarkBot idle; daily quest plan retained"; + } + } + + @Override + public String getStatus() { + return "DailyTaskAPI | " + status; + } + + @Override + public String getStoppedStatus() { + return "DailyTaskAPI stopped | " + status; + } + + @Override + public boolean canRefresh() { + return true; + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskCompanyMap.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskCompanyMap.java new file mode 100644 index 00000000..3a8df59a --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskCompanyMap.java @@ -0,0 +1,36 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.game.other.EntityInfo; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Resolves company-owned maps without relying on the ship's current map. */ +final class DailyTaskCompanyMap { + private static final Pattern COMPANY_MAP = Pattern.compile("^([1-3])-"); + + private DailyTaskCompanyMap() { + } + + static String prefix(EntityInfo.Faction faction, String currentMapName) { + if (faction == EntityInfo.Faction.MMO) return "1"; + if (faction == EntityInfo.Faction.EIC) return "2"; + if (faction == EntityInfo.Faction.VRU) return "3"; + if (currentMapName == null) return null; + Matcher matcher = COMPANY_MAP.matcher(currentMapName); + return matcher.find() ? matcher.group(1) : null; + } + + static String homeBase(EntityInfo.Faction faction, String currentMapName) { + return companyMap(faction, currentMapName, "1"); + } + + static String questBase(EntityInfo.Faction faction, String currentMapName) { + return companyMap(faction, currentMapName, "8"); + } + + private static String companyMap(EntityInfo.Faction faction, String currentMapName, String suffix) { + String prefix = prefix(faction, currentMapName); + return prefix == null ? null : prefix + "-" + suffix; + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskConfig.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskConfig.java new file mode 100644 index 00000000..ae05630e --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskConfig.java @@ -0,0 +1,74 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.config.annotations.Configuration; +import eu.darkbot.api.config.annotations.Number; +import eu.darkbot.api.config.annotations.Option; + +@Configuration("dailytaskapi.config") +public final class DailyTaskConfig { + @Option("dailytaskapi.auto_open_quest_window") + public boolean autoOpenQuestWindow = true; + + @Option("dailytaskapi.require_uridium_reward") + public boolean requireUridiumReward = true; + + @Option("dailytaskapi.skip_tetrathrin_only") + public boolean skipTetrathrinOnly = true; + + @Option("dailytaskapi.skip_protegit") + public boolean skipProtegitQuests = true; + + @Option("dailytaskapi.accept_player_kills") + public boolean acceptPlayerKillQuests = false; + + @Option("dailytaskapi.prefer_shorter") + public boolean preferShorterQuests = true; + + @Option("dailytaskapi.server_delay") + @Number(min = 250, max = 20000, step = 250) + public int questSwitchDelayMs = 500; + + @Option("dailytaskapi.response_retries") + @Number(min = 1, max = 10, step = 1) + public int maxSelectionRetries = 4; + + @Option("dailytaskapi.npc_radius") + @Number(min = 300, max = 750, step = 25) + public int attackRadius = 590; + + @Option("dailytaskapi.player_radius") + @Number(min = 300, max = 750, step = 25) + public int playerAttackRadius = 590; + + @Option("dailytaskapi.enemy_players_only") + public boolean enemyPlayersOnly = true; + + @Option("dailytaskapi.avoid_allies") + public boolean avoidAlliedAndGroupPlayers = true; + + @Option("dailytaskapi.prioritize_attackers") + public boolean prioritizePlayersAttackingMe = true; + + @Option("dailytaskapi.player_map") + public String playerCombatMap = ""; + + @Option("dailytaskapi.player_min_shield") + @Number(min = 0.00, max = 0.90, step = 0.05) + public double playerMinimumShieldPercent = 0.20; + + @Option("dailytaskapi.max_player_deaths") + @Number(min = 0, max = 20, step = 1) + public int maxPlayerDeaths = 2; + + @Option("dailytaskapi.player_search_timeout") + @Number(min = 30, max = 1800, step = 30) + public int playerSearchTimeoutSeconds = 300; + + @Option("dailytaskapi.minimum_hp") + @Number(min = 0.10, max = 0.90, step = 0.05) + public double minimumHpPercent = 0.30; + + @Option("dailytaskapi.stagnation_timeout") + @Number(min = 60, max = 900, step = 30) + public int stagnationTimeoutSeconds = 300; +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskPlanner.java b/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskPlanner.java new file mode 100644 index 00000000..78467317 --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/DailyTaskPlanner.java @@ -0,0 +1,218 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.managers.OreAPI; +import eu.darkbot.api.managers.QuestAPI; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class DailyTaskPlanner { + private static final Pattern MAP_PATTERN = Pattern.compile("(?i)([1-3]-BL|[1-3]-[1-8]|4-[1-5]|5-[1-3])"); + private static final double DAILY_DURATION_MS = 86_400_000d; + + private DailyTaskPlanner() { + } + + static boolean isDaily(QuestAPI.Quest quest) { + if (quest == null) return false; + for (QuestAPI.Requirement requirement : flatten(quest.getRequirements())) { + if (requirement.getRequirementType() == QuestAPI.Requirement.RequirementType.REAL_TIME_HASTE) { + double goal = requirement.getGoal(); + return goal >= DAILY_DURATION_MS - 120_000d && goal <= DAILY_DURATION_MS + 120_000d; + } + } + return false; + } + + static boolean isDailyType(String type) { + if (type == null) return false; + String normalized = type.trim().toLowerCase(Locale.ROOT); + return normalized.startsWith("questtype_daily") || normalized.equals("daily"); + } + + static boolean isOnlyTetrathrinReward(Collection rewards) { + if (rewards == null || rewards.isEmpty()) return false; + boolean hasTetrathrin = false; + for (QuestAPI.Reward reward : rewards) { + if (isPositiveReward(reward)) { + String type = normalize(reward.getType()); + if (type.contains("uridium")) return false; + if (!type.startsWith("currency ")) { + if (!type.contains("tetrathrin")) return false; + hasTetrathrin = true; + } + } + } + return hasTetrathrin; + } + + private static boolean isPositiveReward(QuestAPI.Reward reward) { + return reward != null && reward.getAmount() > 0; + } + + static boolean hasUridiumReward(Collection rewards) { + if (rewards == null || rewards.isEmpty()) return false; + for (QuestAPI.Reward reward : rewards) { + if (reward != null && reward.getAmount() > 0 && normalize(reward.getType()).contains("uridium")) { + return true; + } + } + return false; + } + + static List actionable(QuestAPI.Quest quest) { + List result = new ArrayList<>(); + if (quest == null) return result; + for (QuestAPI.Requirement requirement : flatten(quest.getRequirements())) { + if (isActionable(requirement, false)) result.add(requirement); + } + return result; + } + + static List flatten(Collection roots) { + List result = new ArrayList<>(); + if (roots == null) return result; + for (QuestAPI.Requirement root : roots) flattenInto(root, result); + return result; + } + + private static void flattenInto(QuestAPI.Requirement requirement, List output) { + if (requirement == null) return; + // A requirement may be both actionable itself and contain structural + // children. DarkBot models quests such as "kill Mordon on 1-4" as a + // KILL_NPC parent with a MAP child, so retaining leaves only loses the + // NPC target and prevents both combat and the PET enemy locator. + output.add(requirement); + List children = requirement.getRequirements(); + if (children != null) { + for (QuestAPI.Requirement child : children) flattenInto(child, output); + } + } + + static Optional findNpc(String description, Collection npcNames) { + if (description == null || npcNames == null) return Optional.empty(); + String text = normalize(description); + boolean wantsBoss = text.contains("boss"); + boolean wantsUber = text.contains("uber"); + return npcNames.stream() + .map(name -> new NpcCandidate(name, normalizeNpcName(name))) + .filter(candidate -> !candidate.clean.isBlank() && text.contains(candidate.clean)) + .filter(candidate -> wantsBoss == candidate.clean.startsWith("boss ")) + .filter(candidate -> wantsUber == candidate.clean.startsWith("uber")) + .max(Comparator.comparingInt(candidate -> candidate.clean.length())) + .map(candidate -> candidate.original); + } + + static boolean matchesNpcName(String description, String npcName) { + if (description == null || npcName == null) return false; + String target = normalizeNpcName(description); + String candidate = normalizeNpcName(npcName); + boolean wantsBoss = target.contains("boss"); + boolean wantsUber = target.contains("uber"); + return !candidate.isBlank() && target.contains(candidate) && + wantsBoss == candidate.startsWith("boss ") && + wantsUber == candidate.startsWith("uber"); + } + + static Optional findMap(String description) { + if (description == null) return Optional.empty(); + Matcher matcher = MAP_PATTERN.matcher(description); + return matcher.find() ? Optional.of(matcher.group(1).toUpperCase(Locale.ROOT)) : Optional.empty(); + } + + static Optional findOre(String description) { + String text = normalize(description); + if (text.contains("prometium")) return Optional.of(OreAPI.Ore.PROMETIUM); + if (text.contains("endurium")) return Optional.of(OreAPI.Ore.ENDURIUM); + if (text.contains("terbium")) return Optional.of(OreAPI.Ore.TERBIUM); + if (text.contains("prometid")) return Optional.of(OreAPI.Ore.PROMETID); + if (text.contains("duranium")) return Optional.of(OreAPI.Ore.DURANIUM); + if (text.contains("promerium")) return Optional.of(OreAPI.Ore.PROMERIUM); + if (text.contains("seprom")) return Optional.of(OreAPI.Ore.SEPROM); + if (text.contains("palladium")) return Optional.of(OreAPI.Ore.PALLADIUM); + if (text.contains("osmium")) return Optional.of(OreAPI.Ore.OSMIUM); + if (text.contains("xenomit")) return Optional.of(OreAPI.Ore.XENOMIT); + return Optional.empty(); + } + + static String normalizeNpcName(String value) { + return normalize(value) + .replace('-', ' ') + .replaceAll("\\b(delta|alpha|beta|gamma)\\s*\\d+\\b", " ") + .replaceAll("\\b[a-z]\\d+\\b", " ") + .replaceAll("\\s+", " ") + .trim(); + } + + static String normalize(String value) { + if (value == null) return ""; + return Normalizer.normalize(value, Normalizer.Form.NFD) + .replaceAll("\\p{M}+", "") + .toLowerCase(Locale.ROOT) + .replace('ı', 'i') + .replaceAll("[^a-z0-9-]+", " ") + .replaceAll("\\s+", " ") + .trim(); + } + + static double progress(QuestAPI.Quest quest) { + List requirements = actionableIncludingCompleted(quest); + if (requirements.isEmpty()) return 0d; + double sum = 0d; + for (QuestAPI.Requirement requirement : requirements) { + sum += requirementProgress(requirement); + } + return sum / requirements.size(); + } + + static double remainingWork(QuestAPI.Quest quest) { + double remaining = 0d; + for (QuestAPI.Requirement requirement : actionable(quest)) { + double goal = Math.max(0d, requirement.getGoal()); + remaining += goal <= 0d ? 1d : Math.max(0d, goal - requirement.getProgress()); + } + return remaining; + } + + private static double requirementProgress(QuestAPI.Requirement requirement) { + double goal = requirement.getGoal(); + if (goal <= 0d) return requirement.isCompleted() ? 1d : 0d; + return Math.max(0d, Math.min(1d, requirement.getProgress() / goal)); + } + + private static List actionableIncludingCompleted(QuestAPI.Quest quest) { + List result = new ArrayList<>(); + if (quest == null) return result; + for (QuestAPI.Requirement requirement : flatten(quest.getRequirements())) { + if (isActionable(requirement, true)) result.add(requirement); + } + return result; + } + + private static boolean isActionable(QuestAPI.Requirement requirement, boolean includeCompleted) { + if (requirement == null || !requirement.isEnabled()) return false; + if (!includeCompleted && requirement.isCompleted()) return false; + QuestAPI.Requirement.RequirementType type = requirement.getRequirementType(); + return type != QuestAPI.Requirement.RequirementType.REAL_TIME_HASTE && + type != QuestAPI.Requirement.RequirementType.HASTE && + type != QuestAPI.Requirement.RequirementType.TIMER && + type != QuestAPI.Requirement.RequirementType.COUNTDOWN; + } + + private static final class NpcCandidate { + private final String original; + private final String clean; + + private NpcCandidate(String original, String clean) { + this.original = original; + this.clean = clean; + } + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/QuestGiverSource.java b/src/main/java/dev/shared/berke/dailytaskapi/QuestGiverSource.java new file mode 100644 index 00000000..2b857047 --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/QuestGiverSource.java @@ -0,0 +1,73 @@ +package dev.shared.berke.dailytaskapi; + +import com.github.manolo8.darkbot.Main; +import eu.darkbot.api.game.other.Area; +import eu.darkbot.api.managers.GameScreenAPI; +import eu.darkbot.api.utils.NativeAction; + +/** Source-verified controls for DarkOrbit's quest-giver modal. */ +final class QuestGiverSource { + static final int MAX_ROWS = 6; + static final int MAX_SCROLL_PAGES = 10; + + private static final double WINDOW_WIDTH = 840d; + private static final double WINDOW_HEIGHT = 550d; + private static final double TABS_X = 8d; + private static final double TABS_Y = 35d; + private static final double TABS_WIDTH = 813d; + private static final double TABS_HEIGHT = 21d; + private static final int TAB_COUNT = 5; + private static final int DAILY_TAB_POSITION = 3; + private static final double LIST_X = 8d; + private static final double LIST_Y = 305d; + private static final double LIST_WIDTH = 238d; + private static final double LIST_HEIGHT = 206d; + private static final double ACCEPT_X = 668d; + private static final double ACCEPT_Y = 494d; + private static final double ACCEPT_WIDTH = 162d; + private static final double ACCEPT_HEIGHT = 21d; + + private final GameScreenAPI screen; + + QuestGiverSource(GameScreenAPI screen) { + this.screen = screen; + } + + void selectDailyTab() { + double tabWidth = TABS_WIDTH / TAB_COUNT; + click(TABS_X + tabWidth * DAILY_TAB_POSITION - tabWidth / 2d, + TABS_Y + TABS_HEIGHT / 2d); + } + + void scrollDailyListDown() { + Area.Rectangle viewport = screen.getViewBounds(); + double windowX = viewport.getWidth() / 2d - WINDOW_WIDTH / 2d; + double windowY = viewport.getHeight() / 2d - WINDOW_HEIGHT / 2d; + int x = (int) Math.round(windowX + LIST_X + LIST_WIDTH / 2d); + int y = (int) Math.round(windowY + LIST_Y + LIST_HEIGHT / 2d); + Main.API.postActions(NativeAction.MouseWheel.down(x, y)); + } + + void selectRow(int oneBasedRow) { + int row = Math.max(1, Math.min(MAX_ROWS, oneBasedRow)); + double rowHeight = LIST_HEIGHT / MAX_ROWS; + click(LIST_X + LIST_WIDTH / 2d, + LIST_Y + rowHeight * row - rowHeight / 2d); + } + + void acceptSelected() { + click(ACCEPT_X + ACCEPT_WIDTH / 2d, ACCEPT_Y + ACCEPT_HEIGHT / 2d); + } + + void close() { + Main.API.keyClick(27); + } + + private void click(double localX, double localY) { + Area.Rectangle viewport = screen.getViewBounds(); + double windowX = viewport.getWidth() / 2d - WINDOW_WIDTH / 2d; + double windowY = viewport.getHeight() / 2d - WINDOW_HEIGHT / 2d; + Main.API.mouseClick((int) Math.round(windowX + localX), + (int) Math.round(windowY + localY)); + } +} diff --git a/src/main/java/dev/shared/berke/dailytaskapi/QuestMenuSource.java b/src/main/java/dev/shared/berke/dailytaskapi/QuestMenuSource.java new file mode 100644 index 00000000..09fef24f --- /dev/null +++ b/src/main/java/dev/shared/berke/dailytaskapi/QuestMenuSource.java @@ -0,0 +1,229 @@ +package dev.shared.berke.dailytaskapi; + +import com.github.manolo8.darkbot.Main; +import com.github.manolo8.darkbot.core.IDarkBotAPI; +import eu.darkbot.api.game.other.Gui; +import eu.darkbot.api.utils.NativeAction; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Discovers the active-quest selector row from DarkBot's native quest GUI. + * + *

No screenshot, OCR, quest title, fixed slot number or screen resolution is + * used. The selector row is recognized from the native sprite source: it lives + * in the quest header and contains evenly-spaced selector sprites. All quest + * identity and daily/non-daily decisions remain the responsibility of + * QuestAPI.

+ */ +final class QuestMenuSource { + static final class Selector { + private final int x; + private final int y; + + Selector(int x, int y) { + this.x = x; + this.y = y; + } + + int x() { + return x; + } + + int y() { + return y; + } + } + + private static final class Sprite { + private final int x; + private final int y; + private final List children; + + private Sprite(int x, int y, List children) { + this.x = x; + this.y = y; + this.children = children; + } + + private int x() { + return x; + } + + private int y() { + return y; + } + + private List children() { + return children; + } + } + + private static final class Row { + private final int x; + private final int y; + private final List childXs; + private final int score; + + private Row(int x, int y, List childXs, int score) { + this.x = x; + this.y = y; + this.childXs = childXs; + this.score = score; + } + + private int x() { + return x; + } + + private int y() { + return y; + } + + private List childXs() { + return childXs; + } + + private int score() { + return score; + } + } + + private final Gui questGui; + private final com.github.manolo8.darkbot.core.objects.Gui nativeQuestGui; + private final IDarkBotAPI memory; + private String lastError = ""; + + QuestMenuSource(Gui questGui) { + this.questGui = questGui; + this.nativeQuestGui = questGui instanceof com.github.manolo8.darkbot.core.objects.Gui + ? (com.github.manolo8.darkbot.core.objects.Gui) questGui : null; + this.memory = Main.API; + } + + List discoverSelectors() { + lastError = ""; + if (questGui == null) return fail("Quest window not found"); + if (memory == null || nativeQuestGui == null) { + return fail("DarkBot quest source tree is unavailable"); + } + + try { + long rootAddress = nativeQuestGui.getAddress(); + if (rootAddress == 0L) return fail("Quest source address is not ready"); + Sprite root = readSprite(rootAddress, 0, new HashSet<>()); + if (root == null) return fail("Quest source tree could not be read"); + + List rows = new ArrayList<>(); + // The root coordinate is the quest window's screen position. + // Remove it so candidates are evaluated in GUI-local space; + // window position, animation and resolution cannot affect discovery. + findRows(root, -root.x(), -root.y(), 0, rows); + Row best = rows.stream().max(Comparator.comparingInt(Row::score)).orElse(null); + if (best == null) return fail("Quest selector row not found in source tree"); + + List selectors = new ArrayList<>(); + for (int childX : best.childXs()) { + // These compact quest tabs only expose a hitbox near their + // top-left edge. The visual centre is outside the clickable + // area in the current client, so use a one-pixel inset. + selectors.add(new Selector(best.x() + childX + 1, best.y() + 1)); + } + return List.copyOf(selectors); + } catch (RuntimeException error) { + return fail("Quest source tree error: " + error.getClass().getSimpleName()); + } + } + + String getLastError() { + return lastError; + } + + void scrollSelectorsDown() { + List current = discoverSelectors(); + if (current.isEmpty()) return; + Selector middle = current.get(current.size() / 2); + int screenX = (int) Math.round(questGui.getX() + middle.x()); + int screenY = (int) Math.round(questGui.getY() + middle.y()); + Main.API.postActions(NativeAction.MouseWheel.down(screenX, screenY)); + } + + private Sprite readSprite(long address, int depth, Set visited) { + if (address == 0L || depth > 7 || visited.size() > 600 || !visited.add(address)) return null; + List childAddresses = new ArrayList<>(); + try { + nativeQuestGui.forEachSpriteChild(address, childAddresses::add); + } catch (RuntimeException error) { + lastError = "Quest source child could not be read: " + describe(error); + } + + List children = new ArrayList<>(); + for (long childAddress : childAddresses) { + Sprite child = readSprite(childAddress, depth + 1, visited); + if (child != null) children.add(child); + } + return new Sprite(coordinate(address, 88L), coordinate(address, 92L), List.copyOf(children)); + } + + private int coordinate(long address, long offset) { + try { + long matrix = memory.readLong(address + 72L); + int raw = memory.readInt(matrix + offset); + return Math.round(raw * 0.05f); + } catch (RuntimeException ignored) { + return 0; + } + } + + private void findRows(Sprite sprite, int parentX, int parentY, int depth, List rows) { + int absoluteX = parentX + sprite.x(); + int absoluteY = parentY + sprite.y(); + Row candidate = rowCandidate(sprite, absoluteX, absoluteY, depth); + if (candidate != null) rows.add(candidate); + for (Sprite child : sprite.children()) findRows(child, absoluteX, absoluteY, depth + 1, rows); + } + + private Row rowCandidate(Sprite sprite, int absoluteX, int absoluteY, int depth) { + List children = sprite.children(); + if (children.size() < 2 || children.size() > 24 || depth < 2) return null; + if (absoluteY < 0 || absoluteY > 25 || absoluteX < 100) return null; + + List xs = children.stream().map(Sprite::x).sorted().collect(Collectors.toList()); + int minY = children.stream().mapToInt(Sprite::y).min().orElse(0); + int maxY = children.stream().mapToInt(Sprite::y).max().orElse(0); + if (maxY - minY > 2) return null; + + List differences = new ArrayList<>(); + for (int index = 1; index < xs.size(); index++) differences.add(xs.get(index) - xs.get(index - 1)); + int step = median(differences); + if (step < 8 || step > 14) return null; + if (differences.stream().anyMatch(value -> Math.abs(value - step) > 2)) return null; + + int score = children.size() * 100 + Math.min(absoluteX, 500) - absoluteY * 2; + return new Row(absoluteX, absoluteY, xs, score); + } + + private int median(List values) { + if (values.isEmpty()) return 0; + List sorted = values.stream().sorted().collect(Collectors.toList()); + return sorted.get(sorted.size() / 2); + } + + private List fail(String message) { + if (lastError == null || lastError.isBlank()) lastError = message; + return List.of(); + } + + private String describe(RuntimeException error) { + if (error == null) return "unknown error"; + String description = error.getClass().getSimpleName(); + String message = error.getMessage(); + if (message == null || message.isBlank()) return description; + return description + ": " + message; + } +} diff --git a/src/main/resources/dev/shared/lang/strings_en.properties b/src/main/resources/dev/shared/lang/strings_en.properties index 35da6b6d..ceda1975 100644 --- a/src/main/resources/dev/shared/lang/strings_en.properties +++ b/src/main/resources/dev/shared/lang/strings_en.properties @@ -251,4 +251,44 @@ do_gamer.autobuy.ammo.slugElsD01=ELS-D01 (Slug) do_gamer.autobuy.ammo.spoolGsp30=GSP-30 (Spool) do_gamer.autobuy.purchase.amount=Purchase amount do_gamer.autobuy.purchase.min= Min in inventory -do_gamer.autobuy.purchase.min.desc=Minimum amount of the item in inventory to trigger the purchase. \ No newline at end of file +do_gamer.autobuy.purchase.min.desc=Minimum amount of the item in inventory to trigger the purchase. + +dailytaskapi.config=Daily Task API +dailytaskapi.auto_open_quest_window=Open the quest window automatically +dailytaskapi.auto_open_quest_window.desc=Opens the active quest window when a daily-task scan starts. +dailytaskapi.require_uridium_reward=Accept only quests with Uridium +dailytaskapi.require_uridium_reward.desc=Skips station daily offers that do not contain a positive Uridium reward. +dailytaskapi.skip_tetrathrin_only=Skip Tetrathrin-only quests +dailytaskapi.skip_tetrathrin_only.desc=Skips offers whose only non-currency reward is Tetrathrin. +dailytaskapi.skip_protegit=Skip Protegit quests +dailytaskapi.skip_protegit.desc=Prevents accepting or running daily quests that require Protegit kills. +dailytaskapi.accept_player_kills=Accept player-combat quests +dailytaskapi.accept_player_kills.desc=Opt in to daily quests that require attacking players. Disabled by default for safety. +dailytaskapi.prefer_shorter=Prefer shorter quests +dailytaskapi.prefer_shorter.desc=Runs supported daily quests with less remaining work first. +dailytaskapi.server_delay=Server response delay (ms) +dailytaskapi.server_delay.desc=Wait time between quest-window actions. Increase this on slow servers. +dailytaskapi.response_retries=Server response retries +dailytaskapi.response_retries.desc=Number of safe retries before an unresponsive quest row is skipped. +dailytaskapi.npc_radius=NPC attack radius +dailytaskapi.npc_radius.desc=Maximum distance for selecting the NPC required by the current daily quest. +dailytaskapi.player_radius=Player attack radius +dailytaskapi.player_radius.desc=Maximum distance for engaging an eligible player target. +dailytaskapi.enemy_players_only=Enemy players only +dailytaskapi.enemy_players_only.desc=Rejects player targets that DarkBot does not classify as hostile. +dailytaskapi.avoid_allies=Avoid allies and group members +dailytaskapi.avoid_allies.desc=Never selects group members, allies or non-aggression-pact players. +dailytaskapi.prioritize_attackers=Prioritize players attacking me +dailytaskapi.prioritize_attackers.desc=Prefers an eligible player who is already attacking the ship. +dailytaskapi.player_map=Player-combat map +dailytaskapi.player_map.desc=Optional map used for player-combat quests when the quest does not specify one. +dailytaskapi.player_min_shield=Minimum shield for player combat +dailytaskapi.player_min_shield.desc=Stops local player combat and moves toward safety below this shield percentage. +dailytaskapi.max_player_deaths=Maximum player-combat deaths +dailytaskapi.max_player_deaths.desc=Safely pauses a player-combat quest after this many deaths. Set to 0 for no limit. +dailytaskapi.player_search_timeout=Player search timeout (seconds) +dailytaskapi.player_search_timeout.desc=Safely pauses when no eligible player is found within this time. +dailytaskapi.minimum_hp=Minimum HP +dailytaskapi.minimum_hp.desc=Moves toward the nearest portal when local task handling falls below this HP percentage. +dailytaskapi.stagnation_timeout=Stagnation recovery (seconds) +dailytaskapi.stagnation_timeout.desc=Reevaluates the target and route if quest progress does not change for this long. diff --git a/src/main/resources/dev/shared/lang/strings_tr.properties b/src/main/resources/dev/shared/lang/strings_tr.properties index 4f07a24f..fef06d24 100644 --- a/src/main/resources/dev/shared/lang/strings_tr.properties +++ b/src/main/resources/dev/shared/lang/strings_tr.properties @@ -251,4 +251,44 @@ do_gamer.autobuy.ammo.slugElsD01=ELS-D01 (Slug) do_gamer.autobuy.ammo.spoolGsp30=GSP-30 (Spool) do_gamer.autobuy.purchase.amount=Purchase amount do_gamer.autobuy.purchase.min= Min in inventory -do_gamer.autobuy.purchase.min.desc=Minimum amount of the item in inventory to trigger the purchase. \ No newline at end of file +do_gamer.autobuy.purchase.min.desc=Minimum amount of the item in inventory to trigger the purchase. + +dailytaskapi.config=Günlük Görev API +dailytaskapi.auto_open_quest_window=Görev penceresini otomatik aç +dailytaskapi.auto_open_quest_window.desc=Günlük görev taraması başladığında aktif görev penceresini açar. +dailytaskapi.require_uridium_reward=Yalnız Uridium ödüllü görevleri al +dailytaskapi.require_uridium_reward.desc=Pozitif Uridium ödülü içermeyen istasyon günlüklerini atlar. +dailytaskapi.skip_tetrathrin_only=Yalnız Tetrathrin verenleri atla +dailytaskapi.skip_tetrathrin_only.desc=Tek eşya ödülü Tetrathrin olan görevleri atlar. +dailytaskapi.skip_protegit=Protegit görevlerini atla +dailytaskapi.skip_protegit.desc=Protegit öldürmeyi isteyen günlük görevlerin alınmasını ve çalıştırılmasını engeller. +dailytaskapi.accept_player_kills=Oyuncu savaşı görevlerini al +dailytaskapi.accept_player_kills.desc=Oyunculara saldırmayı isteyen günlük görevleri etkinleştirir. Güvenlik için varsayılan olarak kapalıdır. +dailytaskapi.prefer_shorter=Daha kısa görevleri önce yap +dailytaskapi.prefer_shorter.desc=Kalan işi daha az olan desteklenen günlük görevleri önce çalıştırır. +dailytaskapi.server_delay=Sunucu yanıt gecikmesi (ms) +dailytaskapi.server_delay.desc=Görev penceresi işlemleri arasındaki bekleme süresi. Yavaş sunucularda artırın. +dailytaskapi.response_retries=Sunucu yanıt denemeleri +dailytaskapi.response_retries.desc=Yanıt vermeyen bir görev satırı atlanmadan önce yapılacak güvenli deneme sayısı. +dailytaskapi.npc_radius=NPC saldırı menzili +dailytaskapi.npc_radius.desc=Etkin günlük görevin istediği NPC'yi seçmek için kullanılacak en yüksek mesafe. +dailytaskapi.player_radius=Oyuncu saldırı menzili +dailytaskapi.player_radius.desc=Uygun bir oyuncu hedefiyle çatışmaya girilecek en yüksek mesafe. +dailytaskapi.enemy_players_only=Yalnız düşman oyuncular +dailytaskapi.enemy_players_only.desc=DarkBot'un düşman olarak sınıflandırmadığı oyuncu hedeflerini reddeder. +dailytaskapi.avoid_allies=Müttefiklerden ve grup üyelerinden kaçın +dailytaskapi.avoid_allies.desc=Grup üyelerini, müttefikleri ve saldırmazlık anlaşmalı oyuncuları asla seçmez. +dailytaskapi.prioritize_attackers=Saldıran oyunculara öncelik ver +dailytaskapi.prioritize_attackers.desc=Gemiye zaten saldıran uygun oyuncuya öncelik verir. +dailytaskapi.player_map=Oyuncu savaşı haritası +dailytaskapi.player_map.desc=Görev harita belirtmiyorsa oyuncu savaşı görevlerinde kullanılacak isteğe bağlı harita. +dailytaskapi.player_min_shield=Oyuncu savaşı asgari kalkanı +dailytaskapi.player_min_shield.desc=Kalkan bu oranın altına indiğinde oyuncu savaşını bırakıp güvenli alana gider. +dailytaskapi.max_player_deaths=En fazla oyuncu savaşı ölümü +dailytaskapi.max_player_deaths.desc=Bu ölüm sayısından sonra oyuncu savaşı görevini güvenli biçimde duraklatır. Sınırsız için 0. +dailytaskapi.player_search_timeout=Oyuncu arama zaman aşımı (saniye) +dailytaskapi.player_search_timeout.desc=Bu süre içinde uygun oyuncu bulunamazsa güvenli biçimde duraklatır. +dailytaskapi.minimum_hp=Asgari DP +dailytaskapi.minimum_hp.desc=Yerel görev işlemleri sırasında DP bu oranın altına düşerse en yakın portala gider. +dailytaskapi.stagnation_timeout=İlerleme kurtarma süresi (saniye) +dailytaskapi.stagnation_timeout.desc=Görev ilerlemesi bu süre boyunca değişmezse hedefi ve güzergâhı yeniden değerlendirir. diff --git a/src/main/resources/plugin.json b/src/main/resources/plugin.json index f8d30a0e..5e825073 100644 --- a/src/main/resources/plugin.json +++ b/src/main/resources/plugin.json @@ -19,8 +19,9 @@ "dev.shared.orbithelper.behaviours.fast_travel.FastTravel", "dev.shared.do_gamer.module.simple_galaxy_gate.SimpleGalaxyGate", "dev.shared.do_gamer.task.autobuy.Autobuy", - "dev.shared.halizeur.log_overlay.LogOverlay" + "dev.shared.halizeur.log_overlay.LogOverlay", + "dev.shared.berke.dailytaskapi.DailyTaskAPI" ], "update": "https://raw.githubusercontent.com/Darkbot-Plugins/SharedPlugin/main/src/main/resources/plugin.json", "download": "https://github.com/Darkbot-Plugins/SharedPlugin/releases/latest/download/SharedPlugin.jar" -} \ No newline at end of file +} diff --git a/src/test/java/dev/shared/berke/dailytaskapi/DailyTaskPlannerTest.java b/src/test/java/dev/shared/berke/dailytaskapi/DailyTaskPlannerTest.java new file mode 100644 index 00000000..ab5a72ef --- /dev/null +++ b/src/test/java/dev/shared/berke/dailytaskapi/DailyTaskPlannerTest.java @@ -0,0 +1,343 @@ +package dev.shared.berke.dailytaskapi; + +import eu.darkbot.api.config.ConfigSetting; +import eu.darkbot.api.config.types.NpcInfo; +import eu.darkbot.api.game.other.EntityInfo; +import eu.darkbot.api.game.other.GameMap; +import eu.darkbot.api.managers.OreAPI; +import eu.darkbot.api.managers.QuestAPI; +import eu.darkbot.api.managers.StarSystemAPI; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class DailyTaskPlannerTest { + private static final double DAY_MS = 86_400_000d; + + @Test + void identifiesDailyQuestsFromTheSourceTimer() { + QuestAPI.Requirement timer = requirement("Complete within one day", "REAL_TIME_HASTE", + QuestAPI.Requirement.RequirementType.REAL_TIME_HASTE, 10, DAY_MS, false); + QuestAPI.Requirement kill = requirement("Destroy Lordakium", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 2, 5, false); + + assertTrue(DailyTaskPlanner.isDaily(quest(1, true, false, List.of(kill, timer)))); + assertTrue(DailyTaskPlanner.isDaily(quest(2, false, false, List.of(kill, timer)))); + assertFalse(DailyTaskPlanner.isDaily(quest(3, true, false, List.of(kill)))); + assertTrue(DailyTaskPlanner.isDailyType("questType_daily1")); + assertFalse(DailyTaskPlanner.isDailyType("questType_kill")); + } + + @Test + void retainsNestedNpcAndMapRequirements() { + QuestAPI.Requirement map = requirement("On map 1-4", "MAP", + QuestAPI.Requirement.RequirementType.MAP, 0, 0, false); + QuestAPI.Requirement npc = requirement("Destroy Mordon", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 0, 8, false, List.of(map)); + QuestAPI.Quest daily = quest(4, true, false, List.of(npc, dailyTimer())); + + assertTrue(DailyTaskPlanner.actionable(daily).contains(npc)); + assertTrue(DailyTaskPlanner.actionable(daily).contains(map)); + + DailyQuestConditionEngine.Plan plan = DailyQuestConditionEngine.build(daily); + assertEquals("1-4", plan.targetMapName()); + assertTrue(plan.targetNpcDescription().contains("Mordon")); + } + + @Test + void parsesCoordinatesAndTypedMapVisits() { + QuestAPI.Requirement coordinates = requirement("X: 12400 Y: 7600", "COORDINATES", + QuestAPI.Requirement.RequirementType.COORDINATES, 0, 1, false); + DailyQuestConditionEngine.Plan coordinatePlan = DailyQuestConditionEngine.build( + quest(5, true, false, List.of(coordinates, dailyTimer()))); + assertEquals(12400, coordinatePlan.targetCoordinates().x()); + assertEquals(7600, coordinatePlan.targetCoordinates().y()); + assertTrue(coordinatePlan.coordinatesOnly()); + + QuestAPI.Requirement visit = requirement("Visit map 1-8", "VISIT_MAP", + QuestAPI.Requirement.RequirementType.VISIT_MAP, 0, 1, false); + QuestAPI.Quest visitQuest = quest(6, true, false, List.of(visit, dailyTimer())); + assertEquals("1-8", DailyQuestConditionEngine.build(visitQuest).targetMapName()); + assertTrue(DailyQuestConditionEngine.supports(visitQuest)); + } + + @Test + void matchesNpcVariantsWithoutMaintainingNpcMapLists() { + List names = List.of("-=[ Lordakium ]=-", "..::{ Boss Lordakium }::..", "( UberLordakium )"); + assertEquals("-=[ Lordakium ]=-", DailyTaskPlanner.findNpc("Destroy Lordakium", names).orElseThrow()); + assertTrue(DailyTaskPlanner.findNpc("Destroy Boss Lordakium", names).orElseThrow() + .contains("Boss Lordakium")); + assertTrue(DailyTaskPlanner.findNpc("Destroy Boss Sibelonit", List.of("-=[ Sibelonit ]=-")).isEmpty()); + assertTrue(DailyTaskPlanner.findNpc("Destroy UberLordakium", + List.of("-=[ Lordakium ]=-", "..::{ Boss Lordakium }::..")).isEmpty()); + assertEquals(OreAPI.Ore.PROMETIUM, DailyTaskPlanner.findOre("Sell Prometium").orElseThrow()); + } + + @Test + void resolvesNpcMapThroughRuntimeMetadataResolver() { + QuestAPI.Requirement npc = requirement("Destroy a newly added NPC", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 0, 3, false); + DailyQuestConditionEngine.Plan plan = DailyQuestConditionEngine.build( + quest(20, true, false, List.of(npc, dailyTimer())), + ignored -> java.util.Optional.of("5-3")); + + assertEquals("5-3", plan.targetMapName()); + assertTrue(plan.targetNpcDescription().contains("newly added NPC")); + } + + @Test + void acceptsOnlyValidMapNames() { + assertEquals("1-6", DailyTaskPlanner.findMap("Travel to 1-6").orElseThrow()); + assertEquals("4-5", DailyTaskPlanner.findMap("Travel to 4-5").orElseThrow()); + assertEquals("5-3", DailyTaskPlanner.findMap("Travel to 5-3").orElseThrow()); + assertTrue(DailyTaskPlanner.findMap("Travel to 4-8").isEmpty()); + assertTrue(DailyTaskPlanner.findMap("Travel to 5-8").isEmpty()); + } + + @Test + void filtersOffersBySourceRewardTypes() { + assertTrue(DailyTaskPlanner.isOnlyTetrathrinReward(List.of( + reward("currency_experience", 1000), reward("currency_credits", 1000), + reward("resource_tetrathrin", 10)))); + assertFalse(DailyTaskPlanner.isOnlyTetrathrinReward(List.of( + reward("resource_tetrathrin", 10), reward("ammunition_laser_ucb-100", 5000)))); + assertFalse(DailyTaskPlanner.isOnlyTetrathrinReward(List.of( + reward("resource_tetrathrin", 10), reward("currency_uridium", 1500)))); + assertFalse(DailyTaskPlanner.isOnlyTetrathrinReward(List.of(reward("currency_experience", 1000)))); + assertTrue(DailyTaskPlanner.hasUridiumReward(List.of(reward("currency_uridium", 1500)))); + assertFalse(DailyTaskPlanner.hasUridiumReward(List.of(reward("resource_tetrathrin", 10)))); + } + + @Test + void calculatesProgressWithoutTimerRequirements() { + QuestAPI.Requirement partial = requirement("Destroy NPC", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 2, 5, false); + QuestAPI.Requirement timer = dailyTimer(); + assertEquals(0.4d, DailyTaskPlanner.progress( + quest(7, true, false, List.of(partial, timer))), 0.000001d); + } + + @Test + void handlesZeroGoalsCompletedRequirementsAndClamping() { + QuestAPI.Requirement incompleteZero = requirement("Visit target", "MAP", + QuestAPI.Requirement.RequirementType.MAP, 0, 0, false); + QuestAPI.Requirement completedZero = requirement("Visit target", "MAP", + QuestAPI.Requirement.RequirementType.MAP, 0, 0, true); + QuestAPI.Requirement overGoal = requirement("Destroy NPC", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 8, 5, false); + assertEquals(0d, DailyTaskPlanner.progress(quest(8, true, false, List.of(incompleteZero))), 0d); + assertEquals(1d, DailyTaskPlanner.progress(quest(9, true, true, List.of(completedZero))), 0d); + assertEquals(1d, DailyTaskPlanner.progress(quest(10, true, false, List.of(overGoal))), 0d); + } + + @Test + void buildsSequentialStepsFromRemainingObjectives() { + QuestAPI.Requirement first = requirement("Destroy Mordon", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 0, 1, false); + QuestAPI.Requirement second = requirement("Destroy Lordakium", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 0, 1, false); + DailyQuestConditionEngine.Plan plan = DailyQuestConditionEngine.build( + quest(11, true, false, List.of(first, second, dailyTimer()))); + + assertEquals(2, plan.steps().stream() + .filter(step -> step.type() == DailyQuestConditionEngine.StepType.NPC_COMBAT) + .count()); + assertTrue(plan.targetNpcDescription().contains("Mordon")); + + QuestAPI.Requirement completedFirst = requirement("Destroy Mordon", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 1, 1, true); + DailyQuestConditionEngine.Plan nextPlan = DailyQuestConditionEngine.build( + quest(12, true, false, List.of(completedFirst, second, dailyTimer()))); + assertTrue(nextPlan.targetNpcDescription().contains("Lordakium")); + } + + @Test + void classifiesPlayerCombatWithoutTreatingItAsNpcCombat() { + QuestAPI.Requirement pvp = requirement("Destroy enemy players", "KILL_PLAYERS", + QuestAPI.Requirement.RequirementType.KILL_PLAYERS, 0, 3, false); + QuestAPI.Quest quest = quest(13, true, false, List.of(pvp, dailyTimer())); + DailyQuestConditionEngine.Plan plan = DailyQuestConditionEngine.build(quest); + + assertTrue(DailyQuestConditionEngine.hasPlayerCombat(quest)); + assertFalse(DailyQuestConditionEngine.supports(quest)); + assertTrue(DailyQuestConditionEngine.supports(quest, true)); + assertTrue(plan.targetPlayerDescription().contains("players")); + assertNull(plan.targetNpcDescription()); + } + + @Test + void policyRejectsRiskyOrUnsupportedOffersBeforeAcceptance() { + DailyTaskConfig config = new DailyTaskConfig(); + QuestAPI.Requirement pvp = requirement("Destroy enemy players", "KILL_PLAYERS", + QuestAPI.Requirement.RequirementType.KILL_PLAYERS, 0, 3, false); + QuestAPI.Quest pvpOffer = quest(14, true, false, List.of(pvp, dailyTimer()), + List.of(reward("currency_uridium", 1500))); + assertEquals(DailyQuestPolicy.Decision.SKIP_PLAYER_COMBAT, + DailyQuestPolicy.evaluateOffer(pvpOffer, config)); + + config.acceptPlayerKillQuests = true; + assertEquals(DailyQuestPolicy.Decision.ACCEPT, + DailyQuestPolicy.evaluateOffer(pvpOffer, config)); + + QuestAPI.Requirement unsupported = requirement("Upgrade Skylab", "UPDATE_SKYLAB_TO_LEVEL", + QuestAPI.Requirement.RequirementType.UPDATE_SKYLAB_TO_LEVEL, 0, 1, false); + QuestAPI.Quest unsupportedOffer = quest(15, true, false, List.of(unsupported, dailyTimer()), + List.of(reward("currency_uridium", 1500))); + assertEquals(DailyQuestPolicy.Decision.SKIP_UNSUPPORTED, + DailyQuestPolicy.evaluateOffer(unsupportedOffer, config)); + } + + @Test + void policyHonorsRewardAndProtegitOptions() { + DailyTaskConfig config = new DailyTaskConfig(); + QuestAPI.Requirement npc = requirement("Destroy Protegit", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 0, 3, false); + QuestAPI.Quest offer = quest(16, true, false, List.of(npc, dailyTimer()), + List.of(reward("currency_uridium", 1500))); + assertEquals(DailyQuestPolicy.Decision.SKIP_PROTEGIT, + DailyQuestPolicy.evaluateOffer(offer, config)); + + config.skipProtegitQuests = false; + assertEquals(DailyQuestPolicy.Decision.ACCEPT, + DailyQuestPolicy.evaluateOffer(offer, config)); + + QuestAPI.Quest noUri = quest(17, true, false, List.of(npc, dailyTimer()), + List.of(reward("resource_tetrathrin", 10))); + assertEquals(DailyQuestPolicy.Decision.SKIP_TETRATHRIN_ONLY, + DailyQuestPolicy.evaluateOffer(noUri, config)); + config.skipTetrathrinOnly = false; + assertEquals(DailyQuestPolicy.Decision.SKIP_NO_URIDIUM, + DailyQuestPolicy.evaluateOffer(noUri, config)); + } + + @Test + void computesRemainingWorkForQuestPriority() { + QuestAPI.Requirement shortTask = requirement("Destroy Mordon", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 4, 5, false); + QuestAPI.Requirement longTask = requirement("Destroy Mordon", "KILL_NPC", + QuestAPI.Requirement.RequirementType.KILL_NPC, 1, 20, false); + assertTrue(DailyTaskPlanner.remainingWork( + quest(18, true, false, List.of(shortTask, dailyTimer()))) < + DailyTaskPlanner.remainingWork( + quest(19, true, false, List.of(longTask, dailyTimer())))); + assertEquals(590, new DailyTaskConfig().attackRadius); + assertEquals(4, new DailyTaskConfig().maxSelectionRetries); + } + + @Test + void resolvesCompanyMapsFromFactionBeforeCurrentMap() { + assertEquals("1-8", DailyTaskCompanyMap.questBase(EntityInfo.Faction.MMO, "5-3")); + assertEquals("2-1", DailyTaskCompanyMap.homeBase(EntityInfo.Faction.EIC, "1-7")); + assertEquals("3", DailyTaskCompanyMap.prefix(EntityInfo.Faction.VRU, null)); + assertEquals("2", DailyTaskCompanyMap.prefix(EntityInfo.Faction.NONE, "2-BL")); + assertNull(DailyTaskCompanyMap.questBase(EntityInfo.Faction.NONE, "4-4")); + } + + @Test + @SuppressWarnings("unchecked") + void resolvesNpcMapsFromDarkBotMetadataWithoutNpcLists() { + GameMap mmoMap = gameMap(101, "1-5"); + GameMap eicMap = gameMap(202, "2-5"); + NpcInfo npc = proxy(NpcInfo.class, (method, args) -> { + if (method.equals("getName")) return "-=[ Future NPC ]=-"; + if (method.equals("getMapIds")) return Set.of(101, 202); + return null; + }); + ConfigSetting> setting = proxy(ConfigSetting.class, + (method, args) -> method.equals("getValue") ? Map.of("future", npc) : null); + StarSystemAPI maps = proxy(StarSystemAPI.class, (method, args) -> { + if (method.equals("getCurrentMap")) return gameMap(404, "4-4"); + if (method.equals("isAccessible")) return true; + if (method.equals("findMap") && args[0] instanceof Integer) { + int id = (Integer) args[0]; + return Optional.ofNullable(id == 101 ? mmoMap : id == 202 ? eicMap : null); + } + return null; + }); + + DailyNpcMapResolver resolver = new DailyNpcMapResolver(setting, maps); + assertEquals("2-5", resolver.resolve("Destroy Future NPC", "2").orElseThrow()); + } + + private static QuestAPI.Requirement dailyTimer() { + return requirement("Complete within one day", "REAL_TIME_HASTE", + QuestAPI.Requirement.RequirementType.REAL_TIME_HASTE, 0, DAY_MS, false); + } + + private static QuestAPI.Reward reward(String type, int amount) { + return new QuestAPI.Reward() { + public int getAmount() { return amount; } + public String getType() { return type; } + }; + } + + private static QuestAPI.Requirement requirement(String description, String type, + QuestAPI.Requirement.RequirementType requirementType, + double progress, double goal, boolean completed) { + return requirement(description, type, requirementType, progress, goal, completed, List.of()); + } + + private static QuestAPI.Requirement requirement(String description, String type, + QuestAPI.Requirement.RequirementType requirementType, + double progress, double goal, boolean completed, + List children) { + return new QuestAPI.Requirement() { + public String getDescription() { return description; } + public double getProgress() { return progress; } + public double getGoal() { return goal; } + public boolean isCompleted() { return completed; } + public String getType() { return type; } + public List getRequirements() { return children; } + public boolean isEnabled() { return true; } + public QuestAPI.Requirement.RequirementType getRequirementType() { return requirementType; } + }; + } + + private static QuestAPI.Quest quest(int id, boolean active, boolean completed, + List requirements) { + return quest(id, active, completed, requirements, List.of()); + } + + private static GameMap gameMap(int id, String name) { + return proxy(GameMap.class, (method, args) -> { + if (method.equals("getId")) return id; + if (method.equals("getName") || method.equals("getShortName")) return name; + if (method.equals("isGG") || method.equals("isPvp")) return false; + return null; + }); + } + + @SuppressWarnings("unchecked") + private static T proxy(Class type, ProxyCall call) { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, + (instance, method, args) -> call.invoke(method.getName(), args)); + } + + @FunctionalInterface + private interface ProxyCall { + Object invoke(String method, Object[] args); + } + + private static QuestAPI.Quest quest(int id, boolean active, boolean completed, + List requirements, + List rewards) { + return new QuestAPI.Quest() { + public int getId() { return id; } + public boolean isActive() { return active; } + public String getTitle() { return "Quest " + id; } + public String getDescription() { return ""; } + public boolean isCompleted() { return completed; } + public List getRequirements() { return requirements; } + public List getRewards() { return rewards; } + }; + } +}