Updates monster imports from tetra cube.

This commit is contained in:
2026-09-07 20:10:36 -07:00
parent 044202cb3a
commit 2af18cbf1d
10 changed files with 10846 additions and 21 deletions

View File

@@ -0,0 +1,663 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "0ab331e246de46304f847d7ed2d10124",
"entities": [
{
"tableName": "monsters",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `size` TEXT NOT NULL DEFAULT '', `type` TEXT NOT NULL DEFAULT '', `subtype` TEXT NOT NULL DEFAULT '', `alignment` TEXT NOT NULL DEFAULT '', `strength_score` INTEGER NOT NULL DEFAULT 10, `strength_saving_throw_advantage` TEXT DEFAULT 'none', `strength_saving_throw_proficiency` TEXT DEFAULT 'none', `dexterity_score` INTEGER NOT NULL DEFAULT 10, `dexterity_saving_throw_advantage` TEXT DEFAULT 'none', `dexterity_saving_throw_proficiency` TEXT DEFAULT 'none', `constitution_score` INTEGER NOT NULL DEFAULT 10, `constitution_saving_throw_advantage` TEXT DEFAULT 'none', `constitution_saving_throw_proficiency` TEXT DEFAULT 'none', `intelligence_score` INTEGER NOT NULL DEFAULT 10, `intelligence_saving_throw_advantage` TEXT DEFAULT 'none', `intelligence_saving_throw_proficiency` TEXT DEFAULT 'none', `wisdom_score` INTEGER NOT NULL DEFAULT 10, `wisdom_saving_throw_advantage` TEXT DEFAULT 'none', `wisdom_saving_throw_proficiency` TEXT DEFAULT 'none', `charisma_score` INTEGER NOT NULL DEFAULT 10, `charisma_saving_throw_advantage` TEXT DEFAULT 'none', `charisma_saving_throw_proficiency` TEXT DEFAULT 'none', `armor_type` TEXT DEFAULT 'none', `shield_bonus` INTEGER NOT NULL DEFAULT 0, `natural_armor_bonus` INTEGER NOT NULL DEFAULT 0, `other_armor_description` TEXT DEFAULT '', `hit_dice` INTEGER NOT NULL DEFAULT 1, `has_custom_hit_points` INTEGER NOT NULL, `custom_hit_points_description` TEXT DEFAULT '', `walk_speed` INTEGER NOT NULL DEFAULT 0, `burrow_speed` INTEGER NOT NULL DEFAULT 0, `climb_speed` INTEGER NOT NULL DEFAULT 0, `fly_speed` INTEGER NOT NULL DEFAULT 0, `can_hover` INTEGER NOT NULL DEFAULT false, `swim_speed` INTEGER NOT NULL DEFAULT 0, `has_custom_speed` INTEGER NOT NULL DEFAULT false, `custom_speed_description` TEXT, `challenge_rating` TEXT DEFAULT '1', `custom_challenge_rating_description` TEXT DEFAULT '', `custom_proficiency_bonus` INTEGER NOT NULL DEFAULT 0, `telepathy_range` INTEGER NOT NULL DEFAULT 0, `understands_but_description` TEXT DEFAULT '', `senses` TEXT DEFAULT '[]', `skills` TEXT DEFAULT '[]', `damage_immunities` TEXT DEFAULT '[]', `damage_resistances` TEXT DEFAULT '[]', `damage_vulnerabilities` TEXT DEFAULT '[]', `condition_immunities` TEXT DEFAULT '[]', `languages` TEXT DEFAULT '[]', `abilities` TEXT DEFAULT '[]', `actions` TEXT DEFAULT '[]', `reactions` TEXT DEFAULT '[]', `lair_actions` TEXT DEFAULT '[]', `legendary_actions` TEXT DEFAULT '[]', `regional_actions` TEXT DEFAULT '[]', `source_url` TEXT DEFAULT '', `bonus_actions` TEXT DEFAULT '[]', `mythic_actions` TEXT DEFAULT '[]', `legendary_actions_description` TEXT DEFAULT '', `lair_actions_description` TEXT DEFAULT '', `lair_actions_end_note` TEXT DEFAULT '', `regional_actions_description` TEXT DEFAULT '', `regional_actions_end_note` TEXT DEFAULT '', `mythic_actions_description` TEXT DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subtype",
"columnName": "subtype",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "alignment",
"columnName": "alignment",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "strengthScore",
"columnName": "strength_score",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "10"
},
{
"fieldPath": "strengthSavingThrowAdvantage",
"columnName": "strength_saving_throw_advantage",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "strengthSavingThrowProficiency",
"columnName": "strength_saving_throw_proficiency",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "dexterityScore",
"columnName": "dexterity_score",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "10"
},
{
"fieldPath": "dexteritySavingThrowAdvantage",
"columnName": "dexterity_saving_throw_advantage",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "dexteritySavingThrowProficiency",
"columnName": "dexterity_saving_throw_proficiency",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "constitutionScore",
"columnName": "constitution_score",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "10"
},
{
"fieldPath": "constitutionSavingThrowAdvantage",
"columnName": "constitution_saving_throw_advantage",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "constitutionSavingThrowProficiency",
"columnName": "constitution_saving_throw_proficiency",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "intelligenceScore",
"columnName": "intelligence_score",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "10"
},
{
"fieldPath": "intelligenceSavingThrowAdvantage",
"columnName": "intelligence_saving_throw_advantage",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "intelligenceSavingThrowProficiency",
"columnName": "intelligence_saving_throw_proficiency",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "wisdomScore",
"columnName": "wisdom_score",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "10"
},
{
"fieldPath": "wisdomSavingThrowAdvantage",
"columnName": "wisdom_saving_throw_advantage",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "wisdomSavingThrowProficiency",
"columnName": "wisdom_saving_throw_proficiency",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "charismaScore",
"columnName": "charisma_score",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "10"
},
{
"fieldPath": "charismaSavingThrowAdvantage",
"columnName": "charisma_saving_throw_advantage",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "charismaSavingThrowProficiency",
"columnName": "charisma_saving_throw_proficiency",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "armorType",
"columnName": "armor_type",
"affinity": "TEXT",
"defaultValue": "'none'"
},
{
"fieldPath": "shieldBonus",
"columnName": "shield_bonus",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "naturalArmorBonus",
"columnName": "natural_armor_bonus",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "otherArmorDescription",
"columnName": "other_armor_description",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "hitDice",
"columnName": "hit_dice",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "hasCustomHP",
"columnName": "has_custom_hit_points",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "customHPDescription",
"columnName": "custom_hit_points_description",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "walkSpeed",
"columnName": "walk_speed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "burrowSpeed",
"columnName": "burrow_speed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "climbSpeed",
"columnName": "climb_speed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "flySpeed",
"columnName": "fly_speed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "canHover",
"columnName": "can_hover",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "swimSpeed",
"columnName": "swim_speed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "hasCustomSpeed",
"columnName": "has_custom_speed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "customSpeedDescription",
"columnName": "custom_speed_description",
"affinity": "TEXT"
},
{
"fieldPath": "challengeRating",
"columnName": "challenge_rating",
"affinity": "TEXT",
"defaultValue": "'1'"
},
{
"fieldPath": "customChallengeRatingDescription",
"columnName": "custom_challenge_rating_description",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "customProficiencyBonus",
"columnName": "custom_proficiency_bonus",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "telepathyRange",
"columnName": "telepathy_range",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "understandsButDescription",
"columnName": "understands_but_description",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "senses",
"columnName": "senses",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "skills",
"columnName": "skills",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "damageImmunities",
"columnName": "damage_immunities",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "damageResistances",
"columnName": "damage_resistances",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "damageVulnerabilities",
"columnName": "damage_vulnerabilities",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "conditionImmunities",
"columnName": "condition_immunities",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "languages",
"columnName": "languages",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "abilities",
"columnName": "abilities",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "actions",
"columnName": "actions",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "reactions",
"columnName": "reactions",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "lairActions",
"columnName": "lair_actions",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "legendaryActions",
"columnName": "legendary_actions",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "regionalActions",
"columnName": "regional_actions",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "sourceUrl",
"columnName": "source_url",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "bonusActions",
"columnName": "bonus_actions",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "mythicActions",
"columnName": "mythic_actions",
"affinity": "TEXT",
"defaultValue": "'[]'"
},
{
"fieldPath": "legendaryActionsDescription",
"columnName": "legendary_actions_description",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "lairActionsDescription",
"columnName": "lair_actions_description",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "lairActionsEndNote",
"columnName": "lair_actions_end_note",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "regionalActionsDescription",
"columnName": "regional_actions_description",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "regionalActionsEndNote",
"columnName": "regional_actions_end_note",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "mythicActionsDescription",
"columnName": "mythic_actions_description",
"affinity": "TEXT",
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "monsters_fts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT, `size` TEXT, `type` TEXT, `subtype` TEXT, `alignment` TEXT, content=`monsters`)",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT"
},
{
"fieldPath": "subtype",
"columnName": "subtype",
"affinity": "TEXT"
},
{
"fieldPath": "alignment",
"columnName": "alignment",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "monsters",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_monsters_fts_BEFORE_UPDATE BEFORE UPDATE ON `monsters` BEGIN DELETE FROM `monsters_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_monsters_fts_BEFORE_DELETE BEFORE DELETE ON `monsters` BEGIN DELETE FROM `monsters_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_monsters_fts_AFTER_UPDATE AFTER UPDATE ON `monsters` BEGIN INSERT INTO `monsters_fts`(`docid`, `name`, `size`, `type`, `subtype`, `alignment`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`size`, NEW.`type`, NEW.`subtype`, NEW.`alignment`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_monsters_fts_AFTER_INSERT AFTER INSERT ON `monsters` BEGIN INSERT INTO `monsters_fts`(`docid`, `name`, `size`, `type`, `subtype`, `alignment`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`size`, NEW.`type`, NEW.`subtype`, NEW.`alignment`); END"
]
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `description` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "collection_monsters",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collection_id` TEXT NOT NULL, `monster_id` TEXT NOT NULL, `ordinal` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`collection_id`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`monster_id`) REFERENCES `monsters`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collection_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "monsterId",
"columnName": "monster_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ordinal",
"columnName": "ordinal",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collection_monsters_collection_id",
"unique": false,
"columnNames": [
"collection_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_monsters_collection_id` ON `${TABLE_NAME}` (`collection_id`)"
},
{
"name": "index_collection_monsters_monster_id",
"unique": false,
"columnNames": [
"monster_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_monsters_monster_id` ON `${TABLE_NAME}` (`monster_id`)"
}
],
"foreignKeys": [
{
"table": "collections",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collection_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "monsters",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"monster_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "dashboard_monsters",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `monster_id` TEXT NOT NULL, `ordinal` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`monster_id`) REFERENCES `monsters`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "monsterId",
"columnName": "monster_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ordinal",
"columnName": "ordinal",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_dashboard_monsters_monster_id",
"unique": false,
"columnNames": [
"monster_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_dashboard_monsters_monster_id` ON `${TABLE_NAME}` (`monster_id`)"
}
],
"foreignKeys": [
{
"table": "monsters",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"monster_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0ab331e246de46304f847d7ed2d10124')"
]
}
}

View File

@@ -20,7 +20,7 @@ import com.majinnaibu.monstercards.models.DashboardMonster;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.MonsterFTS;
@Database(entities = {Monster.class, MonsterFTS.class, Collection.class, CollectionMonster.class, DashboardMonster.class}, version = 5)
@Database(entities = {Monster.class, MonsterFTS.class, Collection.class, CollectionMonster.class, DashboardMonster.class}, version = 6)
@TypeConverters({
ArmorTypeConverter.class,
ChallengeRatingConverter.class,

View File

@@ -52,6 +52,20 @@ public class MonsterCardsApplication extends Application {
database.execSQL("INSERT INTO dashboard_monsters(monster_id, ordinal) SELECT id, 0 FROM monsters");
}
};
private static final Migration MIGRATION_5_6 = new Migration(5, 6) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE monsters ADD COLUMN `source_url` TEXT DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `bonus_actions` TEXT DEFAULT '[]'");
database.execSQL("ALTER TABLE monsters ADD COLUMN `mythic_actions` TEXT DEFAULT '[]'");
database.execSQL("ALTER TABLE monsters ADD COLUMN `legendary_actions_description` TEXT DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `lair_actions_description` TEXT DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `lair_actions_end_note` TEXT DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `regional_actions_description` TEXT DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `regional_actions_end_note` TEXT DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `mythic_actions_description` TEXT DEFAULT ''");
}
};
private MonsterRepository m_monsterLibraryRepository;
@@ -75,6 +89,7 @@ public class MonsterCardsApplication extends Application {
.addMigrations(MIGRATION_2_3)
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_5_6)
.fallbackToDestructiveMigrationOnDowngrade()
// .fallbackToDestructiveMigration()
.build();

