package org.mafagafogigante.dungeon.util; import org.mafagafogigante.dungeon.game.Name; import org.apache.commons.lang3.StringUtils; import org.apache.commons.math3.stat.descriptive.SummaryStatistics; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; /** * A collection of Selectable objects that listMatches a given query. */ public class Matches<T extends Selectable> { private final List<T> matches = new ArrayList<>(); private final boolean disjoint; private int differentNames = 0; private boolean differentNamesUpToDate = true; private Matches(boolean disjoint) { this.disjoint = disjoint; } /** * Converts a Collection to Matches. */ public static <T extends Selectable> Matches<T> fromCollection(Collection<T> collection) { return fromCollection(collection, true); } /** * Converts a Collection to possibly not disjoint Matches. */ private static <T extends Selectable> Matches<T> fromCollection(Collection<T> collection, boolean disjoint) { Matches<T> newInstance = new Matches<>(disjoint); for (T t : collection) { newInstance.addMatch(t); } return newInstance; } /** * Finds the best matches to the provided tokens among the {@code Selectable}s of a specified {@code Collection}. * * @param collection a {@code Collection} of {@code Selectable} objects * @param tokens the search Strings * @param <T> a type T that extends {@code Selectable} * @return a {@code Matches} object with zero or more elements of type T */ public static <T extends Selectable> Matches<T> findBestMatches(Collection<T> collection, String... tokens) { return findMatches(collection, tokens, false); } /** * Finds the best complete matches to the provided tokens among the {@code Selectable}s of a specified {@code * Collection}. A listMatches is considered complete if it has a word for each provided token. * * <p>This is the method that should be used to select objects of the class {@code Entity}, as, for instance, {@code * "Fruit Bat"} should never listMatches a {@code "Bat"}. * * @param <T> a type T that extends {@code Selectable} * @param elements a {@code Collection} of {@code Selectable} elements * @param tokens the search Strings * @return a {@code Matches} object with zero or more elements of type T */ public static <T extends Selectable> Matches<T> findBestCompleteMatches(Collection<T> elements, String... tokens) { return findMatches(elements, tokens, true); } private static double calculateSimilarity(String[] nameWords, String[] tokens, boolean full) { if (!full || nameWords.length >= tokens.length) { int matches = countMatches(tokens, nameWords); SummaryStatistics statistics = new SummaryStatistics(); statistics.addValue(matches / (double) nameWords.length); statistics.addValue(matches / (double) tokens.length); return statistics.getMean(); } else { return 0.0; } } private static double calculateSingularSimilarity(Name name, String[] tokens, boolean full) { return calculateSimilarity(StringUtils.split(name.getSingular()), tokens, full); } private static double calculatePluralSimilarity(Name name, String[] tokens, boolean full) { return calculateSimilarity(StringUtils.split(name.getPlural()), tokens, full); } private static MatchResult evaluateMatch(Name name, int frequency, String[] tokens, boolean full) { double singularSimilarity = calculateSingularSimilarity(name, tokens, full); if (frequency < 2) { return new MatchResult(singularSimilarity, false); } double pluralSimilarity = calculatePluralSimilarity(name, tokens, full); if (DungeonMath.fuzzyCompare(singularSimilarity, pluralSimilarity) >= 0) { // Disjoint matches are preferred. return new MatchResult(singularSimilarity, true); } else { return new MatchResult(pluralSimilarity, false); } } /** * Finds matches of {@code Selectable}s based on a given {@code Collection} of objects and an array of search tokens. * * @param <T> a type T that extends {@code Selectable} * @param elements a {@code Collection} of {@code Selectable} elements * @param tokens the search Strings * @param full if true, only elements that match all tokens are returned * @return a {@code Matches} object with zero or more elements of type T */ private static <T extends Selectable> Matches<T> findMatches(Collection<T> elements, String[] tokens, boolean full) { List<T> list = new ArrayList<>(); double maximumSimilarity = 0.0; boolean disjoint = true; CounterMap<Name> nameCounters = new CounterMap<>(); for (T element : elements) { nameCounters.incrementCounter(element.getName()); } for (T element : elements) { Name name = element.getName(); MatchResult result = evaluateMatch(name, nameCounters.getCounter(name), tokens, full); if (DungeonMath.fuzzyCompare(result.similarity, 0.0) > 0) { boolean isBetter = DungeonMath.fuzzyCompare(result.similarity, maximumSimilarity) > 0; boolean isAsGood = DungeonMath.fuzzyCompare(result.similarity, maximumSimilarity) == 0; if (isBetter) { maximumSimilarity = result.similarity; disjoint = result.disjoint; list.clear(); list.add(element); } else if (isAsGood) { if (disjoint == result.disjoint) { list.add(element); } else if (result.disjoint) { // Disjoint matches are preferred. // Discard the current non-disjoint matches in favor of the disjoint ones. disjoint = true; list.clear(); list.add(element); } } } } return fromCollection(list, disjoint); } /** * Counts how many Strings in the entry array start with the Strings of the query array. */ private static int countMatches(String[] query, String[] entry) { int matches = 0; int indexOfLastMatchPlusOne = 0; for (int i = 0; i < query.length && indexOfLastMatchPlusOne < entry.length; i++) { for (int j = indexOfLastMatchPlusOne; j < entry.length; j++) { if (Utils.startsWithIgnoreCase(entry[j], query[i])) { indexOfLastMatchPlusOne = j + 1; matches++; } } } return matches; } /** * Returns whether or not the Matches are disjoint. * * <p>If they are, any element constitutes a listMatches, otherwise, only all elements together constitute a * listMatches. */ public boolean isDisjoint() { return disjoint; } private void addMatch(T match) { matches.add(match); differentNamesUpToDate = false; } public T getMatch(int index) { return matches.get(index); } public List<T> toList() { return new ArrayList<>(matches); } /** * Returns true if there is a listMatches with the given name, false otherwise. * * @param name the name used for comparison * @return true if there is a listMatches with the given name, false otherwise */ public boolean hasMatchWithName(Name name) { for (T match : matches) { if (match.getName().equals(name)) { return true; } } return false; } /** * Returns the number of matches. */ public int size() { return matches.size(); } /** * Returns how many different names the matches have. For instance, if the matches consist of two Entity objects with * identical names, this method will return 1. * * <p>This method will calculate how many different names are in the list of matches or use the last calculated value, * if the matches list did not change since the last calculation. Therefore, after adding all matches and calling this * method once, subsequent method calls should be substantially faster. */ public int getDifferentNames() { if (!differentNamesUpToDate) { updateDifferentNamesCount(); } return differentNames; } /** * Updates the differentNames variable after iterating over the list of matches. */ private void updateDifferentNamesCount() { HashSet<Name> uniqueNames = new HashSet<>(); for (T match : matches) { uniqueNames.add(match.getName()); } differentNames = uniqueNames.size(); differentNamesUpToDate = true; } private static class MatchResult { private final double similarity; private final boolean disjoint; private MatchResult(double similarity, boolean disjoint) { this.similarity = similarity; this.disjoint = disjoint; } } }