diff --git a/src/main/java/dev/ebullient/convert/tools/JsonTextConverter.java b/src/main/java/dev/ebullient/convert/tools/JsonTextConverter.java index dc96e02c9..6583c9cba 100644 --- a/src/main/java/dev/ebullient/convert/tools/JsonTextConverter.java +++ b/src/main/java/dev/ebullient/convert/tools/JsonTextConverter.java @@ -58,6 +58,33 @@ default JsonNode createNode(String source) { } } + default boolean isArrayNode(JsonNode node) { + return node != null && node.isArray(); + } + + default boolean isObjectNode(JsonNode node) { + return node != null && node.isObject(); + } + + default JsonNode objectIntersect(JsonNode a, JsonNode b) { + if (a.equals(b)) { + return a; + } + ObjectNode x = Tui.MAPPER.createObjectNode(); + for (String k : iterableFieldNames(a)) { + if (a.get(k).equals(b.get(k))) { + x.set(k, a.get(k)); + } else if (isObjectNode(a.get(k)) && isObjectNode(b.get(k))) { + x.set(k, objectIntersect(a.get(k), b.get(k))); + } + } + return x; + } + + default boolean isPresent(String s) { + return s != null && !s.isBlank(); + } + default String formatDice(String diceRoll) { // needs to be escaped: \\ to escape the \\ so it is preserved in the output String avg = parseState().inMarkdownTable() ? "\\\\|avg" : "|avg"; @@ -134,6 +161,9 @@ default Iterable iterableElements(JsonNode source) { if (source == null) { return List.of(); } + if (!source.isArray()) { + return List.of(source); + } return source::elements; } @@ -170,6 +200,10 @@ default String joinConjunct(String lastJoiner, List list) { return joinConjunct(list, ", ", lastJoiner, false); } + default String joinConjunct(String joiner, String lastJoiner, List list) { + return joinConjunct(list, joiner, lastJoiner, false); + } + default String joinConjunct(List list, String joiner, String lastJoiner, boolean nonOxford) { if (list == null || list.isEmpty()) { return ""; diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/Json2QuteCommon.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/Json2QuteCommon.java index 88b1b4828..9b875291f 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/Json2QuteCommon.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/Json2QuteCommon.java @@ -5,6 +5,7 @@ import java.text.Normalizer.Form; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -12,9 +13,11 @@ import java.util.Map.Entry; import java.util.Optional; import java.util.regex.Pattern; +import java.util.stream.StreamSupport; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import dev.ebullient.convert.io.Tui; import dev.ebullient.convert.qute.ImageRef; @@ -31,6 +34,8 @@ public class Json2QuteCommon implements JsonSource { static final Pattern featPattern = Pattern.compile("([^|]+)\\|?.*"); static final List SPEED_MODE = List.of("walk", "burrow", "climb", "fly", "swim"); static final List specialTraits = List.of("special equipment", "shapechanger"); + static final Comparator>> compareNumberStrings = Comparator + .comparingInt(e -> Integer.parseInt(e.getKey())); protected final Tools5eIndex index; protected final Tools5eSources sources; @@ -151,74 +156,385 @@ public void getImages(JsonNode imageNode, List images) { } } - String listPrerequisites(JsonNode variantNode) { - List prereqs = new ArrayList<>(); - Tools5eIndex index = index(); - for (JsonNode entry : iterableElements(PrereqFields.prerequisite.getFrom(variantNode))) { - if (PrereqFields.level.existsIn(entry)) { - prereqs.add(levelToText(entry.get("level"))); + // {"ability":[{"dex":13}]} + private String abilityPrereq(JsonNode abilityPrereq) { + ArrayNode elements = ensureArray(abilityPrereq); + + boolean multipleInner = false; + boolean multiMultipleInner = false; + JsonNode allValuesEqual = null; + + // See if all of the abilities have the same value + outer: for (JsonNode abMetaNode : elements) { + ObjectNode objectNode = (ObjectNode) abMetaNode; + + for (JsonNode valueNode : objectNode) { + if (allValuesEqual == null) { + allValuesEqual = valueNode; + } else { + var ave = allValuesEqual; + boolean allMatch = StreamSupport.stream(objectNode.spliterator(), false) + .allMatch(node -> node.equals(ave)); + if (!allMatch) { + allValuesEqual = null; + break outer; + } + } } + } - for (JsonNode r : iterableElements(PrereqFields.race.getFrom(entry))) { - prereqs.add(index.linkifyByName(Tools5eIndexType.race, raceToText(r))); + List abilityOptions = new ArrayList<>(); + for (JsonNode abMetaNode : elements) { + if (allValuesEqual != null) { + List options = new ArrayList<>(); + multipleInner |= abMetaNode.size() > 1; + abMetaNode.fieldNames().forEachRemaining(x -> { + options.add(SkillOrAbility.format(x, index(), getSources())); + }); + abilityOptions.add(joinConjunct(" and ", options)); + } else { + Map> groups = new HashMap<>(); + for (Entry score : iterableFields(abMetaNode)) { + groups.computeIfAbsent(score.getValue().asText(), k -> new ArrayList<>()) + .add(SkillOrAbility.format(score.getKey(), index(), sources)); + } + + boolean isMulti = groups.values().stream().anyMatch(x -> x.size() > 1); + ; + multiMultipleInner |= isMulti; + multipleInner |= isMulti; + + List byScore = groups.entrySet().stream() + .sorted((a, b) -> compareNumberStrings.compare(b, a)) + .map(e -> { + List abs = e.getValue().stream() + .map(x -> index().findSkillOrAbility(x, sources)) + .sorted(SkillOrAbility.comparator) + .map(x -> x.value()) + .toList(); + return String.format("%s %s or higher", + joinConjunct(" and ", abs), + e.getKey()); + }) + .toList(); + + abilityOptions.add(isMulti + ? joinConjunct("; ", " and ", byScore) + : joinConjunct(" and ", byScore)); } + } - Map> abilityScores = new HashMap<>(); + var isComplex = multipleInner || multiMultipleInner || allValuesEqual == null; + String joined = joinConjunct( + multiMultipleInner ? " - " : multipleInner ? "; " : ", ", + isComplex ? " OR " : " or ", + abilityOptions); - for (JsonNode a : iterableElements(PrereqFields.ability.getFrom(entry))) { - for (Entry score : iterableFields(a)) { - abilityScores.computeIfAbsent(score.getValue().asText(), k -> new ArrayList<>()) - .add(SkillOrAbility.format(score.getKey(), index(), getSources())); - } + return joined + (allValuesEqual != null + ? " " + allValuesEqual.asText() + " or higher" + : ""); + } + + // {"name":"Rune Carver","displayEntry":"{@background Rune Carver|BGG}"}] + private String backgroundPrereq(JsonNode backgroundPrereq) { + List backgrounds = new ArrayList<>(); + for (JsonNode p : iterableElements(backgroundPrereq)) { + JsonNode displayEntry = PrereqFields.displayEntry.getFrom(p); + if (displayEntry != null) { + backgrounds.add(replaceText(displayEntry.asText())); + } else { + String name = SourceField.name.getTextOrEmpty(p); + backgrounds.add(index.linkifyByName(Tools5eIndexType.background, name)); } + } + return joinConjunct(" or ", backgrounds); + } - abilityScores.forEach( - (k, v) -> prereqs.add(String.format("%s %s or higher", String.join(" or ", v), k))); + private String campaignPrereq(JsonNode campaignPrereq) { + List cmpn = new ArrayList<>(); + for (JsonNode p : iterableElements(campaignPrereq)) { + replaceText(p.asText()); + } + return joinConjunct(" or ", cmpn); + } - if (PrereqFields.spellcasting.existsIn(entry) && PrereqFields.spellcasting.booleanOrDefault(entry, false)) { - prereqs.add("The ability to cast at least one spell"); + // "scion of the outer planes|ua2022wondersofthemultiverse|scion of the outer planes (good outer plane)" + // "scion of the outer planes|sato|scion of the outer planes (good outer plane)" + private String featPrereq(JsonNode featPrereq) { + List feats = new ArrayList<>(); + for (JsonNode p : iterableElements(featPrereq)) { + replaceText(String.format("{@feat %s} feat", p.asText())); + } + return joinConjunct(" or ", feats); + } + + private String featurePrereq(JsonNode featurePrereq) { + List features = new ArrayList<>(); + for (JsonNode p : iterableElements(featurePrereq)) { + replaceText(p.asText()); + } + return joinConjunct(" or ", features); + } + + private String groupPrereq(JsonNode groupPrereq) { + List grp = new ArrayList<>(); + for (JsonNode p : iterableElements(groupPrereq)) { + replaceText(toTitleCase(p.asText())); + } + return joinConjunct(" or ", grp); + } + + private String itemPrereq(JsonNode itemPrereq) { + List items = new ArrayList<>(); + for (JsonNode p : iterableElements(itemPrereq)) { + replaceText(p.asText()); + } + return joinConjunct(" or ", items); + } + + private String itemTypePrereq(JsonNode itemTypePrereq) { + List types = new ArrayList<>(); + for (JsonNode p : iterableElements(itemTypePrereq)) { + ItemType type = index.findItemType(p.asText(), getSources()); + if (type != null) { + types.add(type.getSpecializedType()); + } else { + tui().errorf("Unknown item type %s", p); + } + } + return joinConjunct(" and ", types); + } + + private String itemPropertyPrereq(JsonNode itemPropertyPrereq) { + List props = new ArrayList<>(); + for (JsonNode p : iterableElements(itemPropertyPrereq)) { + ItemProperty prop = index.findItemProperty(p.asText(), getSources()); + if (prop != null) { + props.add(prop.getMarkdownLink(index)); + } else { + tui().errorf("Unknown item property %s", p); } - if (PrereqFields.pact.existsIn(entry)) { - prereqs.add("Pact of the " + PrereqFields.pact.replaceTextFrom(entry, this)); + } + return joinConjunct(" and ", props); + } + + // "level":4 + // "level":{"level":1,"class":{"name":"Fighter","visible":true}}} + private String levelPrereq(JsonNode levelPrereq) { + if (levelPrereq.isArray()) + tui().error("levelPrereq: Array parameter"); + + if (levelPrereq.isNumber()) { + return levelToText(levelPrereq.asText()); + } + + String level = Tools5eFields.level.getTextOrThrow(levelPrereq); + JsonNode classNode = SourceField._class_.getFrom(levelPrereq); + JsonNode subclassNode = Tools5eFields.subclass.getFrom(levelPrereq); + + // neither class nor subclass is defined + if (classNode == null && subclassNode == null) { + return levelToText(level); + } + + boolean isLevelVisible = !"1".equals(level); // hide implied first level + boolean isSubclassVisible = Tools5eFields.visible.booleanOrDefault(subclassNode, false); + boolean isClassVisible = classNode != null + && (isSubclassVisible || Tools5eFields.visible.booleanOrDefault(classNode, false)); + + String classPart = ""; + if (isClassVisible && isSubclassVisible) { + classPart = String.format("%s (%s)", + SourceField.name.getTextOrEmpty(classNode), + SourceField.name.getTextOrEmpty(subclassNode)); + } else if (isClassVisible) { + classPart = SourceField.name.getTextOrEmpty(classNode); + } else if (isSubclassVisible) { + tui().warnf("Subclass %s without class in %s", subclassNode, levelPrereq); + } + + return String.format("%s%s", + isLevelVisible ? levelToText(level) : "", + isClassVisible ? " " + classPart : ""); + } + + // {"proficiency":[{"armor":"medium"}]} + // {"proficiency":[{"weaponGroup":"martial"}]} + private String proficiencyPrereq(JsonNode profPrereq) { + List profs = new ArrayList<>(); + for (JsonNode p : iterableElements(profPrereq)) { + for (Entry prof : iterableFields(p)) { + switch (prof.getKey()) { + case "armor" -> profs.add(String.format("%s armor", + replaceText(prof.getValue().asText()))); + case "weapon" -> profs.add(String.format("a %s weapon", + replaceText(prof.getValue().asText()))); + case "weaponGroup" -> profs.add(String.format("%s weapons", + replaceText(prof.getValue().asText()))); + default -> { + tui().errorf("Unknown proficiency prereq", p); + } + } } - if (PrereqFields.patron.existsIn(entry)) { - prereqs.add(PrereqFields.patron.replaceTextFrom(entry, this) + " Patron"); + } + return String.format("Proficiency with %s", joinConjunct(" or ", profs)); + } + + // [{"name":"elf"}] + // [{"name":"half-elf"}] + // [{"name":"small race","displayEntry":"a Small race"}] + private String racePrereq(JsonNode racePrereq) { + List races = new ArrayList<>(); + for (JsonNode p : iterableElements(racePrereq)) { + JsonNode displayEntry = PrereqFields.displayEntry.getFrom(p); + if (displayEntry != null) { + races.add(replaceText(displayEntry.asText())); + } else { + String name = SourceField.name.getTextOrEmpty(p); + String subraceName = Tools5eFields.subrace.getTextOrNull(p); + races.add(index.linkifyByName(Tools5eIndexType.race, Json2QuteRace.getSubraceName(name, subraceName))); } - PrereqFields.spell.streamFrom(entry).forEach(s -> { - String text = s.asText().replaceAll("#c", ""); - prereqs.add(index.linkifyByName(Tools5eIndexType.spell, text)); - }); - PrereqFields.feat.streamFrom(entry).forEach(f -> prereqs - .add(featPattern.matcher(f.asText()) - .replaceAll(m -> index.linkifyByName(Tools5eIndexType.feat, m.group(1))))); - PrereqFields.feature.streamFrom(entry).forEach(f -> prereqs.add(featPattern.matcher(f.asText()) - .replaceAll(m -> index.linkifyByName(Tools5eIndexType.optionalfeature, m.group(1))))); - PrereqFields.background.streamFrom(entry).forEach(b -> prereqs - .add(index.linkifyByName(Tools5eIndexType.background, SourceField.name.getTextOrEmpty(b)) + " background")); - PrereqFields.item.streamFrom(entry) - .forEach(i -> prereqs.add(index.linkifyByName(Tools5eIndexType.item, i.asText()))); - - if (PrereqFields.psionics.existsIn(entry)) { - prereqs.add("Psionics"); + } + return joinConjunct(" or ", races); + } + + private List testBoolean(JsonNode node, String valueIfTrue) { + return node.booleanValue() + ? List.of(valueIfTrue) + : List.of(); + } + + private String spellPrereq(JsonNode spellPrereq) { + List spells = new ArrayList<>(); + for (JsonNode p : iterableElements(spellPrereq)) { + if (p.isTextual()) { + String[] split = p.asText().split("#"); + if (split.length == 1) { + spells.add(replaceText(String.format("{@spell %s}", split[0]))); + } else if ("c".equals(split[1])) { + spells.add(replaceText(String.format("{@spell %s} cantrip", split[0]))); + } else if ("x".equals(split[1])) { + spells.add(replaceText(String.format("{@spell hex} spell or a warlock feature that curses", split[0]))); + } else { + tui().errorf("Unknown spell prereq %s", p); + } + } else { + spells.add(replaceText(String.format("{@filter %s|spells|%s}", + SourceField.entry.getTextOrEmpty(p), + PrereqFields.choose.getTextOrEmpty(p)))); } + } + return joinConjunct(" or ", spells); + } + + private ObjectNode sharedPrerequisites(ArrayNode prerequisites) { + ObjectNode shared = prerequisites.objectNode(); + + if (prerequisites.size() > 1) { + List others = streamOf(prerequisites).collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + // slice(1) + JsonNode first = others.get(0); + others.remove(0); - List profs = new ArrayList<>(); - PrereqFields.proficiency.streamFrom(entry).forEach(f -> f.fields().forEachRemaining(field -> { - String key = field.getKey(); - if ("weapon".equals(key)) { - key += "s"; + others.stream() + .reduce(first, + (a, b) -> objectIntersect(a, b), + (a, b) -> ((ObjectNode) a).setAll((ObjectNode) b)); + } + return shared; + } + + String listPrerequisites(JsonNode variantNode) { + List allValues = new ArrayList<>(); + boolean hasNote = false; + + ArrayNode prerequisites = PrereqFields.prerequisite.readArrayFrom(variantNode); + + // find shared/common prereqs + ObjectNode prereqsShared = sharedPrerequisites(prerequisites); + String sharedText = prereqsShared.size() > 0 + ? listPrerequisites(prereqsShared) + : null; + + for (JsonNode prerequisite : prerequisites) { + List values = new ArrayList<>(); + String note = null; + + List fields = streamOfFieldNames(prerequisite) + .map(x -> { + PrereqFields field = fromString(x); + if (field == PrereqFields.unknown) { + tui().errorf("Unknown prerequisite %s from %s", x, prerequisite); + } + return field; + }) + .sorted() + .toList(); + + for (PrereqFields field : fields) { + if (prereqsShared.has(field.nodeName())) { + continue; + } + JsonNode value = field.getFrom(prerequisite); + + // TODO: blocklist? + switch (field) { + case ability -> values.add(abilityPrereq(value)); + case alignment -> values.add(alignmentListToFull(value)); + case background -> values.add(backgroundPrereq(value)); + case campaign -> values.add(campaignPrereq(value)); + case feat -> values.add(featPrereq(value)); + case feature -> values.add(featurePrereq(value)); + case group -> values.add(groupPrereq(value)); + case item -> values.add(itemPrereq(value)); + case itemProperty -> values.add(itemPropertyPrereq(value)); + case itemType -> values.add(itemTypePrereq(value)); + case level -> values.add(levelPrereq(value)); + case other -> values.add(replaceText(value)); + case otherSummary -> values.add(SourceField.entry.replaceTextFrom(value, this)); + case pact -> values.add("Pact of the " + replaceText(value)); + case patron -> values.add(replaceText(value + " Patron")); + case proficiency -> values.add(proficiencyPrereq(value)); + case race -> values.add(racePrereq(value)); + case spell -> values.add(spellPrereq(value)); + // --- Boolean values ---- + case psionics -> values.addAll(testBoolean(value, + replaceText("Psionic Talent feature or {@feat Wild Talent|UA2020PsionicOptionsRevisited} feat"))); + case spellcasting -> values.addAll(testBoolean(value, + "The ability to cast at least one spell")); + case spellcasting2020 -> values.addAll(testBoolean(value, + "Spellcasting or Pact Magic feature")); + case spellcastingFeature -> values.addAll(testBoolean(value, + "Spellcasting feature")); + case spellcastingPrepared -> values.addAll(testBoolean(value, + "Spellcasting feature from a class that prepares spells")); + // --- Other: Note ---- + case note -> note = replaceText(value); + default -> { + tui().errorf("Unknown prerequisite %s from %s", field.nodeName(), prerequisite); + } } - profs.add(String.format("%s %s", field.getValue().asText(), key)); - })); - if (!profs.isEmpty()) { - prereqs.add(String.format("Proficiency with %s", String.join(" or ", profs))); } - if (PrereqFields.other.existsIn(entry)) { - prereqs.add(PrereqFields.other.replaceTextFrom(entry, this)); - } + // remove empty values + values = values.stream().filter(x -> isPresent(x)).toList(); + + hasNote |= isPresent(note); + String prereqs = String.join( + values.stream().anyMatch(x -> x.contains(" or ")) ? "; " : ", ", + values); + allValues.add(prereqs + (isPresent(note) ? ". " + note : "")); } - return prereqs.isEmpty() ? null : String.join(", ", prereqs); + + String joinedText = hasNote + ? String.join(" Or, ", allValues) + : joinConjunct(allValues.stream().anyMatch(x -> x.contains(" or ")) ? "; " : ", ", + " or ", allValues); + + return sharedText == null + ? joinedText + : sharedText + ", plus " + joinedText; + } ImmuneResist immuneResist() { @@ -598,21 +914,44 @@ enum VulnerabilityFields implements JsonNodeReader { vulnerable, } + // weighted (order matters) enum PrereqFields implements JsonNodeReader { - ability, - background, - feat, - feature, - item, - level, - other, - pact, - patron, - prerequisite, - proficiency, - psionics, - race, - spell, - spellcasting, + /* 1 */ level, + /* 2 */ pact, + /* 3 */ patron, + /* 4 */ spell, + /* 5 */ race, + /* 6 */ alignment, + /* 7 */ ability, + /* 8 */ proficiency, + /* 9 */ spellcasting, + /* 10 */ spellcasting2020, + /* 11 */ spellcastingFeature, + /* 12 */ spellcastingPrepared, + /* 13 */ psionics, + /* 14 */ feature, + /* 15 */ feat, + /* 16 */ background, + /* 17 */ item, + /* 18 */ itemType, + /* 19 */ itemProperty, + /* 20 */ campaign, + /* 21 */ group, + /* 22 */ other, + /* 23 */ otherSummary, + choose, // inner field for spells + displayEntry, // inner field for display + note, // field alongside other fields + prerequisite, // prereq field itself + unknown // catcher for unknown attributes (see #fromString()) + } + + static PrereqFields fromString(String name) { + for (PrereqFields f : PrereqFields.values()) { + if (f.name().equals(name)) { + return f; + } + } + return PrereqFields.unknown; } } diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/Json2QuteMonster.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/Json2QuteMonster.java index 6f1f0bcc0..b522ca164 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/Json2QuteMonster.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/Json2QuteMonster.java @@ -295,11 +295,6 @@ String monsterAlignment() { } } - String toAlignmentCharacters(String src) { - return src.replaceAll("\"[A-Z]*[a-z ]+\"", "") // remove notes - .replaceAll("[^LCNEGAUXY]", ""); // keep only alignment characters - } - List monsterSpellcasting() { JsonNode array = rootNode.get("spellcasting"); if (array == null || array.isNull()) { diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonSource.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonSource.java index e10b582a2..0a82783f8 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonSource.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonSource.java @@ -510,7 +510,7 @@ default void appendStatblock(List text, JsonNode entry, String heading) String tagPropText = Tools5eFields.tag.getTextOrDefault(entry, Tools5eFields.prop.getTextOrEmpty(entry)); Tools5eIndexType type = Tools5eIndexType.fromText(tagPropText); if (type == null) { - tui().errorf("Unrecognized statblock type in %s", entry); + tui().warnf("🚧 Unrecognized statblock type in %s", entry); return; } embedReference(text, entry, type, heading); @@ -520,7 +520,7 @@ default void appendStatblockInline(List text, JsonNode entry, String hea // For inline statblocks, we start with the dataType Tools5eIndexType type = Tools5eIndexType.fromText(Tools5eFields.dataType.getTextOrEmpty(entry)); if (type == null) { - tui().errorf("Unrecognized statblock dataType in %s", entry); + tui().warnf("🚧 Unrecognized statblock dataType in %s", entry); return; } JsonNode data = Tools5eFields.data.getFrom(entry); @@ -615,7 +615,7 @@ default void embedReference(List text, JsonNode entry, Tools5eIndexType if (type == Tools5eIndexType.charoption) { // charoption is a special case, it is not a linkable type. - tui().warnf("charoption is not yet an embeddable type: %s", entry); + tui().warnf("🚧 charoption is not yet an embeddable type: %s", entry); return; } @@ -827,8 +827,66 @@ default String asAbilityEnum(JsonNode textNode) { return SkillOrAbility.format(textNode.asText(), index(), getSources()); } + default String toAlignmentCharacters(String src) { + return src.replaceAll("\"[A-Z]*[a-z ]+\"", "") // remove notes + .replaceAll("[^LCNEGAUXY]", ""); // keep only alignment characters + } + + default String alignmentListToFull(JsonNode alignmentList) { + if (alignmentList == null) { + return ""; + } + boolean allText = streamOf(alignmentList).allMatch(JsonNode::isTextual); + boolean allObject = streamOf(alignmentList).allMatch(JsonNode::isObject); + + if (allText) { + return mapAlignmentToString(toAlignmentCharacters(alignmentList.toString())); + } else if (allObject) { + return streamOf(alignmentList) + .filter(x -> AlignmentFields.alignment.existsIn(x)) + .map(x -> { + if (AlignmentFields.special.existsIn(x) + || AlignmentFields.chance.existsIn(x) + || AlignmentFields.note.existsIn(x)) { + return alignmentObjToFull(x); + } else { + return alignmentListToFull(x.get("alignment")); + } + }) + .collect(Collectors.joining(" or ")); + + } else { + tui().errorf("Unable to parse alignment list from %s", alignmentList); + } + return ""; + } + + default String alignmentObjToFull(JsonNode alignmentNode) { + if (alignmentNode == null) { + return null; + } + if (alignmentNode.isObject()) { + if (AlignmentFields.special.existsIn(alignmentNode)) { + return AlignmentFields.special.replaceTextFrom(alignmentNode, index()); + } else { + String chance = ""; + String note = ""; + if (AlignmentFields.chance.existsIn(alignmentNode)) { + chance = String.format(" (%s%%)", AlignmentFields.chance.getFrom(alignmentNode)); + } + if (AlignmentFields.note.existsIn(alignmentNode)) { + note = " (" + AlignmentFields.note.replaceTextFrom(alignmentNode, index()) + ")"; + } + return String.format("%s%s%s", + alignmentObjToFull(AlignmentFields.alignment.getFrom(alignmentNode)), + chance, note); + } + } + return mapAlignmentToString(alignmentNode.asText().toUpperCase()); + } + default String mapAlignmentToString(String a) { - return switch (a) { + return switch (a.toUpperCase()) { case "A" -> "Any alignment"; case "C" -> "Chaotic"; case "CE" -> "Chaotic Evil"; @@ -846,7 +904,7 @@ default String mapAlignmentToString(String a) { case "LNCE" -> "Lawful Neutral or Chaotic Evil"; case "LELG" -> "Lawful Evil or Lawful Good"; case "LELN", "LNLE" -> "Lawful Evil or Lawful Neutral"; - case "N", "NNXNYN" -> "Neutral"; + case "N", "NXNY", "NNXNYN" -> "Neutral"; case "NX" -> "Neutral (law/chaos axis)"; case "NY" -> "Neutral (good/evil axis)"; case "NE" -> "Neutral Evil"; @@ -858,7 +916,7 @@ default String mapAlignmentToString(String a) { case "LGNYE" -> "Any Non-Chaotic alignment"; case "LNXCNYE" -> "Any Non-Good alignment"; case "NXCGNYE" -> "Any Non-Lawful alignment"; - case "LNYNXCG" -> "Any Non-Evil alignment"; + case "LNXCNYG", "LNYNXCG" -> "Any Non-Evil alignment"; case "U" -> "Unaligned"; default -> { tui().errorf("What alignment is this? %s (from %s)", a, getSources()); @@ -962,38 +1020,6 @@ default String sizeToString(String size) { }; } - default String raceToText(JsonNode race) { - StringBuilder str = new StringBuilder(); - str.append(SourceField.name.getTextOrEmpty(race)); - if (Tools5eFields.subrace.existsIn(race)) { - str.append(" (").append(Tools5eFields.subrace.getTextOrEmpty(race)).append(")"); - } - return str.toString(); - } - - default String levelToText(JsonNode levelNode) { - if (levelNode.isObject()) { - List levelText = new ArrayList<>(); - levelText.add(levelToText(Tools5eFields.level.getFrom(levelNode).asText())); - if (levelNode.has("class") || levelNode.has("subclass")) { - JsonNode classNode = SourceField._class_.getFrom(levelNode); - if (classNode == null) { - classNode = Tools5eFields.subclass.getFrom(levelNode); - } - boolean visible = !Tools5eFields.visible.existsIn(classNode) - || Tools5eFields.visible.booleanOrDefault(classNode, false); - JsonNode source = SourceField.source.getFrom(classNode); - boolean included = source == null || index().sourceIncluded(source.asText()); - if (visible && included) { - levelText.add(SourceField.name.getTextOrEmpty(classNode)); - } - } - return String.join(" ", levelText); - } else { - return levelToText(levelNode.asText()); - } - } - default String levelToText(String level) { return switch (level) { case "0" -> "cantrip"; @@ -1239,6 +1265,13 @@ static String getFirstRow(JsonNode tableNode) { } } + enum AlignmentFields implements JsonNodeReader { + alignment, + chance, + note, + special + } + enum AttackFields implements JsonNodeReader { attackType, attackEntries, diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java index 2fa2694a3..0fe66b27a 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java @@ -54,10 +54,6 @@ default CompendiumConfig cfg() { return index().cfg(); } - default boolean isPresent(String s) { - return s != null && !s.isBlank(); - } - default List findAndReplace(JsonNode jsonSource, String field) { return findAndReplace(jsonSource, field, s -> s); } diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java index cf7543477..907bbc200 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java @@ -847,7 +847,10 @@ public List originNodesMatching(Function filter) { public JsonNode getOrigin(String finalKey) { JsonNode result = nodeIndex.get(finalKey); if (result == null) { - List target = nodeIndex.keySet().stream() + result = variantIndex.get(finalKey); + } + if (result == null) { + List target = variantIndex.keySet().stream() .filter(k -> k.startsWith(finalKey)) .collect(Collectors.toList()); if (target.size() == 1) { @@ -875,14 +878,15 @@ public JsonNode getOrigin(String finalKey) { public JsonNode getOrigin(Tools5eIndexType type, String name, String source) { String key = type.createKey(name, source); - return nodeIndex.get(key); + return getOrigin(key); } public JsonNode getOrigin(Tools5eIndexType type, JsonNode x) { if (x == null) { return null; } - return nodeIndex.get(type.createKey(x)); + String key = type.createKey(x); + return getOrigin(key); } public String linkifyByName(Tools5eIndexType type, String name) { @@ -917,7 +921,7 @@ public String linkifyByName(Tools5eIndexType type, String name) { String key = getAliasOrDefault(target.get(0)); JsonNode node = filteredIndex.get(key); // only included items - return type.linkify(this, node); + return node == null ? name : type.linkify(this, node); }); }