View File

@@ -36,7 +36,9 @@ public class TetraCubeMonsterImporter implements EntityImporter<Monster> {
return false;
}
JsonObject rootDict = element.getAsJsonObject();
return rootDict.has("hitDice") || rootDict.has("armorName") || rootDict.has("strPoints");
return rootDict.has("hitDice") || rootDict.has("armorName") || rootDict.has("strPoints")
|| rootDict.has("bonusActions") || rootDict.has("isLegendary") || rootDict.has("isMythic")
|| rootDict.has("legendariesDescription");
} catch (Exception e) {
return false;
}
@@ -85,10 +87,46 @@ public class TetraCubeMonsterImporter implements EntityImporter<Monster> {
monster.abilities = getListOfTraits(rootDict, "abilities");
monster.actions = getListOfTraits(rootDict, "actions");
monster.bonusActions = getListOfTraits(rootDict, "bonusActions");
monster.reactions = getListOfTraits(rootDict, "reactions");
monster.legendaryActions = getListOfTraits(rootDict, "legendaries");
monster.mythicActions = getListOfTraits(rootDict, "mythics");
monster.lairActions = getListOfTraits(rootDict, "lairs");
monster.regionalActions = getListOfTraits(rootDict, "regionals");
monster.legendaryActionsDescription = getString(rootDict, "legendariesDescription");
if (!monster.legendaryActionsDescription.isEmpty()) {
monster.legendaryActions.add(0, new Trait("Legendary Actions", monster.legendaryActionsDescription));
}
monster.mythicActionsDescription = getString(rootDict, "mythicDescription");
if (!monster.mythicActionsDescription.isEmpty() || !monster.mythicActions.isEmpty()) {
if (!monster.mythicActionsDescription.isEmpty()) {
monster.legendaryActions.add(new Trait("Mythic Actions", monster.mythicActionsDescription));
}
for (Trait mythic : monster.mythicActions) {
String traitName = mythic.name;
if (!traitName.toLowerCase(Locale.ROOT).startsWith("mythic action")) {
traitName = "Mythic Action: " + traitName;
}
monster.legendaryActions.add(new Trait(traitName, mythic.description));
}
}
monster.lairActionsDescription = getString(rootDict, "lairDescription");
monster.lairActionsEndNote = getString(rootDict, "lairDescriptionEnd");
String fullLairDesc = combineDescriptions(monster.lairActionsDescription, monster.lairActionsEndNote);
if (!fullLairDesc.isEmpty()) {
monster.lairActions.add(0, new Trait("Lair Actions", fullLairDesc));
}
monster.regionalActionsDescription = getString(rootDict, "regionalDescription");
monster.regionalActionsEndNote = getString(rootDict, "regionalDescriptionEnd");
String fullRegionalDesc = combineDescriptions(monster.regionalActionsDescription, monster.regionalActionsEndNote);
if (!fullRegionalDesc.isEmpty()) {
monster.regionalActions.add(0, new Trait("Regional Effects", fullRegionalDesc));
}
addSavingThrows(monster, rootDict);
monster.skills = getSetOfSkills(rootDict);
monster.damageImmunities = getSetOfDamageTypes(rootDict, "damageTypes", "i");
@@ -104,6 +142,17 @@ public class TetraCubeMonsterImporter implements EntityImporter<Monster> {
return monster;
}
@NonNull
private static String combineDescriptions(String desc1, String desc2) {
if (!desc1.isEmpty() && !desc2.isEmpty()) {
return desc1 + "\n\n" + desc2;
} else if (!desc1.isEmpty()) {
return desc1;
} else {
return desc2;
}
}
private static String getString(JsonObject dict, String name) {
return getString(dict, name, "");
}
@@ -169,7 +218,11 @@ public class TetraCubeMonsterImporter implements EntityImporter<Monster> {
private static void addSense(Monster monster, JsonObject root, String name) {
int distance = getInt(root, name);
if (distance > 0) {
monster.senses.add(formatDistance(name, distance));
String senseStr = formatDistance(name, distance);
if ("blindsight".equals(name) && getBool(root, "blind")) {
senseStr += " (blind beyond this radius)";
}
monster.senses.add(senseStr);
}
}

View File

@@ -206,6 +206,33 @@ public class Monster {
@ColumnInfo(name = "regional_actions", defaultValue = "[]")
public List<Trait> regionalActions;
@ColumnInfo(name = "source_url", defaultValue = "")
public String sourceUrl;
@ColumnInfo(name = "bonus_actions", defaultValue = "[]")
public List<Trait> bonusActions;
@ColumnInfo(name = "mythic_actions", defaultValue = "[]")
public List<Trait> mythicActions;
@ColumnInfo(name = "legendary_actions_description", defaultValue = "")
public String legendaryActionsDescription;
@ColumnInfo(name = "lair_actions_description", defaultValue = "")
public String lairActionsDescription;
@ColumnInfo(name = "lair_actions_end_note", defaultValue = "")
public String lairActionsEndNote;
@ColumnInfo(name = "regional_actions_description", defaultValue = "")
public String regionalActionsDescription;
@ColumnInfo(name = "regional_actions_end_note", defaultValue = "")
public String regionalActionsEndNote;
@ColumnInfo(name = "mythic_actions_description", defaultValue = "")
public String mythicActionsDescription;
public Monster() {
id = UUID.randomUUID();
name = "";
@@ -266,6 +293,15 @@ public class Monster {
lairActions = new ArrayList<>();
legendaryActions = new ArrayList<>();
regionalActions = new ArrayList<>();
bonusActions = new ArrayList<>();
mythicActions = new ArrayList<>();
sourceUrl = "";
legendaryActionsDescription = "";
lairActionsDescription = "";
lairActionsEndNote = "";
regionalActionsDescription = "";
regionalActionsEndNote = "";
mythicActionsDescription = "";
}
public String getMeta() {
@@ -783,6 +819,22 @@ public class Monster {
return actionDescriptions;
}
public List<String> getBonusActionDescriptions() {
ArrayList<String> actionDescriptions = new ArrayList<>();
for (Trait action : bonusActions) {
actionDescriptions.add(getPlaceholderReplacedText(String.format("__%s__ %s", action.name, action.description)));
}
return actionDescriptions;
}
public List<String> getMythicActionDescriptions() {
ArrayList<String> actionDescriptions = new ArrayList<>();
for (Trait action : mythicActions) {
actionDescriptions.add(getPlaceholderReplacedText(String.format("__%s__ %s", action.name, action.description)));
}
return actionDescriptions;
}
public List<String> getReactionDescriptions() {
ArrayList<String> actionDescriptions = new ArrayList<>();
for (Trait action : reactions) {

View File

@@ -33,6 +33,8 @@ public class TetraCubeMonsterImporterTest {
+ "\"cr\": \"1/4\""
+ "}";
private static final String ANCIENT_BLACK_DRAGON_JSON = "{\"name\":\"Ancient Black Dragon\",\"size\":\"gargantuan\",\"type\":\"Dragon\",\"tag\":\"\",\"alignment\":\"chaotic evil\",\"hitDice\":\"21\",\"armorName\":\"natural armor\",\"shieldBonus\":0,\"natArmorBonus\":10,\"otherArmorDesc\":\"22 (natural armor)\",\"speed\":\"40\",\"burrowSpeed\":\"0\",\"climbSpeed\":\"0\",\"flySpeed\":\"80\",\"hover\":false,\"swimSpeed\":\"40\",\"customHP\":false,\"customSpeed\":false,\"hpText\":\"367 (21d20 + 147)\",\"speedDesc\":\"40 ft., fly 80 ft., swim 40 ft.\",\"strPoints\":\"27\",\"dexPoints\":\"14\",\"conPoints\":\"25\",\"intPoints\":\"16\",\"wisPoints\":\"15\",\"chaPoints\":\"19\",\"blindsight\":\"60\",\"blind\":false,\"darkvision\":\"120\",\"tremorsense\":\"0\",\"truesight\":\"0\",\"telepathy\":0,\"cr\":\"21\",\"customCr\":\"21 (33,000 XP)\",\"customProf\":7,\"isLegendary\":true,\"legendariesDescription\":\"The dragon can take 3 legendary actions, choosing from the options below. Only one legendary action option can be used at a time and only at the end of another creature's turn. The dragon regains spent legendary actions at the start of its turn.\",\"isLair\":true,\"lairDescription\":\"When fighting inside its lair, the ancient black dragon can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the ancient black dragon can take one lair action to cause one of the following effects:\",\"lairDescriptionEnd\":\"The goblin can't repeat an effect until they have all been used, and it can't use the same effect two rounds in a row.\",\"isMythic\":true,\"mythicDescription\":\"If the ancient black dragon's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.\",\"isRegional\":true,\"regionalDescription\":\"The region containing the ancient black dragon's lair is warped by the creature's presence, which creates one or more of the following effects:\",\"regionalDescriptionEnd\":\"If the ancient black dragon dies, the first two effects fade over the course of 3d10 days.\",\"properties\":[],\"abilities\":[{\"name\":\"Amphibious\",\"desc\":\"The dragon can breathe air and water.\"},{\"name\":\"Legendary Resistance (3/Day)\",\"desc\":\"If the dragon fails a saving throw, it can choose to succeed instead.\"}],\"actions\":[{\"name\":\"Multiattack\",\"desc\":\"The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws.\"},{\"name\":\"Bite\",\"desc\":\"_Melee Weapon Attack:_ +15 to hit, reach 15 ft., one target. _Hit:_ 19 (2d10 + 8) piercing damage plus 9 (2d8) acid damage.\"},{\"name\":\"Claw\",\"desc\":\"_Melee Weapon Attack:_ +15 to hit, reach 10 ft., one target. _Hit:_ 15 (2d6 + 8) slashing damage.\"},{\"name\":\"Tail\",\"desc\":\"_Melee Weapon Attack:_ +15 to hit, reach 20 ft., one target. _Hit:_ 17 (2d8 + 8) bludgeoning damage.\"},{\"name\":\"Frightful Presence\",\"desc\":\"Each creature of the dragon's choice that is within 120 feet of the dragon and aware of it must succeed on a DC 19 Wisdom saving throw or become frightened for 1 minute. A creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success. If a creature's saving throw is successful or the effect ends for it, the creature is immune to the dragon's Frightful Presence for the next 24 hours.\"},{\"name\":\"Acid Breath (Recharge 5-6)\",\"desc\":\"The dragon exhales acid in a 90-foot line that is 10 feet wide. Each creature in that line must make a DC 22 Dexterity saving throw, taking 67 (15d8) acid damage on a failed save, or half as much damage on a successful one.\"}],\"bonusActions\":[],\"reactions\":[],\"legendaries\":[{\"name\":\"Detect\",\"desc\":\"The dragon makes a Wisdom (Perception) check.\"},{\"name\":\"Tail Attack\",\"desc\":\"The dragon makes a tail attack.\"},{\"name\":\"Wing Attack (Costs 2 Actions)\",\"desc\":\"The dragon beats its wings. Each creature within 15 ft. of the dragon must succeed on a DC 23 Dexterity saving throw or take 15 (2d6 + 8) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed.\"}],\"mythics\":[],\"lairs\":[],\"regionals\":[],\"sthrows\":[{\"name\":\"dex\",\"order\":1},{\"name\":\"con\",\"order\":2},{\"name\":\"wis\",\"order\":4},{\"name\":\"cha\",\"order\":5}],\"skills\":[{\"name\":\"perception\",\"stat\":\"wis\",\"note\":\" (ex)\"},{\"name\":\"stealth\",\"stat\":\"dex\"}],\"damagetypes\":[{\"name\":\"acid\",\"note\":\" (Immune)\",\"type\":\"i\"}],\"specialdamage\":[],\"conditions\":[],\"languages\":[{\"name\":\"Common\",\"speaks\":true},{\"name\":\"Draconic\",\"speaks\":true}],\"understandsBut\":\"\",\"shortName\":\"\",\"pluralName\":\"\",\"doubleColumns\":false,\"separationPoint\":5,\"damage\":[]}";
@Test
public void canImport_validTetraCubeJson_returnsTrue() {
assertTrue(importer.canImport(SAMPLE_TETRACUBE_JSON));
@@ -68,6 +70,106 @@ public class TetraCubeMonsterImporterTest {
assertEquals(14, monster.dexterityScore);
}
@Test
public void parse_bonusActions_populatesBonusActions() throws Exception {
String json = "{"
+ "\"name\": \"Goblin Boss\","
+ "\"hitDice\": 4,"
+ "\"bonusActions\": [{\"name\": \"Nimble Escape\", \"desc\": \"Disengage or Hide as bonus action.\"}]"
+ "}";
Monster monster = importer.parse(json);
assertNotNull(monster);
assertEquals(1, monster.bonusActions.size());
assertEquals("Nimble Escape", monster.bonusActions.get(0).name);
assertEquals("Disengage or Hide as bonus action.", monster.bonusActions.get(0).description);
}
@Test
public void parse_blindsightWithBlindFlag_appendsNote() throws Exception {
String json = "{"
+ "\"name\": \"Grimlock\","
+ "\"hitDice\": 3,"
+ "\"blindsight\": 30,"
+ "\"blind\": true"
+ "}";
Monster monster = importer.parse(json);
assertNotNull(monster);
assertTrue(monster.senses.contains("blindsight 30 ft. (blind beyond this radius)"));
}
@Test
public void parse_legendaryAndMythicDescriptions_parsedCorrectly() throws Exception {
String json = "{"
+ "\"name\": \"Ancient Dragon\","
+ "\"hitDice\": 20,"
+ "\"legendariesDescription\": \"The dragon can take 3 legendary actions.\","
+ "\"mythicDescription\": \"If mythic trait is active...\","
+ "\"mythics\": [{\"name\": \"Grasp\", \"desc\": \"Grabs a creature.\"}]"
+ "}";
Monster monster = importer.parse(json);
assertNotNull(monster);
assertEquals(3, monster.legendaryActions.size());
assertEquals("Legendary Actions", monster.legendaryActions.get(0).name);
assertEquals("Mythic Actions", monster.legendaryActions.get(1).name);
assertEquals("Mythic Action: Grasp", monster.legendaryActions.get(2).name);
}
@Test
public void parse_lairAndRegionalDescriptions_parsedCorrectly() throws Exception {
String json = "{"
+ "\"name\": \"Lich\","
+ "\"hitDice\": 18,"
+ "\"lairDescription\": \"Lair intro.\","
+ "\"lairDescriptionEnd\": \"Lair end.\","
+ "\"regionalDescription\": \"Regional intro.\","
+ "\"regionalDescriptionEnd\": \"Regional end.\""
+ "}";
Monster monster = importer.parse(json);
assertNotNull(monster);
assertEquals(1, monster.lairActions.size());
assertEquals("Lair Actions", monster.lairActions.get(0).name);
assertEquals("Lair intro.\n\nLair end.", monster.lairActions.get(0).description);
assertEquals(1, monster.regionalActions.size());
assertEquals("Regional Effects", monster.regionalActions.get(0).name);
assertEquals("Regional intro.\n\nRegional end.", monster.regionalActions.get(0).description);
}
@Test
public void parse_ancientBlackDragonExport_populatesAllSections() throws Exception {
Monster monster = importer.parse(ANCIENT_BLACK_DRAGON_JSON);
assertNotNull(monster);
assertEquals("Ancient Black Dragon", monster.name);
assertEquals("gargantuan", monster.size);
assertEquals("Dragon", monster.type);
assertEquals("chaotic evil", monster.alignment);
assertEquals(27, monster.strengthScore);
// Abilities
assertEquals(2, monster.abilities.size());
assertEquals("Amphibious", monster.abilities.get(0).name);
// Actions
assertEquals(6, monster.actions.size());
assertEquals("Multiattack", monster.actions.get(0).name);
// Legendary + Mythic Actions
// 1 legendary intro + 3 legendary actions + 1 mythic intro = 5 items
assertEquals(5, monster.legendaryActions.size());
assertEquals("Legendary Actions", monster.legendaryActions.get(0).name);
assertEquals("Detect", monster.legendaryActions.get(1).name);
assertEquals("Mythic Actions", monster.legendaryActions.get(4).name);
// Lair Actions
assertEquals(1, monster.lairActions.size());
assertEquals("Lair Actions", monster.lairActions.get(0).name);
assertTrue(monster.lairActions.get(0).description.contains("invoke the ambient magic"));
// Regional Effects
assertEquals(1, monster.regionalActions.size());
assertEquals("Regional Effects", monster.regionalActions.get(0).name);
assertTrue(monster.regionalActions.get(0).description.contains("warped by the creature's presence"));
}
@Test
public void monsterImportHelper_fromJSON_delegatesToImporter() {
Monster monster = MonsterImportHelper.fromJSON(SAMPLE_TETRACUBE_JSON);

View File

@@ -106,32 +106,92 @@ Update `TetraCubeMonsterImporter` to support newer fields from the Tetra-cube ge
---
### Step 3: Import from D&D Beyond Character URL
### Step 3: Import from D&D Beyond URL & Web Services
Allow users to import monsters or characters directly from a D&D Beyond URL.
Allow users to import characters, monsters, and future entity types directly from D&D Beyond URLs using a multi-channel ingestion architecture.
- **Target URL Structure**:
`https://www.dndbeyond.com/characters/49074997` (where `49074997` is the internal character ID).
- **Service Endpoints**:
D&D Beyond character pages load data from background JSON endpoints such as:
`https://character-service.dndbeyond.com/character/v2/character/49074997`
- **New Importer Class**: `DnDBeyondImporter implements EntityImporter<Monster>`
- **Pipeline**:
1. Extract character ID (`49074997`) from user-provided D&D Beyond URL.
2. Perform an asynchronous HTTP request (via OkHttp/Retrofit) to fetch the character JSON payload.
3. Map D&D Beyond JSON fields (stats, modifiers, AC, HP, speed, actions, traits, spells) to our internal `Monster` memory model.
4. Pass the parsed `Monster` to `MonsterImportFragment` for review and persistence.
- **Primary Target Share Links**:
- Site Share Link: `https://www.dndbeyond.com/characters/49074997/s7sLyX` (primary link copied when tapping "Share" on D&D Beyond; contains numeric character ID `49074997` and share token `s7sLyX`).
- Standard Web Link: `https://www.dndbeyond.com/characters/49074997` (HTML page that loads background JavaScript to fetch stats).
- URL Extractor Regex: `https?://(?:www\.)?dndbeyond\.com/characters/(\d+)(?:/([a-zA-Z0-9]+))?`
- **URL Ingestion Channels**:
1. **Android Share Sheet (`ACTION_SEND`)**:
- Register `intent-filter` for `ACTION_SEND` with `text/plain` in `AndroidManifest.xml`.
- Tapping "Share" on D&D Beyond passes `https://www.dndbeyond.com/characters/49074997/s7sLyX` directly into Monster Cards.
2. **Android Web Link Chooser (`ACTION_VIEW`)**:
- Register `intent-filter` for `ACTION_VIEW` in `AndroidManifest.xml` with scheme `https`, host `www.dndbeyond.com`, and path prefixes `/characters/`, `/monsters/`, `/spells/`, etc.
- Allows Monster Cards to appear in the Android "Open with..." link chooser dialog.
3. **In-App Menu & Clipboard Auto-Detection**:
- Add an "Import from URL..." menu item in `LibraryFragment` and `MonsterImportFragment`.
- Displays a URL input dialog with automatic clipboard URL detection (`ClipboardManager`).
- **Known Endpoints & Web Asset Structure**:
- **Main Character Service Endpoint**:
- `https://character-service.dndbeyond.com/character/v5/character/49074997?includeCustomItems=true`
- Returns JSON payload containing full character statistics, attributes, modifiers, classes, race, inventory, and spells.
- Full reference sample JSON payload extracted to: [`docs/dndbeyond-character-sample.json`](file:///Users/tom/Projects/Apps/MonsterCards/docs/dndbeyond-character-sample.json).
- **Auxiliary Service Endpoints**:
- Vehicles: `https://character-service.dndbeyond.com/character/v5/vehicles?characterId=49074997`
- Vehicle Components: `https://character-service.dndbeyond.com/character/v5/vehicle/components?characterId=49074997`
- Known Infusions: `https://character-service.dndbeyond.com/character/v5/known-infusions?characterId=49074997`
- Infusion Items: `https://character-service.dndbeyond.com/character/v5/infusion/items?characterId=49074997`
- **Image & Avatar Assets**:
- Default Builder Avatar: `https://www.dndbeyond.com/Content/Skins/Waterdeep/images/characters/default-avatar-builder.png`
- Race Portrait Avatar: `portraitAvatarUrl` (e.g., `https://www.dndbeyond.com/avatars/2489/881/636680412207671648.jpeg`)
- Item / Attunement Icons: Returned in `inventory[].definition.avatarUrl` (e.g. `https://www.dndbeyond.com/avatars/19/144/636382339478303209.jpeg`, `https://www.dndbeyond.com/avatars/9249/564/637203446409923453.jpeg`).
- **Analytics / Telemetry**:
- `https://global.ketchcdn.com/web/v2/log...` (returns 204 No Content; user consent log endpoint ignored by importer).
- **Architecture (`DnDBeyondImporter implements EntityImporter<Monster>`)**:
- **URL Extractor**: Extracts `{characterId}` (`49074997`) from shared URLs (`https://www.dndbeyond.com/characters/49074997/s7sLyX`), trimming optional share tokens.
- **HTTP Service Client**: Performs an asynchronous HTTP GET request (via OkHttp/Retrofit) to `character-service.dndbeyond.com/character/v5/character/{characterId}?includeCustomItems=true`.
- **Domain Mapper**: Maps D&D Beyond JSON fields (stats, modifiers, AC, HP, speed, proficiencies, actions, bonus actions, reactions, spells, traits, avatar URLs) into the internal `Monster` domain model (`sourceUrl = "https://www.dndbeyond.com/characters/49074997/s7sLyX"`).
- **UI Preview**: Passes the generated `Monster` object to `MonsterImportFragment` for review and persistence.
---
### Step 4: Export to Custom Internal Format (Open5e Specification)
Implement export capability to output monsters and collections using our two-tier Open5e JSON specification (`/Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/import-export.md`).
Implement export capability to output monsters and collections using our two-tier Open5e JSON specification (detailed in [`/Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/import-export.md`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/import-export.md)).
- **New Class**: `Open5eExporter`
- **Output Architecture**:
- **Universal Envelope (`entity.json`)**: `uuid`, `ruleset_id: "open5e"`, `entity_type: "character"`, `display_name`, `version`, `tags`, `properties`.
- **Character Payload (`character.json`)**: Export ability scores, combat vitals (AC, HP formula, speed), proficiency bonuses, actions, reactions, legendary actions, features, and equipped items (`weapon.json`, `armor.json`, `shield.json`, `spell.json`).
#### Open5e Ruleset Architecture & Schema Specification
The Open5e ruleset is part of a modular, versioned ruleset framework located at [`/Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/). In the future, this ruleset will be embedded directly into the application assets and versioned as our canonical model reference.
1. **Ruleset & Entity Identifiers**:
- Every ruleset defines a short string ID (`ruleset_id: "open5e"`) and a unique UUID (`uuid: "7a35e4d2-f67b-4890-a292-6a7593c72b21"`), declared in the ruleset manifest [`manifest.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/manifest.json).
- Exported entities contain top-level identifiers (`uuid`, `ruleset_id`, `entity_type`, `display_name`) wrapped in a universal envelope.
- When importing a JSON payload like [`examples/character/goblin.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/examples/character/goblin.json), importers inspect `ruleset_id == "open5e"` to match the registered ruleset parser.
2. **Base Schemas ([`/Users/tom/Projects/TTRPG/CharacterDataFiles/schema/*.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/schema/))**:
- **[`schema/entity.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/schema/entity.json)**: The universal envelope schema wrapping all exported entities (`uuid`, `ruleset_id`, `entity_type`, `display_name`, `description`, `version`, `tags`, `template_id`, `properties`).
- **[`schema/manifest.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/schema/manifest.json)**: Schema defining ruleset manifest metadata, registered entity types, and sheet templates.
- **[`schema/entity-definition.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/schema/entity-definition.json)**: Meta-schema describing how entity types and property schemas are declared.
3. **Character Entity Schema ([`entities/character.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/entities/character.json))**:
- Defines the `properties` payload for Player Characters (PCs), NPCs, and Monsters/Creatures.
- Core fields in `properties`:
- **Identity & Vitals**: `size`, `type`, `subtype`, `alignment`, `armor_class`, `armor_description`, `hit_points` (`current`, `max`, `formula`, `hit_dice`), `speed`, `speed_desc`, `challenge_rating`, `cr`.
- **Ability Scores**: `abilities` (`strength`, `dexterity`, `constitution`, `intelligence`, `wisdom`, `charisma`).
- **Proficiencies & Senses**: `saving_throws`, `skills`, `senses`, `languages`.
- **Actions & Traits**: `traits`, `actions`, `reactions`, `legendary_actions`, `lair_actions`, `regional_effects`, and `spellcasting`.
4. **Rendering Templates (`template_id`)**:
- The `template_id` field in the universal envelope specifies how the entity should be rendered in external or host applications.
- Registered templates in [`manifest.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/manifest.json):
- `"stat_block"`: Monster Manual style stat block (`templates/stat_block.html`).
- `"character_card"`: Compact NPC summary card (`templates/character_card.html`).
- `"character_list_item"`: Fixed-height quick-reference list item (`templates/character_list_item.html`).
- `"character_sheet"` / `"character_sheet_alt"`: Full 5e interactive character sheets.
#### Exporter Output Requirements (`Open5eExporter`)
- **Envelope Generation**:
- Emits `$schema: "../../../schema/entity.json"`.
- Populates `uuid` (random UUID v4), `ruleset_id: "open5e"`, `entity_type: "character"`, `display_name: monster.name`, `template_id: "stat_block"`.
- **Payload Mapping**:
- Maps `Monster` fields to Open5e character properties in `properties` object conforming to [`entities/character.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/entities/character.json).
---
@@ -169,9 +229,30 @@ Extend the generic sharing feature with specialized, direct sharing channels (to
- [x] **Step 0**: Restrict app intent filters in `AndroidManifest.xml` (`.monster` & `.monster.txt`) and add runtime filename validation in `MainActivity.java`.
- [x] **Step 1**: Refactor import & conversion code into a shared `EntityImporter<T>` interface and `TetraCubeMonsterImporter` class.
- [ ] **Step 2**: Update Tetra-cube importer class to support the newest Tetra-cube format (`bonusActions`, `mythics`, `blind`, intro descriptions).
- [x] **Step 2**: Update Tetra-cube importer class to support the newest Tetra-cube format (`bonusActions`, `mythics`, `blind`, intro descriptions).
- [ ] **Step 3**: Import from D&D Beyond URL (`https://www.dndbeyond.com/characters/49074997` fetching from character service endpoint `character/v2/character/49074997`).
- [ ] **Step 4**: Export to internal format described by Open5e document (`Open5eExporter`).
- [ ] **Step 5**: Import from internal format described by Open5e document (`Open5eImporter`).
- [ ] **Step 6**: Generic Android Share button feature (`ACTION_SEND`).
- [ ] **Step 7**: Specific share targets (7.1 NFC, 7.2 Bluetooth, 7.3 Embedded Web URL).
---
## 4. Schema Planning & Text Formatting Notes
### 4.1 Schema Architecture Considerations
- **Unified Action Entity / Type Column**:
- Instead of maintaining separate `List<Trait>` columns for each category (`actions`, `reactions`, `legendaryActions`, `lairActions`, `regionalActions`, etc.), a unified `monster_actions` table (or model list) with an `action_type` column (`ABILITY`, `ACTION`, `BONUS_ACTION`, `REACTION`, `LEGENDARY_ACTION`, `MYTHIC_ACTION`, `LAIR_ACTION`, `REGIONAL_EFFECT`) simplifies Room DB queries and enables a single reusable editor UI component.
- **Section Intro & End Note Metadata**:
- Store section-level intro text and end notes (e.g. `legendaryActionsDescription`, `lairActionsDescription`, `lairActionsEndNote`, `regionalActionsDescription`, `regionalActionsEndNote`, `mythicActionsDescription`) as dedicated metadata fields on the `Monster` entity rather than as artificial action traits.
### 4.2 Markdown & Content Formatting Support
- **Supported Markdown Syntax**:
- **Emphasis**: Italics (`_text_` or `*text*`) and Bold (`__text__` or `**text**`).
- **Lists**: Bulleted lists (`- `, `* `) and Numbered lists (`1. `, `2. `).
- **External Links**: Standard markdown links `[Text](https://...)`.
- **Internal Links (Future Deep Linking)**: Custom URI scheme `[Label](mc://<type>/<id>)` (e.g., `[Fireball](mc://spell/fireball)`) reserved for in-app navigation.
- **Data Model & Import/Export Handling**:
- All text fields across domain models, Room persistence, and import/export payloads store raw CommonMark markdown strings.
- Importers preserve raw markdown syntax as exported by source formats (Tetra-cube, Open5e, D&D Beyond).
- UI rendering layers handle conversion from CommonMark to Android Spanned text at display time.

View File

@@ -0,0 +1 @@
{"name":"Ancient Black Dragon","size":"gargantuan","type":"Dragon","tag":"","alignment":"chaotic evil","hitDice":"21","armorName":"natural armor","shieldBonus":0,"natArmorBonus":10,"otherArmorDesc":"22 (natural armor)","speed":"40","burrowSpeed":"0","climbSpeed":"0","flySpeed":"80","hover":false,"swimSpeed":"40","customHP":false,"customSpeed":false,"hpText":"367 (21d20 + 147)","speedDesc":"40 ft., fly 80 ft., swim 40 ft.","strPoints":"27","dexPoints":"14","conPoints":"25","intPoints":"16","wisPoints":"15","chaPoints":"19","blindsight":"60","blind":false,"darkvision":"120","tremorsense":"0","truesight":"0","telepathy":0,"cr":"21","customCr":"21 (33,000 XP)","customProf":7,"isLegendary":true,"legendariesDescription":"The dragon can take 3 legendary actions, choosing from the options below. Only one legendary action option can be used at a time and only at the end of another creature's turn. The dragon regains spent legendary actions at the start of its turn.","isLair":true,"lairDescription":"When fighting inside its lair, the ancient black dragon can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the ancient black dragon can take one lair action to cause one of the following effects:","lairDescriptionEnd":"The goblin can't repeat an effect until they have all been used, and it can't use the same effect two rounds in a row.","isMythic":true,"mythicDescription":"If the ancient black dragon's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":true,"regionalDescription":"The region containing the ancient black dragon's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the ancient black dragon dies, the first two effects fade over the course of 3d10 days.","properties":[],"abilities":[{"name":"Amphibious","desc":"The dragon can breathe air and water."},{"name":"Legendary Resistance (3/Day)","desc":"If the dragon fails a saving throw, it can choose to succeed instead."}],"actions":[{"name":"Multiattack","desc":"The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws."},{"name":"Bite","desc":"_Melee Weapon Attack:_ +15 to hit, reach 15 ft., one target. _Hit:_ 19 (2d10 + 8) piercing damage plus 9 (2d8) acid damage."},{"name":"Claw","desc":"_Melee Weapon Attack:_ +15 to hit, reach 10 ft., one target. _Hit:_ 15 (2d6 + 8) slashing damage."},{"name":"Tail","desc":"_Melee Weapon Attack:_ +15 to hit, reach 20 ft., one target. _Hit:_ 17 (2d8 + 8) bludgeoning damage."},{"name":"Frightful Presence","desc":"Each creature of the dragon's choice that is within 120 feet of the dragon and aware of it must succeed on a DC 19 Wisdom saving throw or become frightened for 1 minute. A creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success. If a creature's saving throw is successful or the effect ends for it, the creature is immune to the dragon's Frightful Presence for the next 24 hours."},{"name":"Acid Breath (Recharge 5-6)","desc":"The dragon exhales acid in a 90-foot line that is 10 feet wide. Each creature in that line must make a DC 22 Dexterity saving throw, taking 67 (15d8) acid damage on a failed save, or half as much damage on a successful one."}],"bonusActions":[],"reactions":[],"legendaries":[{"name":"Detect","desc":"The dragon makes a Wisdom (Perception) check."},{"name":"Tail Attack","desc":"The dragon makes a tail attack."},{"name":"Wing Attack (Costs 2 Actions)","desc":"The dragon beats its wings. Each creature within 15 ft. of the dragon must succeed on a DC 23 Dexterity saving throw or take 15 (2d6 + 8) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed."}],"mythics":[],"lairs":[],"regionals":[],"sthrows":[{"name":"dex","order":1},{"name":"con","order":2},{"name":"wis","order":4},{"name":"cha","order":5}],"skills":[{"name":"perception","stat":"wis","note":" (ex)"},{"name":"stealth","stat":"dex"}],"damagetypes":[{"name":"acid","note":" (Immune)","type":"i"}],"specialdamage":[],"conditions":[],"languages":[{"name":"Common","speaks":true},{"name":"Draconic","speaks":true}],"understandsBut":"","shortName":"","pluralName":"","doubleColumns":false,"separationPoint":5,"damage":[]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long