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}