diff --git a/.editorconfig b/.editorconfig index 074c00dd9..0906ef832 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,9 +12,9 @@ insert_final_newline = true indent_style = space indent_size = 2 -[*.md] +[*.{md,txt}] indent_size = 4 trim_trailing_whitespace = false [*.{java,xml,xslt}] -indent_size = 4 +indent_size = 4 \ No newline at end of file diff --git a/src/main/java/dev/ebullient/convert/qute/TtrpgTemplateExtension.java b/src/main/java/dev/ebullient/convert/qute/TtrpgTemplateExtension.java new file mode 100644 index 000000000..abda426ac --- /dev/null +++ b/src/main/java/dev/ebullient/convert/qute/TtrpgTemplateExtension.java @@ -0,0 +1,16 @@ +package dev.ebullient.convert.qute; + +import io.quarkus.qute.TemplateExtension; + +@TemplateExtension +public class TtrpgTemplateExtension { + /** Return the value formatted with a bonus with a +/- prefix */ + static String asBonus(Integer value) { + return String.format("%+d", value); + } + + /** Return the string capitalized */ + static String capitalized(String s) { + return s.substring(0, 1).toUpperCase() + s.substring(1); + } +} diff --git a/src/main/java/dev/ebullient/convert/tools/JsonNodeReader.java b/src/main/java/dev/ebullient/convert/tools/JsonNodeReader.java index 0a297c624..f57c25c19 100644 --- a/src/main/java/dev/ebullient/convert/tools/JsonNodeReader.java +++ b/src/main/java/dev/ebullient/convert/tools/JsonNodeReader.java @@ -1,6 +1,7 @@ package dev.ebullient.convert.tools; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -113,6 +114,14 @@ default JsonNode getFrom(JsonNode source) { return source.get(this.nodeName()); } + /** + * Return an optional of the object at this key in the node, or an empty optional if the key does not exist or is not an + * object. + */ + default Optional getObjectFrom(JsonNode source) { + return this.isObjectIn(source) ? Optional.of(this.getFrom(source)) : Optional.empty(); + } + default JsonNode getFromOrEmptyObjectNode(JsonNode source) { if (source == null) { return Tui.MAPPER.createObjectNode(); @@ -203,6 +212,13 @@ default String getTextOrThrow(JsonNode x) { return text; } + default Optional getTextFrom(JsonNode x) { + if (x != null && existsIn(x) && getFrom(x).isTextual()) { + return Optional.of(getFrom(x).asText()); + } + return Optional.empty(); + } + default int intOrDefault(JsonNode source, int value) { JsonNode result = getFrom(source); return result == null || result.isNull() ? value : result.asInt(); @@ -256,6 +272,15 @@ default Stream streamFrom(JsonNode source) { return StreamSupport.stream(result.spliterator(), false); } + default Stream> streamPropsExcluding(JsonNode source, JsonNodeReader... excludingKeys) { + JsonNode result = getFrom(source); + if (result == null || !result.isObject()) { + return Stream.of(); + } + return result.properties().stream() + .filter(e -> Arrays.stream(excludingKeys).noneMatch(s -> e.getKey().equalsIgnoreCase(s.name()))); + } + default String transformTextFrom(JsonNode source, String join, JsonTextConverter replacer) { JsonNode target = getFrom(source); if (target == null) { diff --git a/src/main/java/dev/ebullient/convert/tools/JsonTextConverter.java b/src/main/java/dev/ebullient/convert/tools/JsonTextConverter.java index 5b0ebdc39..43f2a988c 100644 --- a/src/main/java/dev/ebullient/convert/tools/JsonTextConverter.java +++ b/src/main/java/dev/ebullient/convert/tools/JsonTextConverter.java @@ -479,6 +479,16 @@ default void renderInlineTemplate(List text, QuteBase resource, String a String replaceText(String s); + default String replaceText(JsonNode input) { + if (input == null) { + return null; + } + if (input.isObject() || input.isArray()) { + throw new IllegalArgumentException("Can only replace text for textual nodes: " + input); + } + return replaceText(input.asText()); + } + default String slugify(String s) { return Tui.slugify(s); } @@ -520,6 +530,14 @@ default Stream streamOfFieldNames(JsonNode source) { return StreamSupport.stream(iterableFieldNames(source).spliterator(), false); } + default Stream> streamPropsExcluding(JsonNode source, JsonNodeReader... excludingKeys) { + if (source == null || !source.isObject()) { + return Stream.of(); + } + return source.properties().stream() + .filter(e -> Arrays.stream(excludingKeys).noneMatch(s -> e.getKey().equalsIgnoreCase(s.name()))); + } + default String toAnchorTag(String x) { return Tui.toAnchorTag(x); } 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 f1e1f90e6..4c5b4d14a 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/JsonTextReplacement.java @@ -109,16 +109,6 @@ default String joinAndReplace(ArrayNode array) { return String.join(", ", list); } - default String replaceText(JsonNode input) { - if (input == null) { - return null; - } - if (input.isObject() || input.isArray()) { - throw new IllegalArgumentException("Can only replace text for textual nodes: " + input); - } - return replaceText(input.asText()); - } - default String replaceText(String input) { return replaceTokens(input, (s, b) -> this._replaceTokenText(s, b)); } diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteBase.java b/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteBase.java index de2343656..fc64e9768 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteBase.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteBase.java @@ -36,12 +36,6 @@ public Pf2eSources getSources() { return sources; } - List toAlignments(JsonNode alignNode, JsonNodeReader alignmentField) { - return alignmentField.getListOfStrings(alignNode, tui()).stream() - .map(a -> a.length() > 2 ? a : linkify(Pf2eIndexType.trait, a.toUpperCase())) - .collect(Collectors.toList()); - } - public Pf2eQuteBase build() { boolean pushed = parseState().push(getSources(), rootNode); try { diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteCreature.java b/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteCreature.java index 532fb9143..749cbc1d8 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteCreature.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteCreature.java @@ -2,8 +2,9 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Optional; +import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; @@ -26,108 +27,70 @@ protected QuteCreature buildQuteResource() { appendToText(text, SourceField.entries.getFrom(rootNode), "##"); Collection traits = collectTraitsFrom(rootNode, tags); - if (Pf2eCreature.alignment.existsIn(rootNode)) { - traits.addAll(toAlignments(rootNode, Pf2eCreature.alignment)); - } - Optional level = Pf2eCreature.level.getIntFrom(rootNode); + traits.addAll(getAlignments(Pf2eCreature.alignment.getFrom(rootNode))); return new QuteCreature(sources, text, tags, traits, Field.alias.replaceTextFromList(rootNode, this), Pf2eCreature.description.replaceTextFrom(rootNode, this), - level.orElse(null), - getPerception(), - buildDefenses(), - Pf2eCreatureLanguages.createCreatureLanguages(Pf2eCreature.languages.getFrom(rootNode), this), - buildSkills()); + Pf2eCreature.level.getIntFrom(rootNode).orElse(null), + Pf2eCreature.perception(rootNode), + Pf2eCreature.defenses(rootNode, this), + Pf2eCreatureLanguages.create(Pf2eCreature.languages.getFrom(rootNode), this), + Pf2eCreature.skills(rootNode, this), + Pf2eCreature.senses.streamFrom(rootNode).map(n -> Pf2eCreatureSense.create(n, this)).toList(), + Pf2eCreature.abilityModifiers(rootNode), + Pf2eCreature.items.replaceTextFromList(rootNode, this), + Pf2eTypeReader.Pf2eSpeed.getSpeed(Pf2eCreature.speed.getFrom(rootNode), this)); } /** * Example JSON input: * *
-     *     "perception": {
-     *         "std": 6
+     *     "languages": {
+     *         "languages": ["Common", "Sylvan"],
+     *         "abilities": ["{@ability telepathy} 100 feet"],
+     *         "notes": ["some other notes"],
      *     }
      * 
*/ - private Integer getPerception() { - JsonNode perceptionNode = Pf2eCreature.perception.getFrom(rootNode); - if (perceptionNode == null || !perceptionNode.isObject()) { - return null; - } - return Pf2eCreature.std.getIntOrThrow(perceptionNode); - } + enum Pf2eCreatureLanguages implements JsonNodeReader { + languages, + abilities, + notes; - /** - * Example JSON input: - * - *
-     *     "defenses": { ... }
-     * 
- */ - private QuteDataDefenses buildDefenses() { - JsonNode defenseNode = Pf2eCreature.defenses.getFrom(rootNode); - if (defenseNode == null || !defenseNode.isObject()) { - return null; + static QuteCreature.CreatureLanguages create(JsonNode node, Pf2eTypeReader convert) { + return node == null ? null + : new QuteCreature.CreatureLanguages( + languages.getListOfStrings(node, convert.tui()), + abilities.replaceTextFromList(node, convert), + notes.replaceTextFromList(node, convert)); } - return Pf2eDefenses.createInlineDefenses(defenseNode, this); } /** * Example JSON input: * *
-     *     "skills": {
-     *         "athletics": 30,
-     *         "stealth": {
-     *             "std": 36,
-     *             "in forests": 42,
-     *             "note": "additional note"
-     *         },
-     *         "notes": [
-     *             "some note"
-     *         ]
+     *     {
+     *         "name": "scent",
+     *         "type": "imprecise",
+     *         "range": 60,
      *     }
      * 
*/ - private QuteCreature.CreatureSkills buildSkills() { - JsonNode skillsNode = Pf2eCreature.skills.getFrom(rootNode); - if (skillsNode == null || !skillsNode.isObject()) { - return null; - } - return new QuteCreature.CreatureSkills( - skillsNode.properties().stream() - .filter(e -> !e.getKey().equals(Pf2eCreature.notes.name())) - .map(e -> Pf2eTypeReader.Pf2eSkillBonus.createSkillBonus(e.getKey(), e.getValue(), this)) - .toList(), - Pf2eCreature.notes.replaceTextFromList(rootNode, this)); - } + enum Pf2eCreatureSense implements JsonNodeReader { + name, + type, + range; - /** - * Example JSON input: - * - *
-     *     "languages": {
-     *         "languages": ["Common", "Sylvan"],
-     *         "abilities": ["{@ability telepathy} 100 feet"],
-     *         "notes": ["some other notes"],
-     *     }
-     * 
- */ - enum Pf2eCreatureLanguages implements JsonNodeReader { - languages, - abilities, - notes; - - static QuteCreature.CreatureLanguages createCreatureLanguages(JsonNode node, Pf2eTypeReader convert) { - if (node == null) { - return null; - } - return new QuteCreature.CreatureLanguages( - languages.getListOfStrings(node, convert.tui()), - abilities.getListOfStrings(node, convert.tui()).stream().map(convert::replaceText).toList(), - notes.getListOfStrings(node, convert.tui()).stream().map(convert::replaceText).toList()); + static QuteCreature.CreatureSense create(JsonNode node, Pf2eTypeReader convert) { + return node == null ? null + : new QuteCreature.CreatureSense( + name.getTextFrom(node).map(convert::replaceText).orElseThrow(), + type.getTextFrom(node).map(convert::replaceText).orElse(null), + range.getIntFrom(node).orElse(null)); } } @@ -154,6 +117,76 @@ enum Pf2eCreature implements JsonNodeReader { speed, spellcasting, std, - traits, + traits; + + /** + * Example JSON input: + * + *
+         *     "perception": {
+         *         "std": 6
+         *     }
+         * 
+ */ + private static Integer perception(JsonNode source) { + return perception.getObjectFrom(source).map(std::getIntOrThrow).orElse(null); + } + + /** + * Example JSON input: + * + *
+         *     "defenses": { ... }
+         * 
+ */ + private static QuteDataDefenses defenses(JsonNode source, Pf2eTypeReader convert) { + return defenses.getObjectFrom(source).map(n -> Pf2eDefenses.createInlineDefenses(n, convert)).orElse(null); + } + + /** + * Example JSON input: + * + *
+         *     "skills": {
+         *         "athletics": 30,
+         *         "stealth": {
+         *             "std": 36,
+         *             "in forests": 42,
+         *             "note": "additional note"
+         *         },
+         *         "notes": [
+         *             "some note"
+         *         ]
+         *     }
+         * 
+ */ + private static QuteCreature.CreatureSkills skills(JsonNode source, Pf2eTypeReader convert) { + return new QuteCreature.CreatureSkills( + skills.streamPropsExcluding(source, notes) + .map(e -> Pf2eTypeReader.Pf2eSkillBonus.createSkillBonus(e.getKey(), e.getValue(), convert)) + .toList(), + notes.replaceTextFromList(source, convert)); + } + + /** + * Example JSON input: + * + *
+         *     {
+         *         "str": 10,
+         *         "dex": 10,
+         *         "con": 10,
+         *         "int": 10,
+         *         "wis": 10,
+         *         "cha": 10
+         *     }
+         * 
+ */ + private static Map abilityModifiers(JsonNode source) { + // Use a linked hash map to preserve insertion order + Map mods = new LinkedHashMap<>(); + abilityMods.streamPropsExcluding(source).forEachOrdered(e -> mods.put(e.getKey(), e.getValue().asInt())); + return mods; + } } } diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteDeity.java b/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteDeity.java index cf185039b..4e4129ae0 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteDeity.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteDeity.java @@ -35,8 +35,8 @@ protected QuteDeity buildQuteResource() { appendToText(text, SourceField.entries.getFrom(rootNode), "##"); JsonNode alignNode = Pf2eDeity.alignment.getFrom(rootNode); - String alignment = join(", ", toAlignments(alignNode, Pf2eDeity.alignment)); - String followerAlignment = join(", ", toAlignments(alignNode, Pf2eDeity.followerAlignment)); + String alignment = join(", ", getAlignments(Pf2eDeity.alignment.getFrom(alignNode))); + String followerAlignment = join(", ", getAlignments(Pf2eDeity.followerAlignment.getFrom(alignNode))); return new QuteDeity(sources, text, tags, Field.alias.replaceTextFromList(rootNode, this), @@ -114,33 +114,22 @@ QuteDeity.QuteDivineAvatar buildAvatar(Tags tags) { avatar.preface = replaceText(Pf2eDeity.preface.getTextOrEmpty(avatarNode)); avatar.name = linkify(Pf2eIndexType.spell, "avatar||Avatar") + " of " + sources.getName(); - Speed speed = Pf2eDeity.speed.fieldFromTo(avatarNode, Speed.class, tui()); - avatar.speed = speed.speedToString(this); - - List notes = new ArrayList<>(); + avatar.speed = Pf2eTypeReader.Pf2eSpeed.getSpeed(Pf2eDeity.speed.getFrom(avatarNode), this); if (Pf2eDeity.airWalk.booleanOrDefault(avatarNode, false)) { - notes.add(linkify(Pf2eIndexType.spell, "air walk")); + avatar.speed.addAbility(linkify(Pf2eIndexType.spell, "air walk")); } String immunities = joinConjunct(" and ", Pf2eDeity.immune.linkifyListFrom(avatarNode, Pf2eIndexType.condition, this)); if (!immunities.isEmpty()) { - notes.add("immune to " + immunities); + avatar.speed.addAbility("immune to " + immunities); } if (Pf2eDeity.ignoreTerrain.booleanOrDefault(avatarNode, false)) { - notes.add(replaceText( + avatar.speed.addAbility(replaceText( "ignore {@quickref difficult terrain||3|terrain} and {@quickref greater difficult terrain||3|terrain}")); } - if (speed.speedNote != null) { - notes.add(speed.speedNote); - } - if (!notes.isEmpty()) { - avatar.speed += ", " + join(", ", notes); - } - String shield = Pf2eDeity.shield.getTextOrEmpty(avatarNode); - if (shield != null) { - avatar.shield = "shield (" + shield + " Hardness, can't be damaged)"; - } + avatar.shield = Pf2eDeity.shield.getIntFrom(avatarNode) + .map("shield (%d Hardness, can't be damaged)"::formatted).orElse(null); avatar.melee = Pf2eDeity.melee.streamFrom(avatarNode) .map(n -> buildAvatarAction(n, tags)) diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteItem.java b/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteItem.java index a2b658682..8093b4243 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteItem.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/Json2QuteItem.java @@ -14,7 +14,7 @@ import dev.ebullient.convert.tools.Tags; import dev.ebullient.convert.tools.pf2e.qute.Pf2eQuteBase; import dev.ebullient.convert.tools.pf2e.qute.QuteDataArmorClass; -import dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardness; +import dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardnessBt; import dev.ebullient.convert.tools.pf2e.qute.QuteItem; import dev.ebullient.convert.tools.pf2e.qute.QuteItem.QuteItemActivate; import dev.ebullient.convert.tools.pf2e.qute.QuteItem.QuteItemArmorData; @@ -78,38 +78,36 @@ private String getPrice(JsonNode rootNode) { return ""; } + /** + * Example input JSON: + * + *
+     *     "sheldData": {
+     *         "ac": 2,
+     *         "ac2": 3,
+     *         "hardness": 5,
+     *         "hp": 20,
+     *         "bt": 10,
+     *         "speedPen": 10
+     *     }
+     * 
+ * + *

+ * `speedPen` is optional. + *

+ */ private QuteItemShieldData getShieldData() { - JsonNode shieldDataNode = Pf2eItem.shieldData.getFrom(rootNode); - if (shieldDataNode == null) { - return null; - } - QuteItemShieldData shieldData = new QuteItemShieldData(); - - String ac = Pf2eItem.ac.bonusOrNull(shieldDataNode); - String ac2 = Pf2eItem.ac.bonusOrNull(shieldDataNode); - String dexCap = Pf2eItem.dexCap.bonusOrNull(shieldDataNode); - if (ac != null || dexCap != null) { - shieldData.ac = new QuteDataArmorClass(); - NamedText.SortedBuilder namedText = new NamedText.SortedBuilder(); - namedText.add("AC Bonus", ac + (ac2 == null ? "" : ("/" + ac2))); - if (dexCap != null) { - namedText.add("Dex Cap", dexCap); - } - shieldData.ac.armorClass = namedText.build(); - } - - QuteDataHpHardness hpHardness = new QuteDataHpHardness(); - hpHardness.hpValue = Pf2eItem.hp.getTextOrEmpty(shieldDataNode); - hpHardness.brokenThreshold = Pf2eItem.bt.getTextOrEmpty(shieldDataNode); - hpHardness.hardnessValue = Pf2eItem.hardness.getTextOrEmpty(shieldDataNode); - if (hpHardness.hpValue != null || hpHardness.hardnessValue != null || hpHardness.brokenThreshold != null) { - shieldData.hpHardness = hpHardness; - } - - String speedPen = Pf2eItem.speedPen.getTextOrEmpty(shieldDataNode); - shieldData.speedPenalty = penalty(speedPen, " ft."); - - return shieldData; + JsonNode shieldNode = Pf2eItem.shieldData.getFrom(rootNode); + return shieldNode == null ? null + : new QuteItemShieldData( + new QuteDataArmorClass( + Pf2eItem.ac.getIntOrThrow(shieldNode), + Pf2eItem.ac2.getIntFrom(shieldNode).orElse(null)), + new QuteDataHpHardnessBt( + new QuteDataHpHardnessBt.HpStat(Pf2eItem.hp.getIntOrThrow(shieldNode)), + new Pf2eSimpleStat(Pf2eItem.hardness.getIntOrThrow(shieldNode)), + Pf2eItem.bt.getIntOrThrow(shieldNode)), + penalty(Pf2eItem.speedPen.getTextOrEmpty(shieldNode), " ft.")); } private QuteItemArmorData getArmorData() { @@ -119,19 +117,10 @@ private QuteItemArmorData getArmorData() { } QuteItemArmorData armorData = new QuteItemArmorData(); - String ac = Pf2eItem.ac.bonusOrNull(armorDataNode); - String dexCap = Pf2eItem.dexCap.bonusOrNull(armorDataNode); - if (ac != null || dexCap != null) { - armorData.ac = new QuteDataArmorClass(); - NamedText.SortedBuilder namedText = new NamedText.SortedBuilder(); - namedText.add("AC Bonus", ac); - if (dexCap != null) { - namedText.add("Dex Cap", dexCap); - } - armorData.ac.armorClass = namedText.build(); - } + Pf2eItem.ac.getIntFrom(armorDataNode).ifPresent(ac -> armorData.ac = new QuteDataArmorClass(ac)); + armorData.dexCap = Pf2eItem.dexCap.bonusOrNull(armorDataNode); - armorData.strength = Pf2eItem.str.getTextOrDefault(armorDataNode, "\u2014"); + armorData.strength = Pf2eItem.str.getTextOrDefault(armorDataNode, "—"); String checkPen = Pf2eItem.checkPen.getTextOrDefault(armorDataNode, null); armorData.checkPenalty = penalty(checkPen, ""); @@ -261,9 +250,9 @@ String getCategory(Tags tags) { return subcategory; } - String penalty(String input, String suffix) { + private String penalty(String input, String suffix) { if (input == null || input.isBlank() || "0".equals(input)) { - return "\u2014"; + return "—"; } return (input.startsWith("-") ? input : ("-" + input)) + suffix; } diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/JsonTextReplacement.java b/src/main/java/dev/ebullient/convert/tools/pf2e/JsonTextReplacement.java index ff0d9640d..df7bf5022 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/JsonTextReplacement.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/JsonTextReplacement.java @@ -61,16 +61,6 @@ default CompendiumConfig cfg() { return index().cfg(); } - default String replaceText(JsonNode input) { - if (input == null) { - return null; - } - if (input.isObject() || input.isArray()) { - throw new IllegalArgumentException("Can only replace text for textual nodes: " + input); - } - return replaceText(input.asText()); - } - default String replaceText(String input) { return replaceTokens(input, (s, b) -> this._replaceTokenText(s, b)); } @@ -370,6 +360,7 @@ default String linkifyTrait(JsonNode traitNode, String linkText) { String title; if (categories.contains("Alignment")) { title = "Alignment"; + linkText = linkText.toUpperCase(); } else if (categories.contains("Rarity")) { title = "Rarity"; } else if (categories.contains("Size")) { diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/Pf2eTypeReader.java b/src/main/java/dev/ebullient/convert/tools/pf2e/Pf2eTypeReader.java index bb6ebda9b..7c042d2c2 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/Pf2eTypeReader.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/Pf2eTypeReader.java @@ -2,8 +2,6 @@ import java.util.ArrayList; import java.util.Arrays; -import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -16,14 +14,17 @@ import dev.ebullient.convert.io.Tui; import dev.ebullient.convert.qute.NamedText; +import dev.ebullient.convert.qute.QuteUtil; import dev.ebullient.convert.tools.JsonNodeReader; +import dev.ebullient.convert.tools.JsonTextConverter; import dev.ebullient.convert.tools.Tags; import dev.ebullient.convert.tools.pf2e.qute.QuteDataActivity; import dev.ebullient.convert.tools.pf2e.qute.QuteDataArmorClass; import dev.ebullient.convert.tools.pf2e.qute.QuteDataDefenses; import dev.ebullient.convert.tools.pf2e.qute.QuteDataDefenses.QuteSavingThrows; -import dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardness; +import dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardnessBt; import dev.ebullient.convert.tools.pf2e.qute.QuteDataSkillBonus; +import dev.ebullient.convert.tools.pf2e.qute.QuteDataSpeed; import dev.ebullient.convert.tools.pf2e.qute.QuteItem.QuteItemWeaponData; import io.quarkus.runtime.annotations.RegisterForReflection; @@ -179,56 +180,84 @@ public static QuteDataDefenses createInlineDefenses(JsonNode source, Pf2eTypeRea if (notesNode != null) { convert.tui().warnf("Defenses has notes: %s", source.toString()); } - return new QuteDataDefenses(List.of(), - getAcString(source, convert), + + Map hpHardnessBt = getHpHardnessBt(source, convert); + + return new QuteDataDefenses( + getArmorClass(source, convert), getSavingThrowString(source, convert), - getHpHardness(source, convert), // hp hardness + hpHardnessBt.remove(std.name()), + hpHardnessBt, immunities.linkifyListFrom(source, Pf2eIndexType.trait, convert), // immunities getWeakResist(resistances, source, convert), // resistances getWeakResist(weaknesses, source, convert)); } - // "hardness": { - // "notes": { - // "std": "per mirror" - // }, - // "std": 13 - // }, - // "hp": { - // "std": 54, - // "Reflection ": 30 - // }, - // "bt": { - // "std": 27 - // }, - public static List getHpHardness(JsonNode source, Pf2eTypeReader convert) { - // First pass: for hazards. TODO: creatures - JsonNode btValueNode = bt.getFromOrEmptyObjectNode(source); - JsonNode hpValueNode = hp.getFromOrEmptyObjectNode(source); - Map hpNotes = notes.getMapOfStrings(hpValueNode, convert.tui()); - JsonNode hardValueNode = hardness.getFromOrEmptyObjectNode(source); - Map hardNotes = notes.getMapOfStrings(hardValueNode, convert.tui()); - - // Collect all keys - Set keys = new HashSet<>(); - hpValueNode.fieldNames().forEachRemaining(keys::add); - btValueNode.fieldNames().forEachRemaining(keys::add); - hardValueNode.fieldNames().forEachRemaining(keys::add); - keys.removeIf(k -> k.equalsIgnoreCase("notes")); - - List items = new ArrayList<>(); - for (String k : keys) { - QuteDataHpHardness qhp = new QuteDataHpHardness(); - qhp.name = k.equals("std") ? "" : k; - qhp.hardnessNotes = convert.replaceText(hardNotes.get(k)); - qhp.hpNotes = convert.replaceText(hpNotes.get(k)); - qhp.hardnessValue = convert.replaceText(hardValueNode.get(k)); - qhp.hpValue = convert.replaceText(hpValueNode.get(k)); - qhp.brokenThreshold = convert.replaceText(btValueNode.get(k)); - items.add(qhp); - } - items.sort(Comparator.comparing(a -> a.name)); - return items; + /** + * Example input JSON for a hazard: + * + *
+         *     "hardness": {
+         *         "std": 13,
+         *         "Reflection ": 14,
+         *         "notes": {
+         *             "std": "per mirror"
+         *         }
+         *     },
+         *     "hp": {
+         *         "std": 54,
+         *         "Reflection ": 30,
+         *         "notes": {
+         *             "Reflection ": "some note"
+         *         }
+         *     },
+         *     "bt": {
+         *         "std": 27,
+         *         "Reflection ": 15
+         *     }
+         * 
+ * + * Broken threshold is only valid for hazards. Example input JSON for a creature: + * + *
+         *     "hardness": {
+         *         "std": 13,
+         *     },
+         *     "hp": [
+         *         { "hp": 90, "name": "body", "abilities": [ "hydra regeneration" ] },
+         *         { "hp": 15, "name": "head", "abilities": [ "head regrowth" ] }
+         *     ],
+         * 
+ */ + public static Map getHpHardnessBt(JsonNode source, Pf2eTypeReader convert) { + // We need to do HP mapping separately because creature and hazard HP are structured differently + Map hpStats = hp.isArrayIn(source) + ? Pf2eHpStat.mappedHpFromArray(hp.ensureArrayIn(source), convert) + : Pf2eHpStat.mappedHpFromObject(hp.getFromOrEmptyObjectNode(source), convert); + + // Collect names from the field names of the stat objects + Set names = Stream.of(bt, hardness) + .filter(k -> k.isObjectIn(source)) + .flatMap(k -> k.streamPropsExcluding(source, Pf2eHpStat.values())) + .map(Entry::getKey) + .map(s -> s.equals("default") ? std.name() : s) // compensate for data irregularity + .collect(Collectors.toSet()); + names.addAll(hpStats.keySet()); + + JsonNode btNode = bt.getFromOrEmptyObjectNode(source); + JsonNode hardnessNode = hardness.getFromOrEmptyObjectNode(source); + Map hardnessNotes = notes.getMapOfStrings(hardnessNode, convert.tui()); + + // Map each known name to the known stats for that name + return names.stream().collect(Collectors.toMap( + String::trim, + k -> new QuteDataHpHardnessBt( + hpStats.getOrDefault(k, null), + hardnessNode.has(k) + ? new Pf2eSimpleStat( + hardnessNode.get(k).asInt(), convert.replaceText(hardnessNotes.get(k))) + : null, + btNode.has(k) ? btNode.get(k).asInt() : null))); } public static List getWeakResist(Pf2eDefenses field, JsonNode source, Pf2eTypeReader convert) { @@ -237,40 +266,18 @@ public static List getWeakResist(Pf2eDefenses field, JsonNode source, Pf NameAmountNote nmn = convert.tui().readJsonValue(wr, NameAmountNote.class); items.add(nmn.flatten(convert)); } - ; return items; } - public static QuteDataArmorClass getAcString(JsonNode source, Pf2eTypeReader convert) { + public static QuteDataArmorClass getArmorClass(JsonNode source, Pf2eTypeReader convert) { JsonNode acNode = ac.getFrom(source); - if (acNode == null) { - return null; - } - QuteDataArmorClass ac = new QuteDataArmorClass(); - NamedText.SortedBuilder namedText = new NamedText.SortedBuilder(); - for (Entry e : convert.iterableFields(acNode)) { - if (e.getKey().equals(note.name()) || - e.getKey().equals(abilities.name()) || - e.getKey().equals(notes.name())) { - continue; // skip these three - } - namedText.add( - (e.getKey().equals("std") ? "AC" : e.getKey() + " AC"), - "" + e.getValue()); - } - ac.armorClass = namedText.build(); - ac.abilities = convert.replaceText(abilities.getTextOrEmpty(acNode)); - - // Consolidate "note" and "notes" into different representations of the same data - List acNotes = notes.getListOfStrings(acNode, convert.tui()); - ac.notes = Stream.concat(acNotes.stream(), Stream.of(note.getTextOrEmpty(acNode))) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .map(convert::replaceText) - .toList(); - ac.note = String.join("; ", ac.notes); - - return ac; + return acNode == null ? null + : new QuteDataArmorClass( + std.getIntOrThrow(acNode), + ac.streamPropsExcluding(source, note, abilities, notes, std) + .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().asInt())), + (note.existsIn(acNode) ? note : notes).replaceTextFromList(acNode, convert), + abilities.replaceTextFromList(acNode, convert)); } /** @@ -319,6 +326,71 @@ public static QuteSavingThrows getSavingThrowString(JsonNode source, Pf2eTypeRea } } + enum Pf2eHpStat implements JsonNodeReader { + abilities, + hp, + name, + notes; + + /** + * Read HP stats mapped to names from a JSON array. Each entry in the array corresponds to a different HP + * component in a single creature - e.g. the heads and body on a hydra: + * + *
+         *     [
+         *       {"hp": 10, "name": "head", "abilities": ["head regrowth"]},
+         *       {"hp": 20, "name": "body", "notes": ["some note"]}
+         *     ]
+         * 
+ * + * If there is only a single HP component, then {@code name} may be omitted. In this case, the key in the map + * will be {@code std}. + * + *
+         *     [{"hp": 10, "abilities": ["some ability"], "notes": ["some note"]}]
+         * 
+ */ + static Map mappedHpFromArray(JsonNode source, JsonTextConverter convert) { + return convert.streamOf(convert.ensureArray(source)).collect(Collectors.toMap( + n -> { + // Capitalize the names + if (!Pf2eHpStat.name.existsIn(n)) { + return Pf2eDefenses.std.name(); + } + String name = Pf2eHpStat.name.getTextOrThrow(n); + return name.substring(0, 1).toUpperCase() + name.substring(1); + }, + n -> new QuteDataHpHardnessBt.HpStat( + hp.getIntOrThrow(n), + notes.replaceTextFromList(n, convert), + abilities.replaceTextFromList(n, convert)))); + } + + /** + * Read HP stats mapped to names from a JSON object. Each resulting entry corresponds to a different HP + * component in a single hazard - e.g. the standard HP, and the reflection HP on a Clone Mirror hazard: + * + *
+         *     {
+         *         "std": 54,
+         *         "Reflection ": 30,
+         *         "notes": {
+         *             "std": "per mirror"
+         *         }
+         *     }
+         * 
+ */ + static Map mappedHpFromObject( + JsonNode source, JsonTextConverter convert) { + JsonNode notesNode = notes.getFromOrEmptyObjectNode(source); + return convert.streamPropsExcluding(convert.ensureObjectNode(source), notes).collect(Collectors.toMap( + Entry::getKey, + e -> new QuteDataHpHardnessBt.HpStat( + e.getValue().asInt(), + notesNode.has(e.getKey()) ? convert.replaceText(notesNode.get(e.getKey())) : null))); + } + } + enum Pf2eFeat implements JsonNodeReader { access, activity, @@ -470,39 +542,17 @@ public static QuteDataSkillBonus createSkillBonus( .collect( Collectors.toUnmodifiableMap( e -> convert.replaceText(e.getKey()), e -> e.getValue().asInt())), - convert.replaceText(note.getTextOrNull(source))); + Optional.ofNullable(note.getTextOrNull(source)) + .map(s -> List.of(convert.replaceText(s))) + .orElse(List.of())); } } - @RegisterForReflection - class Speed { - public Integer walk; - public Integer climb; - public Integer fly; - public Integer burrow; - public Integer swim; - public Integer dimensional; - public String speedNote; - - public String speedToString(Pf2eTypeReader convert) { - List parts = new ArrayList<>(); - if (climb != null) { - parts.add("climb " + climb + " feet"); - } - if (fly != null) { - parts.add("fly " + fly + " feet"); - } - if (burrow != null) { - parts.add("burrow " + burrow + " feet"); - } - if (swim != null) { - parts.add("swim " + swim + " feet"); - } - return String.format("%s%s%s", - walk == null ? "no land Speed" : "Speed " + walk + " feet", - (walk == null || parts.isEmpty()) ? "" : ", ", - convert.join(", ", parts)); - } + default List getAlignments(JsonNode alignNode) { + return streamOf(alignNode) + .map(JsonNode::asText) + .map(a -> a.length() > 2 ? a : linkifyTrait(a.toUpperCase())) + .collect(Collectors.toList()); } static QuteDataActivity getQuteActivity(JsonNode source, JsonNodeReader field, JsonSource convert) { @@ -587,6 +637,38 @@ public String flatten(Pf2eTypeReader convert) { } } + enum Pf2eSpeed implements JsonNodeReader { + walk, + speedNote, + abilities; + + /** + * Example JSON input: + * + *
+         *     {
+         *         "walk": 10,
+         *         "fly": 20,
+         *         "speedNote": "(with fly spell)",
+         *         "abilities": "air walk"
+         *     }
+         * 
+ */ + static QuteDataSpeed getSpeed(JsonNode source, JsonTextConverter convert) { + return source == null || !source.isObject() ? null + : new QuteDataSpeed( + walk.getIntFrom(source).orElse(null), + convert.streamPropsExcluding(source, speedNote, abilities) + .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().asInt())), + speedNote.getTextFrom(source) + .map(convert::replaceText) + .map(s -> s.replaceFirst("^\\((%s)\\)$", "\1")) // Remove parens around the note + .map(List::of).orElse(List.of()), + // Specifically make this mutable because we later need to add additional abilities for deities + new ArrayList<>(abilities.replaceTextFromList(source, convert))); + } + } + default String getOrdinalForm(String level) { return switch (level) { case "1" -> "1st"; @@ -731,4 +813,43 @@ default String intToString(int number, boolean freq) { } } + /** A generic container for a PF2e stat value which may have an attached note. */ + interface Pf2eStat extends QuteUtil { + /** Returns the value of the stat. */ + Integer value(); + + /** Returns any notes associated with this value. */ + List notes(); + + /** Return the value formatted with a leading +/-. */ + default String bonus() { + return String.format("%+d", value()); + } + + /** Return notes formatted as space-delimited parenthesized strings. */ + default String formattedNotes() { + return notes().stream().map(s -> String.format("(%s)", s)).collect(Collectors.joining(" ")); + } + } + + /** + * A basic {@link Pf2eStat} which provides only a value and possibly a note. Default representation: + *

+ * 10 (some note) (some other note) + *

+ */ + record Pf2eSimpleStat(Integer value, List notes) implements Pf2eStat { + Pf2eSimpleStat(Integer value) { + this(value, List.of()); + } + + Pf2eSimpleStat(Integer value, String note) { + this(value, note == null || note.isBlank() ? List.of() : List.of(note)); + } + + @Override + public String toString() { + return value.toString() + (notes.isEmpty() ? "" : " " + formattedNotes()); + } + } } diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteCreature.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteCreature.java index dfe503100..84e99c9a9 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteCreature.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteCreature.java @@ -2,7 +2,9 @@ import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.StringJoiner; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -44,11 +46,21 @@ public class QuteCreature extends Pf2eQuteBase { * Skill bonuses as {@link dev.ebullient.convert.tools.pf2e.qute.QuteCreature.CreatureSkills CreatureSkills} */ public final CreatureSkills skills; + /** Senses as a list of {@link dev.ebullient.convert.tools.pf2e.qute.QuteCreature.CreatureSense CreatureSense} */ + public final List senses; + /** Ability modifiers as a map of (name, modifier) */ + public final Map abilityMods; + /** Items held by the creature as a list of strings */ + public final List items; + /** The creature's speed, as an {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataSpeed QuteDataSpeed} */ + public final QuteDataSpeed speed; public QuteCreature(Pf2eSources sources, List text, Tags tags, Collection traits, List aliases, String description, Integer level, Integer perception, - QuteDataDefenses defenses, CreatureLanguages languages, CreatureSkills skills) { + QuteDataDefenses defenses, CreatureLanguages languages, CreatureSkills skills, + List senses, Map abilityMods, + List items, QuteDataSpeed speed) { super(sources, text, tags); this.traits = traits; this.aliases = aliases; @@ -58,6 +70,10 @@ public QuteCreature(Pf2eSources sources, List text, Tags tags, this.languages = languages; this.defenses = defenses; this.skills = skills; + this.senses = senses; + this.abilityMods = abilityMods; + this.items = items; + this.speed = speed; } /** @@ -113,4 +129,27 @@ public String toString() { (notes == null ? "" : " " + String.join("; ", notes)); } } + + /** + * A creature's senses. + * + * @param name The name of the sense (required, string) + * @param type The type of the sense - e.g. precise, imprecise (optional, string) + * @param range The range of the sense (optional, integer) + */ + @TemplateData + public record CreatureSense(String name, String type, Integer range) implements QuteUtil { + + @Override + public String toString() { + StringJoiner s = new StringJoiner(" ").add(name); + if (type != null) { + s.add(String.format("(%s)", type)); + } + if (range != null) { + s.add(range.toString()); + } + return s.toString(); + } + } } diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataArmorClass.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataArmorClass.java index 056e5bbd2..74b3a3581 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataArmorClass.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataArmorClass.java @@ -1,35 +1,65 @@ package dev.ebullient.convert.tools.pf2e.qute; import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; +import java.util.stream.Stream; -import dev.ebullient.convert.qute.NamedText; -import dev.ebullient.convert.qute.QuteUtil; +import dev.ebullient.convert.tools.pf2e.Pf2eTypeReader.Pf2eStat; import io.quarkus.qute.TemplateData; /** - * Pf2eTools armor class attributes + * Pf2eTools armor class attributes. Default representation example: + *

+ * AC 15 (10 with mage armor) note ability + *

+ * + * @param value The AC value + * @param alternateValues Alternate AC values as a map of (condition, AC value) + * @param notes Any notes associated with the AC e.g. "with mage armor" + * @param abilities Any AC related abilities */ @TemplateData -public class QuteDataArmorClass implements QuteUtil { - public Collection armorClass; - public String abilities; +public record QuteDataArmorClass( + Integer value, + Map alternateValues, + List notes, + List abilities) implements Pf2eStat { - /** Notes associated with the armor class, e.g. "with mage armor". */ - public Collection notes; + public QuteDataArmorClass(Integer value) { + this(value, Map.of(), List.of(), List.of()); + } + + public QuteDataArmorClass(Integer value, Integer alternateValue) { + this(value, alternateValue == null ? Map.of() : Map.of("", alternateValue), List.of(), List.of()); + } /** - * Any notes associated with the armor class. This contains the same data as - * {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataArmorClass#notes notes}, but as a single - * semicolon-delimited string. + * @param asBonus If true, then prefix alternate AC values with a +/- + * @return Alternate values formatted as e.g. {@code (30 with mage armor)} */ - public String note; + private String formattedAlternates(boolean asBonus) { + return alternateValues.entrySet().stream() + .map(e -> String.format( + asBonus ? "(%+d%s)" : "(%d%s)", e.getValue(), + e.getKey().isEmpty() ? "" : " " + e.getKey())) + .collect(Collectors.joining(" ")); + } + + @Override + public String bonus() { + String alternates = formattedAlternates(true); + return Pf2eStat.super.bonus() + (alternates.isEmpty() ? "" : " " + alternates); + } + @Override public String toString() { - return armorClass.stream() - .map(NamedText::toString) - .collect(Collectors.joining("; ")) - + (isPresent(note) ? " " + note.trim() : "") - + (isPresent(abilities) ? "; " + abilities : ""); + return Stream.of(List.of("**AC**", value, formattedAlternates(false)), notes, abilities) + .flatMap(Collection::stream) + .map(Objects::toString) + .filter(this::isPresent) + .collect(Collectors.joining(" ")); } } diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataDefenses.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataDefenses.java index 3d1e1183a..f12e0965c 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataDefenses.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataDefenses.java @@ -1,65 +1,72 @@ package dev.ebullient.convert.tools.pf2e.qute; -import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.StringJoiner; import java.util.stream.Collectors; +import java.util.stream.Stream; import dev.ebullient.convert.qute.QuteUtil; import io.quarkus.qute.TemplateData; /** - * Pf2eTools Armor class, Saving Throws, and other attributes describing defenses + * Pf2eTools Armor class, Saving Throws, and other attributes describing defenses of a creature or hazard. Example: + *
    + *
  • AC 23 (33 with mage armor); Fort +15, Ref +12, Will +10
  • + *
  • + * Floor Hardness 18, Floor HP 72 (BT 36); + * Channel Hardness 12, Channel HP 48 (BT24 ) to destroy a channel gate; + * Resistances precision damage; + * Immunities critical hits; + * Weaknesses bludgeoning damage + *
  • + *
+ * + * @param ac The armor class as a {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataArmorClass QuteDataArmorClass} + * @param savingThrows The saving throws, as + * {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataDefenses.QuteSavingThrows QuteSavingThrows} + * @param hpHardnessBt HP, hardness, and broken threshold stored in a + * {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardnessBt QuteDataHpHardnessBt} + * @param additionalHpHardnessBt Additional HP, hardness, or broken thresholds for other HP components as a map of + * names to {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardnessBt QuteDataHpHardnessBt} + * @param immunities List of strings, optional + * @param resistances List of strings, optional + * @param weaknesses List of strings, optional */ @TemplateData -public class QuteDataDefenses implements QuteUtil { +public record QuteDataDefenses( + QuteDataArmorClass ac, + QuteSavingThrows savingThrows, + QuteDataHpHardnessBt hpHardnessBt, + Map additionalHpHardnessBt, + List immunities, + List resistances, + List weaknesses) implements QuteUtil { - public final QuteDataArmorClass ac; - public final QuteSavingThrows savingThrows; - public final List hpHardness; - public final List immunities; - public final List resistances; - public final List weaknesses; + @Override + public String toString() { + String first = Stream.of(ac, savingThrows) + .filter(this::isPresent).map(Objects::toString) + .collect(Collectors.joining("; ")); - public QuteDataDefenses(List text, QuteDataArmorClass ac, QuteSavingThrows savingThrows, - List hpHardness, List immunities, - List resistances, List weaknesses) { - this.ac = ac; - this.savingThrows = savingThrows; - this.hpHardness = hpHardness; - this.immunities = immunities; - this.resistances = resistances; - this.weaknesses = weaknesses; - } + StringJoiner secondLine = new StringJoiner("; "); - public String toString() { - List lines = new ArrayList<>(); - List first = new ArrayList<>(); - if (ac != null) { - first.add(ac.toString()); - } - if (savingThrows != null) { - first.add(savingThrows.toString()); - } - if (!first.isEmpty()) { - lines.add("- " + String.join(", ", first)); - } - if (hpHardness != null) { - lines.add("- " + hpHardness.stream() - .map(hp -> hp.toString()) - .collect(Collectors.joining("; "))); + if (hpHardnessBt != null) { + secondLine.add(hpHardnessBt.toString()); } - if (isPresent(immunities)) { - lines.add("- **Immunities** " + String.join("; ", immunities)); - } - if (isPresent(resistances)) { - lines.add("- **Resistances** " + String.join("; ", resistances)); - } - if (isPresent(weaknesses)) { - lines.add("- **Weaknesses** " + String.join("; ", weaknesses)); - } - return String.join("\n", lines); + additionalHpHardnessBt.entrySet().stream() + .map(e -> e.getValue().toStringWithName(e.getKey())) + .forEachOrdered(secondLine::add); + + Map.of("Immunities", immunities, "Resistances", resistances, "Weaknesses", weaknesses) + .entrySet().stream() + .filter(e -> isPresent(e.getValue())) + .map(e -> String.format("**%s** %s", e.getKey(), String.join(", ", e.getValue()))) + .forEachOrdered(secondLine::add); + + return Stream.of(first, secondLine.toString()).map(s -> "- " + s).collect(Collectors.joining("\n")); } /** diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataHpHardness.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataHpHardness.java deleted file mode 100644 index 3cb24d4a0..000000000 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataHpHardness.java +++ /dev/null @@ -1,47 +0,0 @@ -package dev.ebullient.convert.tools.pf2e.qute; - -import java.util.ArrayList; -import java.util.List; - -import dev.ebullient.convert.qute.QuteUtil; -import io.quarkus.qute.TemplateData; - -/** - * Pf2eTools Hit Points and Hardiness attributes - */ -@TemplateData -public class QuteDataHpHardness implements QuteUtil { - public String name; - public String hpNotes; - public String hpValue; - public String hardnessNotes; - public String hardnessValue; - public String brokenThreshold; - - public String toString() { - String n = isPresent(name) ? (name.trim() + " ") : ""; - String btPart = ""; - String hpNotePart = ""; - boolean hasHardnessNotes = isPresent(hardnessNotes); - - List hardParts = new ArrayList<>(); - - if (isPresent(hardnessValue)) { - hardParts.add(String.format("**%sHardness** %s%s", - n, - hardnessValue, - isPresent(hardnessNotes) ? (" " + hardnessNotes) : "")); - } - if (isPresent(hpValue)) { - hardParts.add(String.format("**%sHP** %s", n, hpValue)); - } - if (isPresent(brokenThreshold)) { - btPart = " (BT " + brokenThreshold + ")"; - } - if (isPresent(hpNotes)) { - hpNotePart = " " + hpNotes; - } - return String.join(hasHardnessNotes ? "; " : ", ", hardParts) - + btPart + hpNotePart; - } -} diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataHpHardnessBt.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataHpHardnessBt.java new file mode 100644 index 000000000..1aefe7a48 --- /dev/null +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataHpHardnessBt.java @@ -0,0 +1,86 @@ +package dev.ebullient.convert.tools.pf2e.qute; + +import java.util.List; +import java.util.StringJoiner; + +import dev.ebullient.convert.qute.QuteUtil; +import dev.ebullient.convert.tools.pf2e.Pf2eTypeReader.Pf2eStat; +import io.quarkus.qute.TemplateData; + +/** + * Hit Points, Hardness, and a broken threshold for hazards and shields. Used for creatures, hazards, and shields. + *

+ * Hardness 10, HP (BT) 30 (15) to destroy a channel gate (some ability) + *

+ * + * @param hp The HP as a {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardnessBt.HpStat HpStat} (optional) + * @param hardness Hardness as a {@link dev.ebullient.convert.tools.pf2e.Pf2eTypeReader.Pf2eStat Pf2eStat} (optional) + * @param brokenThreshold Broken threshold as an integer (optional, not populated for creatures) + */ +@TemplateData +public record QuteDataHpHardnessBt(HpStat hp, Pf2eStat hardness, Integer brokenThreshold) implements QuteUtil { + + @Override + public String toString() { + return toStringWithName(""); + } + + /** Return a representation of these stats with the given name used to label each component. */ + public String toStringWithName(String name) { + name = name.isEmpty() ? "" : name + " "; + StringJoiner parts = new StringJoiner(", "); + if (hardness != null) { + parts.add(String.format("**%sHardness** %s", name, hardness)); + } + if (hp != null && hp.value != null) { + if (isPresent(brokenThreshold)) { + parts.add(String.format( + name.isEmpty() ? "**%sHP (BT)** %d (%d) %s" : "**%sHP** %d (BT %d) %s", + name, hp.value, brokenThreshold, hp.formattedNotes())); + } else { + parts.add(String.format("**%sHP** %s", name, hp)); + } + } + return parts.toString(); + } + + /** + * HP value and associated notes. Referencing this directly provides a default representation, e.g. + *

+ * 15 to destroy a head (head regrowth) + *

+ * + * @param value The HP value itself + * @param abilities Any abilities associated with the HP + * @param notes Any notes associated with the HP. + */ + @TemplateData + public record HpStat(Integer value, List notes, List abilities) implements Pf2eStat { + public HpStat(Integer value) { + this(value, null); + } + + public HpStat(Integer value, String note) { + this(value, note == null || note.isBlank() ? List.of() : List.of(note), List.of()); + } + + @Override + public String toString() { + String formattedNotes = formattedNotes(); + return value + (formattedNotes.isEmpty() ? "" : " " + formattedNotes); + } + + /** Returns any notes and abilities formatted as a string. */ + @Override + public String formattedNotes() { + StringJoiner formatted = new StringJoiner(" "); + if (isPresent(notes)) { + formatted.add(String.join(", ", notes)); + } + if (isPresent(abilities)) { + abilities.stream().map(s -> String.format("(%s)", s)).forEachOrdered(formatted::add); + } + return formatted.toString(); + } + } +} diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataSkillBonus.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataSkillBonus.java index 35efb4c28..565def625 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataSkillBonus.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataSkillBonus.java @@ -1,13 +1,11 @@ package dev.ebullient.convert.tools.pf2e.qute; -import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.stream.Collectors; import java.util.stream.Stream; -import dev.ebullient.convert.qute.QuteUtil; +import dev.ebullient.convert.tools.pf2e.Pf2eTypeReader; import io.quarkus.qute.TemplateData; /** @@ -19,30 +17,33 @@ *

* * @param name The name of the skill - * @param standardBonus The standard bonus associated with this skill + * @param value The standard bonus associated with this skill * @param otherBonuses Any additional bonuses, as a map of descriptions to bonuses. Iterate over all map entries to * display the values: {@code {#each resource.skills.otherBonuses}{it.key}: {it.value}{/each}} - * @param note Any note associated with this skill bonus + * @param notes Any notes associated with this skill bonus */ @TemplateData public record QuteDataSkillBonus( String name, - Integer standardBonus, + Integer value, Map otherBonuses, - String note) implements QuteUtil { + List notes) implements Pf2eTypeReader.Pf2eStat { public QuteDataSkillBonus(String name, Integer standardBonus) { - this(name, standardBonus, null, null); + this(name, standardBonus, Map.of(), List.of()); } + /** Return the standard bonus and any other conditional bonuses. */ @Override - public String toString() { - return Stream.of( - List.of(String.format("%s %+d", name, standardBonus)), - otherBonuses.entrySet().stream().map(e -> String.format("(%+d %s)", e.getValue(), e.getKey())).toList(), - note == null ? List. of() : List.of("(" + note + ")")) - .flatMap(Collection::stream) - .filter(Objects::nonNull) + public String bonus() { + return Stream.concat( + Stream.of(Pf2eTypeReader.Pf2eStat.super.bonus()), + otherBonuses.entrySet().stream().map(e -> String.format("(%+d %s)", e.getValue(), e.getKey()))) .collect(Collectors.joining(" ")); } + + @Override + public String toString() { + return String.join(" ", name, bonus(), formattedNotes()).trim(); + } } diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataSpeed.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataSpeed.java new file mode 100644 index 000000000..70561051a --- /dev/null +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDataSpeed.java @@ -0,0 +1,60 @@ +package dev.ebullient.convert.tools.pf2e.qute; + +import dev.ebullient.convert.tools.pf2e.Pf2eTypeReader; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.StringJoiner; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * + * @param value The land speed in feet + * @param otherSpeeds Other speeds, as a map of (name, speed in feet) + * @param notes Any speed-related notes + * @param abilities Any speed-related abilities + */ +public record QuteDataSpeed( + Integer value, Map otherSpeeds, List notes, + List abilities) implements Pf2eTypeReader.Pf2eStat { + + public void addAbility(String ability) { + abilities.add(ability); + } + + /** Return formatted notes and abilities. e.g. {@code (note) (another note); ability, another ability} */ + @Override + public String formattedNotes() { + return Stream.of(Pf2eTypeReader.Pf2eStat.super.formattedNotes(), String.join(", ", abilities)) + .filter(this::isPresent) + .collect(Collectors.joining("; ")); + } + + /** Return formatted speeds as a string, starting with land speed. e.g. {@code 10 feet, swim 20 feet} */ + public String formattedSpeeds() { + StringJoiner speeds = new StringJoiner(", ") + .add(Optional.ofNullable(value).map(n -> String.format("%d feet", value)).orElse("no land speed")); + otherSpeeds.entrySet().stream() + .map(e -> String.format("%s %d feet", e.getKey(), e.getValue())) + .forEach(speeds::add); + return speeds.toString(); + } + + /** + * Examples: + *
+ * 10 feet, swim 20 feet (some note); some ability + *
+ *
+ * 10 feet, swim 20 feet, some ability + *
+ * + */ + @Override + public String toString() { + return Stream.of(formattedSpeeds(), formattedNotes()) + .filter(this::isPresent).collect(Collectors.joining(notes.isEmpty() ? ", " : " ")); + } +} diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDeity.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDeity.java index 50f53ca4b..0aeb3793c 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDeity.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteDeity.java @@ -4,7 +4,10 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.StringJoiner; import java.util.stream.Collectors; +import java.util.stream.Stream; import dev.ebullient.convert.qute.NamedText; import dev.ebullient.convert.qute.QuteUtil; @@ -128,33 +131,41 @@ public String toString() { public static class QuteDivineAvatar implements QuteUtil { public String preface; public String name; - public String speed; + /** The avatar's speed, as a {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataSpeed QuteDataSpeed} */ + public QuteDataSpeed speed; + public List abilities; public String shield; public List melee; public List ranged; public Collection ability; + /** + * Example: + * + *
+ *

+ * Cee-el-aye When casting the avatar spell, a worshipper of the Cee-el-aye typically begins reading + * entirely too much JSON, and gains the following additional abilities. + *

+ *

+ * Speed 50 feet, burrow 70 feet, immune to petrified; + * shield (15 Hardness, can't be damaged); + * Melee polytool (reach 15 feet), Damage 6d6+6 slashing; + * Ranged pull request (nonlethal, reach 9358 miles), Damage 3d6+3 mental plus commit + * history; + * Commit History A creature who reviews the pull request must spend the next 1d4 hours reading code. + *

+ */ + @Override public String toString() { - List lines = new ArrayList<>(); - if (isPresent(preface)) { - lines.add(preface); - lines.add(""); - } - lines.add("```ad-embed-avatar"); - lines.add("title: " + name); - lines.add(""); - if (isPresent(speed)) { - lines.add("- **Speed**: " + speed); - } - if (shield != null) { - lines.add("- **Shield**: " + shield); - } - melee.forEach(m -> lines.add("- " + m)); - ranged.forEach(r -> lines.add("- " + r)); - ability.forEach(a -> lines.add("- " + a)); - lines.add("```"); - - return String.join("\n", lines); + String speedText = speed == null ? "" + : "Speed %s%s".formatted(speed.formattedSpeeds(), + speed.formattedNotes().isEmpty() ? "" : (", " + speed.formattedNotes())); + return "**" + name + "** " + Stream.of(List.of(speedText, shield), melee, ranged, ability) + .flatMap(Collection::stream) + .filter(this::isPresent) + .map(Object::toString) + .collect(Collectors.joining("; ")); } } @@ -168,7 +179,7 @@ public String toString() { *

*/ @TemplateData - public static class QuteDivineAvatarAction { + public static class QuteDivineAvatarAction implements QuteUtil { public String actionType; public String name; public QuteDataActivity activityType; @@ -178,17 +189,10 @@ public static class QuteDivineAvatarAction { public String note; public String toString() { - List parts = new ArrayList<>(); - parts.add(String.format("**%s**: %s", actionType, activityType)); - parts.add(name); - if (!traits.isEmpty()) { - parts.add("(" + String.join(", ", traits) + "),"); - } - parts.add("**Damage** " + damage); - if (note != null) { - parts.add(note); - } - return String.join(" ", parts); + StringJoiner traitText = new StringJoiner(", ", " (", ")").setEmptyValue(""); + traits.stream().filter(this::isPresent).forEach(traitText::add); + return "**%s**: %s %s%s, **Damage** %s %s".formatted( + actionType, activityType, name, traitText, damage, Optional.ofNullable(note).orElse("")).trim(); } } diff --git a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteItem.java b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteItem.java index 9384f697d..4c094a5b7 100644 --- a/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteItem.java +++ b/src/main/java/dev/ebullient/convert/tools/pf2e/qute/QuteItem.java @@ -140,35 +140,28 @@ public String toString() { } /** - * Pf2eTools item shield attributes - * + * Pf2eTools item shield attributes. When referenced directly, provides a default formatting, e.g. *

- * This data object provides a default mechanism for creating - * a marked up string based on the attributes that are present. - * To use it, reference it directly: `{resource.shield}`. + * AC Bonus +2; Speed Penalty —; Hardness 3; HP (BT) 12 (6) *

+ * + * @param ac AC bonus for the shield, as {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataArmorClass QuteDataArmorClass} + * (required) + * @param hpHardnessBt HP, hardness, and broken threshold of the shield, as + * {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardnessBt QuteDataHpHardnessBt} + * (required) + * @param speedPenalty Speed penalty for the shield, as a formatted string (string, required) */ @TemplateData - public static class QuteItemShieldData implements QuteUtil { - /** {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataArmorClass Shield armor class details} */ - public QuteDataArmorClass ac; - /** {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataHpHardness Shield hardness details} */ - public QuteDataHpHardness hpHardness; - /** Formatted string. Speed penalty */ - public String speedPenalty; + public record QuteItemShieldData( + QuteDataArmorClass ac, + QuteDataHpHardnessBt hpHardnessBt, + String speedPenalty) implements QuteUtil { + @Override public String toString() { - List parts = new ArrayList<>(); - if (ac != null) { - parts.add(ac.toString()); - } - if (hpHardness != null) { - parts.add(hpHardness.toString()); - } - if (isPresent(speedPenalty)) { - parts.add("**Speed Penalty** " + speedPenalty); - } - return "- " + String.join("; ", parts); + return String.join("; ", + "**AC Bonus** " + ac.bonus(), "**Speed Penalty** " + speedPenalty, hpHardnessBt.toString()); } } @@ -185,6 +178,8 @@ public String toString() { public static class QuteItemArmorData implements QuteUtil { /** {@link dev.ebullient.convert.tools.pf2e.qute.QuteDataArmorClass Item armor class details} */ public QuteDataArmorClass ac; + /** Formatted string. Dex cap */ + public String dexCap; /** Formatted string. Armor strength */ public String strength; /** Formatted string. Check penalty */ @@ -194,6 +189,10 @@ public static class QuteItemArmorData implements QuteUtil { public String toString() { List parts = new ArrayList<>(); + parts.add("**AC Bonus** " + ac.bonus()); + if (isPresent(dexCap)) { + parts.add("**Dex Cap** " + dexCap); + } if (isPresent(strength)) { parts.add("**Strength** " + strength); } @@ -203,8 +202,7 @@ public String toString() { if (isPresent(speedPenalty)) { parts.add("**Speed Penalty** " + speedPenalty); } - return "- " + ac.toString() - + "\n- " + String.join("; ", parts); + return "- " + String.join("; ", parts); } } diff --git a/src/main/resources/templates/toolsPf2e/creature2md.txt b/src/main/resources/templates/toolsPf2e/creature2md.txt index 26ba841a7..22cc82ed0 100644 --- a/src/main/resources/templates/toolsPf2e/creature2md.txt +++ b/src/main/resources/templates/toolsPf2e/creature2md.txt @@ -14,29 +14,36 @@ aliases: {/each} {/if} --- -# {resource.name} *Creature {resource.level}* +# {resource.name} *Creature {resource.level}* {#if resource.traits}{#each resource.traits}{it} {/each}{/if} ```ad-statblock -{#if resource.perception != null} -- **Perception** {#if resource.perception >= 0}+{#else}-{/if}{resource.perception} +{#if resource.perception} +- **Perception** {resource.perception.asBonus}; {#each resource.senses}{it}{#if it_hasNext}, {/if}{/each} {/if}{#if resource.languages} - **Languages** {resource.languages} {/if}{#if resource.skills} - **Skills** {resource.skills} -{/if}{#if resource.defenses} +{/if} +- {#each resource.abilityMods.keys}**{it.capitalized}** {resource.abilityMods.get(it).asBonus}{#if it_hasNext}, {/if}{/each} +{#if resource.items} +- **Items** {#each resource.items}{it}{#if it_hasNext}, {/if}{/each} +{/if} + +{#if resource.defenses} {resource.defenses} {/if} + +- **Speed** {resource.speed} ``` ^statblock -{#if resource.hasSections} +{#if resource.hasSections} ## Summary -{/if} -{#if resource.description} - +{/if}{#if resource.description} {resource.description} - +{/if}{#if resource.text} +{it} {/if} *Source: {resource.source}* diff --git a/src/main/resources/templates/toolsPf2e/deity2md.txt b/src/main/resources/templates/toolsPf2e/deity2md.txt index 5ed16c837..9de690fd9 100644 --- a/src/main/resources/templates/toolsPf2e/deity2md.txt +++ b/src/main/resources/templates/toolsPf2e/deity2md.txt @@ -33,12 +33,29 @@ aliases: ["{resource.name}"{#each resource.aliases}, "{it}"{/each}] ## Devotee benefits {resource.cleric} -{/if}{#if resource.avatar } +{/if} +{#if resource.avatar}{#let avatar=resource.avatar} +{#if avatar.preface} +{avatar.preface} + +{/if} +```ad-embed-avatar +title: {avatar.name} -{resource.avatar} +{#if avatar.speed or avatar.shield} +- {#if avatar.speed}Speed {avatar.speed}{#if avatar.shield}; {/if}{/if}{avatar.shield or ''} +{/if} +{#each avatar.melee} +- {it} +{/each}{#each avatar.ranged} +- {it} +{/each}{#each avatar.ability} +- {it} +{/each} +``` {/if}{#if resource.intercession } -## Divine intercession +## Divine intercession *Source: {resource.intercession.source}* {resource.intercession.flavor}