Compare commits

...

14 Commits

212 changed files with 33313 additions and 567 deletions

30
Android/.gitignore vendored
View File

@@ -1,16 +1,20 @@
*.iml .artifacts
.gradle .gradle
/local.properties .idea/appInsightsSettings.xml
/.idea/caches .idea/caches
/.idea/libraries .idea/compiler.xml
/.idea/modules.xml .idea/deploymentTargetSelector.xml
/.idea/workspace.xml .idea/deviceManager.xml
/.idea/navEditor.xml .idea/gradle.xml
/.idea/assetWizardSettings.xml .idea/markdown.xml
/.idea/dictionaries .idea/migrations.xml
/build .idea/planningMode.xml
/captures .idea/studiobot.xml
.idea/workspace.xml
.DS_Store
.externalNativeBuild .externalNativeBuild
.cxx .cxx
/app/debug *.iml
/app/release build
captures
local.properties

3
Android/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
Android/.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

23
Android/.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Android vitals">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="com.planbgamestudios.downtosize" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="SEVEN_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="PLATFORM" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="1.8" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings>
</option>
</component>
</project>

64
Android/.idea/misc.xml generated
View File

@@ -1,49 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="NullableNotNullManager"> <component name="NullableNotNullManager">
<option name="myDefaultNullable" value="androidx.annotation.Nullable" /> <option name="myDefaultNullable" value="androidx.annotation.Nullable" />
<option name="myDefaultNotNull" value="androidx.annotation.NonNull" /> <option name="myDefaultNotNull" value="androidx.annotation.NonNull" />
<option name="myOrdered" value="true" />
<option name="myNullables"> <option name="myNullables">
<value> <value>
<list size="14"> <list size="17">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.Nullable" /> <item index="0" class="java.lang.String" itemvalue="androidx.annotation.Nullable" />
<item index="1" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" /> <item index="1" class="java.lang.String" itemvalue="io.reactivex.annotations.Nullable" />
<item index="2" class="java.lang.String" itemvalue="android.support.annotation.Nullable" /> <item index="2" class="java.lang.String" itemvalue="io.reactivex.rxjava3.annotations.Nullable" />
<item index="3" class="java.lang.String" itemvalue="androidx.annotation.Nullable" /> <item index="3" class="java.lang.String" itemvalue="android.support.annotation.Nullable" />
<item index="4" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNullable" /> <item index="4" class="java.lang.String" itemvalue="android.annotation.Nullable" />
<item index="5" class="java.lang.String" itemvalue="com.android.annotations.Nullable" /> <item index="5" class="java.lang.String" itemvalue="com.android.annotations.Nullable" />
<item index="6" class="java.lang.String" itemvalue="javax.annotation.Nullable" /> <item index="6" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNullable" />
<item index="7" class="java.lang.String" itemvalue="javax.annotation.CheckForNull" /> <item index="7" class="java.lang.String" itemvalue="org.jspecify.annotations.Nullable" />
<item index="8" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.Nullable" /> <item index="8" class="java.lang.String" itemvalue="org.jetbrains.annotations.Nullable" />
<item index="9" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableDecl" /> <item index="9" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.Nullable" />
<item index="10" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableType" /> <item index="10" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableDecl" />
<item index="11" class="java.lang.String" itemvalue="org.eclipse.jdt.annotation.Nullable" /> <item index="11" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableType" />
<item index="12" class="java.lang.String" itemvalue="io.reactivex.annotations.Nullable" /> <item index="12" class="java.lang.String" itemvalue="org.eclipse.jdt.annotation.Nullable" />
<item index="13" class="java.lang.String" itemvalue="io.reactivex.rxjava3.annotations.Nullable" /> <item index="13" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" />
<item index="14" class="java.lang.String" itemvalue="jakarta.annotation.Nullable" />
<item index="15" class="java.lang.String" itemvalue="javax.annotation.Nullable" />
<item index="16" class="java.lang.String" itemvalue="javax.annotation.CheckForNull" />
</list> </list>
</value> </value>
</option> </option>
<option name="myNotNulls"> <option name="myNotNulls">
<value> <value>
<list size="13"> <list size="16">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.NotNull" /> <item index="0" class="java.lang.String" itemvalue="androidx.annotation.NonNull" />
<item index="1" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.NonNull" /> <item index="1" class="java.lang.String" itemvalue="io.reactivex.annotations.NonNull" />
<item index="2" class="java.lang.String" itemvalue="android.support.annotation.NonNull" /> <item index="2" class="java.lang.String" itemvalue="io.reactivex.rxjava3.annotations.NonNull" />
<item index="3" class="java.lang.String" itemvalue="androidx.annotation.NonNull" /> <item index="3" class="java.lang.String" itemvalue="android.support.annotation.NonNull" />
<item index="4" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNonNull" /> <item index="4" class="java.lang.String" itemvalue="android.annotation.NonNull" />
<item index="5" class="java.lang.String" itemvalue="com.android.annotations.NonNull" /> <item index="5" class="java.lang.String" itemvalue="com.android.annotations.NonNull" />
<item index="6" class="java.lang.String" itemvalue="javax.annotation.Nonnull" /> <item index="6" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNonNull" />
<item index="7" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" /> <item index="7" class="java.lang.String" itemvalue="org.jspecify.annotations.NonNull" />
<item index="8" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullDecl" /> <item index="8" class="java.lang.String" itemvalue="org.jetbrains.annotations.NotNull" />
<item index="9" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullType" /> <item index="9" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" />
<item index="10" class="java.lang.String" itemvalue="org.eclipse.jdt.annotation.NonNull" /> <item index="10" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullDecl" />
<item index="11" class="java.lang.String" itemvalue="io.reactivex.annotations.NonNull" /> <item index="11" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullType" />
<item index="12" class="java.lang.String" itemvalue="io.reactivex.rxjava3.annotations.NonNull" /> <item index="12" class="java.lang.String" itemvalue="org.eclipse.jdt.annotation.NonNull" />
<item index="13" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.NonNull" />
<item index="14" class="java.lang.String" itemvalue="jakarta.annotation.Nonnull" />
<item index="15" class="java.lang.String" itemvalue="javax.annotation.Nonnull" />
</list> </list>
</value> </value>
</option> </option>
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -3,7 +3,14 @@
<component name="RunConfigurationProducerService"> <component name="RunConfigurationProducerService">
<option name="ignoredProducers"> <option name="ignoredProducers">
<set> <set>
<option value="com.android.tools.idea.compose.preview.runconfiguration.ComposePreviewRunConfigurationProducer" /> <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" /> <option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" /> <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" /> <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />

68
Android/.idea/runConfigurations/app.xml generated Normal file
View File

@@ -0,0 +1,68 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="app" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false" focusToolWindowBeforeRun="true">
<module name="MonsterCards.app" />
<option name="ANDROID_RUN_CONFIGURATION_SCHEMA_VERSION" value="1" />
<option name="DEPLOY" value="true" />
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
<option name="DEPLOY_AS_INSTANT" value="false" />
<option name="ARTIFACT_NAME" value="" />
<option name="PM_INSTALL_OPTIONS" value="" />
<option name="ALL_USERS" value="false" />
<option name="ALWAYS_INSTALL_WITH_PM" value="false" />
<option name="ALLOW_ASSUME_VERIFIED" value="false" />
<option name="CLEAR_APP_STORAGE" value="false" />
<option name="DYNAMIC_FEATURES_DISABLED_LIST" value="" />
<option name="ACTIVITY_EXTRA_FLAGS" value="" />
<option name="MODE" value="default_activity" />
<option name="RESTORE_ENABLED" value="false" />
<option name="RESTORE_FILE" value="" />
<option name="RESTORE_FRESH_INSTALL_ONLY" value="false" />
<option name="CLEAR_LOGCAT" value="true" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="true" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
</Hybrid>
<Java>
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
</Java>
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="LeakCanary" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>
<option name="DEEP_LINK" value="" />
<option name="ACTIVITY" value="" />
<option name="ACTIVITY_CLASS" value="" />
<option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
<option name="SKIP_ACTIVITY_VALIDATION" value="false" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

2
Android/.idea/vcs.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component> </component>
</project> </project>

View File

@@ -11,22 +11,21 @@ if (propertiesFile.exists()) {
def appCenterLocalSecret = properties.getProperty('appCenter.localSecret') def appCenterLocalSecret = properties.getProperty('appCenter.localSecret')
def appCenterEnvSecret = System.getenv('APPCENTER_SECRET') def appCenterEnvSecret = System.getenv('APPCENTER_SECRET')
def appCenterSecret = appCenterLocalSecret != null ? appCenterLocalSecret : appCenterEnvSecret != null ? appCenterEnvSecret : "" def appCenterSecret = appCenterLocalSecret != null ? appCenterLocalSecret : appCenterEnvSecret != null ? appCenterEnvSecret : ""
def appCenterSdkVersion = '3.3.0' def appCenterSdkVersion = '5.0.4'
def nav_version = '2.3.5' def nav_version = '2.9.8'
def room_version = '2.3.0' def room_version = '2.8.4'
def rxjava_version = '3.0.0' def rxjava_version = '3.1.8'
def flipper_version = '0.87.0' def rxandroid_version = '3.0.2'
def soloader_version = '0.10.1' def gson_version = '2.11.0'
def gson_version = '2.8.6'
android { android {
compileSdkVersion 30 namespace "com.majinnaibu.monstercards"
buildToolsVersion '30.0.3' compileSdk 35
defaultConfig { defaultConfig {
applicationId "com.majinnaibu.monstercards" applicationId "com.majinnaibu.monstercards"
minSdkVersion 22 minSdkVersion 23
targetSdkVersion 30 targetSdk 35
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
buildConfigField "String", "APPCENTER_SECRET", "\"${appCenterSecret}\"" buildConfigField "String", "APPCENTER_SECRET", "\"${appCenterSecret}\""
@@ -57,11 +56,18 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_21
} }
buildFeatures { buildFeatures {
viewBinding true viewBinding true
buildConfig true
}
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
} }
} }
@@ -88,7 +94,7 @@ dependencies {
// Room DB // Room DB
implementation "io.reactivex.rxjava3:rxjava:$rxjava_version" implementation "io.reactivex.rxjava3:rxjava:$rxjava_version"
implementation "io.reactivex.rxjava3:rxandroid:$rxjava_version" implementation "io.reactivex.rxjava3:rxandroid:$rxandroid_version"
implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-rxjava3:$room_version" implementation "androidx.room:room-rxjava3:$room_version"
@@ -98,11 +104,6 @@ dependencies {
debugImplementation "com.microsoft.appcenter:appcenter-analytics:${appCenterSdkVersion}" debugImplementation "com.microsoft.appcenter:appcenter-analytics:${appCenterSdkVersion}"
debugImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}" debugImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}"
// Flipper
debugImplementation "com.facebook.flipper:flipper:$flipper_version"
debugImplementation "com.facebook.soloader:soloader:$soloader_version"
releaseImplementation "com.facebook.flipper:flipper-noop:$flipper_version"
// Other 3rd Party // Other 3rd Party
implementation 'com.atlassian.commonmark:commonmark:0.15.2' implementation 'com.atlassian.commonmark:commonmark:0.15.2'
implementation "com.google.code.gson:gson:$gson_version" implementation "com.google.code.gson:gson:$gson_version"

View File

@@ -0,0 +1,554 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "ca0ad042eed557daa5ff0ada5de6596c",
"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 '[]', 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": "'[]'"
}
],
"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"
]
}
]
}
],
"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, 'ca0ad042eed557daa5ff0ada5de6596c')"
]
}
}

View File

@@ -0,0 +1,609 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "6d497d0f0530c0f46bed1d57492e940e",
"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 '[]', 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": "'[]'"
}
],
"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, '6d497d0f0530c0f46bed1d57492e940e')"
]
}
}

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

@@ -0,0 +1,768 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "38f6bbd6f1e298b20e17b3a08fbc7f70",
"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 '', `player_name` TEXT NOT NULL DEFAULT '', `background` TEXT NOT NULL DEFAULT '', `personality_traits` TEXT NOT NULL DEFAULT '', `ideals` TEXT NOT NULL DEFAULT '', `bonds` TEXT NOT NULL DEFAULT '', `flaws` TEXT NOT NULL DEFAULT '', `age` TEXT NOT NULL DEFAULT '', `height` TEXT NOT NULL DEFAULT '', `weight` TEXT NOT NULL DEFAULT '', `eyes` TEXT NOT NULL DEFAULT '', `skin` TEXT NOT NULL DEFAULT '', `hair` TEXT NOT NULL DEFAULT '', `appearance` TEXT NOT NULL DEFAULT '', `backstory` TEXT NOT NULL DEFAULT '', `allies_and_organizations` 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": "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": "''"
},
{
"fieldPath": "playerName",
"columnName": "player_name",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "background",
"columnName": "background",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "personalityTraits",
"columnName": "personality_traits",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "ideals",
"columnName": "ideals",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "bonds",
"columnName": "bonds",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "flaws",
"columnName": "flaws",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "age",
"columnName": "age",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "eyes",
"columnName": "eyes",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "skin",
"columnName": "skin",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "hair",
"columnName": "hair",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "appearance",
"columnName": "appearance",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "backstory",
"columnName": "backstory",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "alliesAndOrganizations",
"columnName": "allies_and_organizations",
"affinity": "TEXT",
"notNull": true,
"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, '38f6bbd6f1e298b20e17b3a08fbc7f70')"
]
}
}

View File

@@ -0,0 +1,768 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "38f6bbd6f1e298b20e17b3a08fbc7f70",
"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 '', `player_name` TEXT NOT NULL DEFAULT '', `background` TEXT NOT NULL DEFAULT '', `personality_traits` TEXT NOT NULL DEFAULT '', `ideals` TEXT NOT NULL DEFAULT '', `bonds` TEXT NOT NULL DEFAULT '', `flaws` TEXT NOT NULL DEFAULT '', `age` TEXT NOT NULL DEFAULT '', `height` TEXT NOT NULL DEFAULT '', `weight` TEXT NOT NULL DEFAULT '', `eyes` TEXT NOT NULL DEFAULT '', `skin` TEXT NOT NULL DEFAULT '', `hair` TEXT NOT NULL DEFAULT '', `appearance` TEXT NOT NULL DEFAULT '', `backstory` TEXT NOT NULL DEFAULT '', `allies_and_organizations` 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": "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": "''"
},
{
"fieldPath": "playerName",
"columnName": "player_name",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "background",
"columnName": "background",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "personalityTraits",
"columnName": "personality_traits",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "ideals",
"columnName": "ideals",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "bonds",
"columnName": "bonds",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "flaws",
"columnName": "flaws",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "age",
"columnName": "age",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "eyes",
"columnName": "eyes",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "skin",
"columnName": "skin",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "hair",
"columnName": "hair",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "appearance",
"columnName": "appearance",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "backstory",
"columnName": "backstory",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "alliesAndOrganizations",
"columnName": "allies_and_organizations",
"affinity": "TEXT",
"notNull": true,
"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, '38f6bbd6f1e298b20e17b3a08fbc7f70')"
]
}
}

View File

@@ -1,42 +0,0 @@
package com.majinnaibu.monstercards.init;
import android.content.Context;
import android.os.Bundle;
import androidx.navigation.NavController;
import androidx.navigation.NavDestination;
import com.facebook.flipper.android.AndroidFlipperClient;
import com.facebook.flipper.android.utils.FlipperUtils;
import com.facebook.flipper.core.FlipperClient;
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.navigation.NavigationFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.soloader.SoLoader;
import com.google.gson.Gson;
import com.majinnaibu.monstercards.BuildConfig;
public class FlipperInitializer {
public static void init(Context ctx) {
SoLoader.init(ctx, false);
if (BuildConfig.DEBUG && FlipperUtils.shouldEnableFlipper(ctx)) {
final FlipperClient client = AndroidFlipperClient.getInstance(ctx);
client.addPlugin(new InspectorFlipperPlugin(ctx, DescriptorMapping.withDefaults()));
client.addPlugin(new DatabasesFlipperPlugin(ctx));
client.addPlugin(new SharedPreferencesFlipperPlugin(ctx));
client.addPlugin(NavigationFlipperPlugin.getInstance());
client.start();
}
}
public static void sendNavigationEvent(NavController controller, NavDestination destination, Bundle arguments) {
Gson gson = new Gson();
String json = gson.toJson(arguments != null ? arguments : new Bundle());
NavigationFlipperPlugin.getInstance().sendNavigationEvent(String.format("%s:%s", destination.getLabel(), json), null, null);
}
}

View File

@@ -15,45 +15,77 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:launchMode="singleTask"> android:launchMode="singleTask"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Intent filter for viewing/opening content:// URIs with binary/octet-stream MIME type (.monster) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="application/octet-stream" />
</intent-filter>
<!-- Intent filter for file:// URIs ending in .monster, .monster.txt, .card, or .card.txt -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" android:host="*" />
<data android:pathPattern=".*\\.monster" />
<data android:pathPattern=".*\\.monster\\.txt" />
<data android:pathPattern=".*\\.card" />
<data android:pathPattern=".*\\.card\\.txt" />
</intent-filter>
<!-- Intent filter for explicitly sharing files/text to Monster Cards -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
<data android:mimeType="application/octet-stream" />
</intent-filter> </intent-filter>
<!-- Intent filter for viewing dndbeyond.com character URLs directly in Monster Cards -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.EDIT" />
<action android:name="android.intent.action.PICK" />
<action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.INSERT_OR_EDIT" />
<category android:name="android.intent.category.ALTERNATIVE" />
<category android:name="android.intent.category.SELECTED_ALTERNATIVE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:mimeType="text/plain" <data android:scheme="https" />
android:scheme="content" /> <data android:host="www.dndbeyond.com" />
<data <data android:host="dndbeyond.com" />
android:mimeType="application/octet-stream" <data android:pathPrefix="/characters/" />
android:scheme="content" />
<data
android:mimeType="text/plain"
android:scheme="file" />
</intent-filter> </intent-filter>
<nav-graph android:value="@navigation/mobile_navigation" /> <nav-graph android:value="@navigation/mobile_navigation" />
</activity> </activity>
<activity
android:name="com.facebook.flipper.android.diagnostics.FlipperDiagnosticActivity" <provider
android:exported="true" /> android:name="androidx.core.content.FileProvider"
android:authorities="com.majinnaibu.monstercards.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -4,6 +4,8 @@ import androidx.room.Database;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
import androidx.room.TypeConverters; import androidx.room.TypeConverters;
import com.majinnaibu.monstercards.data.CollectionDAO;
import com.majinnaibu.monstercards.data.DashboardDAO;
import com.majinnaibu.monstercards.data.MonsterDAO; import com.majinnaibu.monstercards.data.MonsterDAO;
import com.majinnaibu.monstercards.data.converters.ArmorTypeConverter; import com.majinnaibu.monstercards.data.converters.ArmorTypeConverter;
import com.majinnaibu.monstercards.data.converters.ChallengeRatingConverter; import com.majinnaibu.monstercards.data.converters.ChallengeRatingConverter;
@@ -12,10 +14,13 @@ import com.majinnaibu.monstercards.data.converters.SetOfLanguageConverter;
import com.majinnaibu.monstercards.data.converters.SetOfSkillConverter; import com.majinnaibu.monstercards.data.converters.SetOfSkillConverter;
import com.majinnaibu.monstercards.data.converters.SetOfStringConverter; import com.majinnaibu.monstercards.data.converters.SetOfStringConverter;
import com.majinnaibu.monstercards.data.converters.UUIDConverter; import com.majinnaibu.monstercards.data.converters.UUIDConverter;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.CollectionMonster;
import com.majinnaibu.monstercards.models.DashboardMonster;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.MonsterFTS; import com.majinnaibu.monstercards.models.MonsterFTS;
@Database(entities = {Monster.class, MonsterFTS.class}, version = 3) @Database(entities = {Monster.class, MonsterFTS.class, Collection.class, CollectionMonster.class, DashboardMonster.class}, version = 8)
@TypeConverters({ @TypeConverters({
ArmorTypeConverter.class, ArmorTypeConverter.class,
ChallengeRatingConverter.class, ChallengeRatingConverter.class,
@@ -27,4 +32,6 @@ import com.majinnaibu.monstercards.models.MonsterFTS;
}) })
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract MonsterDAO monsterDAO(); public abstract MonsterDAO monsterDAO();
public abstract CollectionDAO collectionDAO();
public abstract DashboardDAO dashboardDAO();
} }

View File

@@ -1,8 +1,10 @@
package com.majinnaibu.monstercards; package com.majinnaibu.monstercards;
import android.content.Intent; import android.content.Intent;
import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.provider.OpenableColumns;
import android.view.MenuItem; import android.view.MenuItem;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -14,18 +16,27 @@ import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.AppBarConfiguration; import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI; import androidx.navigation.ui.NavigationUI;
import android.widget.Toast;
import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.gson.Gson;
import com.majinnaibu.monstercards.helpers.MonsterImportHelper;
import com.majinnaibu.monstercards.helpers.StringHelper; import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.init.AppCenterInitializer; import com.majinnaibu.monstercards.init.AppCenterInitializer;
import com.majinnaibu.monstercards.init.FlipperInitializer;
import com.majinnaibu.monstercards.utils.Logger; import com.majinnaibu.monstercards.utils.Logger;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
@Override @Override
@@ -55,7 +66,6 @@ public class MainActivity extends AppCompatActivity {
.build(); .build();
NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment); NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment);
NavController navController = navHostFragment.getNavController(); NavController navController = navHostFragment.getNavController();
navController.addOnDestinationChangedListener(FlipperInitializer::sendNavigationEvent);
NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration); NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);
NavigationUI.setupWithNavController(navView, navController); NavigationUI.setupWithNavController(navView, navController);
onNewIntent(getIntent()); onNewIntent(getIntent());
@@ -65,39 +75,117 @@ public class MainActivity extends AppCompatActivity {
protected void onNewIntent(Intent intent) { protected void onNewIntent(Intent intent) {
super.onNewIntent(intent); super.onNewIntent(intent);
if (intent == null) {
return;
}
String action = intent.getAction();
Bundle extras = intent.getExtras();
if ("android.intent.action.SEND".equals(action) && extras != null) {
CharSequence sharedText = extras.getCharSequence(Intent.EXTRA_TEXT);
if (sharedText != null) {
String textStr = sharedText.toString().trim();
DnDBeyondImporter dndImporter = new DnDBeyondImporter();
if (dndImporter.canImport(textStr)) {
importMonsterFromInputAndNavigate(textStr);
return;
}
}
} else if ("android.intent.action.VIEW".equals(action)) {
Uri dataUri = intent.getData();
if (dataUri != null) {
String dataStr = dataUri.toString().trim();
DnDBeyondImporter dndImporter = new DnDBeyondImporter();
if (dndImporter.canImport(dataStr)) {
importMonsterFromInputAndNavigate(dataStr);
return;
}
}
}
String json = readMonsterJSONFromIntent(intent); String json = readMonsterJSONFromIntent(intent);
if (!StringHelper.isNullOrEmpty(json)) { if (!StringHelper.isNullOrEmpty(json)) {
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment)); importMonsterFromInputAndNavigate(json);
NavController navController = navHostFragment.getNavController();
NavDirections action = MobileNavigationDirections.actionGlobalMonsterImportFragment(json);
navController.navigate(action);
} }
} }
public void importMonsterFromInputAndNavigate(@NonNull String input) {
Toast.makeText(this, R.string.toast_importing_url, Toast.LENGTH_SHORT).show();
Single.fromCallable(() -> MonsterImportHelper.fromJSON(input))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monster -> {
String serializedJson = new Gson().toJson(monster);
NavHostFragment navHostFragment = Objects.requireNonNull((NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment));
NavController navController = navHostFragment.getNavController();
NavDirections navAction = MobileNavigationDirections.actionGlobalMonsterImportFragment(serializedJson);
navController.navigate(navAction);
}, throwable -> {
Logger.logError("Failed to import monster from input", throwable);
Toast.makeText(this, R.string.failed_to_import_url, Toast.LENGTH_LONG).show();
});
}
@Nullable @Nullable
private String readMonsterJSONFromIntent(@NonNull Intent intent) { private String readMonsterJSONFromIntent(@NonNull Intent intent) {
String action = intent.getAction(); String action = intent.getAction();
Bundle extras = intent.getExtras(); Bundle extras = intent.getExtras();
String type = intent.getType();
String json;
Uri uri = null; Uri uri = null;
if ("android.intent.action.SEND".equals(action) && "text/plain".equals(type)) { if ("android.intent.action.SEND".equals(action)) {
uri = extras.getParcelable("android.intent.extra.STREAM"); if (extras != null) {
} else if ("android.intent.action.VIEW".equals(action) && ("text/plain".equals(type) || "application/octet-stream".equals(type))) { uri = extras.getParcelable(Intent.EXTRA_STREAM);
}
} else if ("android.intent.action.VIEW".equals(action) || "android.intent.action.EDIT".equals(action)) {
uri = intent.getData(); uri = intent.getData();
} else { } else {
Logger.logError(String.format("unexpected launch configuration action: %s, type: %s", action, type)); Logger.logError(String.format("unexpected launch configuration action: %s", action));
} }
if (uri == null) {
if (uri == null || !isMonsterFile(uri)) {
if (uri != null) {
Logger.logError("Ignored file because extension is not supported (.monster, .card): " + uri);
}
return null; return null;
} }
json = readContentsOfUri(uri);
String json = readContentsOfUri(uri);
if (StringHelper.isNullOrEmpty(json)) { if (StringHelper.isNullOrEmpty(json)) {
return null; return null;
} }
return json; return json;
} }
private boolean isMonsterFile(@NonNull Uri uri) {
String fileName = getFileNameFromUri(uri);
if (fileName == null) {
return false;
}
String lowerName = fileName.toLowerCase(Locale.ROOT);
return lowerName.endsWith(".monster") || lowerName.endsWith(".monster.txt")
|| lowerName.endsWith(".card") || lowerName.endsWith(".card.txt");
}
@Nullable
private String getFileNameFromUri(@NonNull Uri uri) {
String displayName = null;
if ("content".equals(uri.getScheme())) {
try (Cursor cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (nameIndex != -1) {
displayName = cursor.getString(nameIndex);
}
}
} catch (Exception e) {
Logger.logError("Error querying display name from content URI", e);
}
}
if (displayName == null) {
displayName = uri.getPath();
}
return displayName;
}
@Nullable @Nullable
private String readContentsOfUri(Uri uri) { private String readContentsOfUri(Uri uri) {
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();

View File

@@ -9,7 +9,6 @@ import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase; import androidx.sqlite.db.SupportSQLiteDatabase;
import com.majinnaibu.monstercards.data.MonsterRepository; import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.init.FlipperInitializer;
public class MonsterCardsApplication extends Application { public class MonsterCardsApplication extends Application {
@@ -36,6 +35,66 @@ public class MonsterCardsApplication extends Application {
database.execSQL("ALTER TABLE new_monsters RENAME TO monsters"); database.execSQL("ALTER TABLE new_monsters RENAME TO monsters");
} }
}; };
private static final Migration MIGRATION_3_4 = new Migration(3, 4) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE IF NOT EXISTS `collections` (`id` TEXT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `description` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))");
database.execSQL("CREATE TABLE IF NOT EXISTS `collection_monsters` (`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)");
database.execSQL("CREATE INDEX IF NOT EXISTS `index_collection_monsters_collection_id` ON `collection_monsters` (`collection_id`)");
database.execSQL("CREATE INDEX IF NOT EXISTS `index_collection_monsters_monster_id` ON `collection_monsters` (`monster_id`)");
}
};
private static final Migration MIGRATION_4_5 = new Migration(4, 5) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE IF NOT EXISTS `dashboard_monsters` (`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)");
database.execSQL("CREATE INDEX IF NOT EXISTS `index_dashboard_monsters_monster_id` ON `dashboard_monsters` (`monster_id`)");
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 static final Migration MIGRATION_6_7 = new Migration(6, 7) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE monsters ADD COLUMN `player_name` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `background` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `personality_traits` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `ideals` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `bonds` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `flaws` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `age` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `height` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `weight` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `eyes` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `skin` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `hair` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `appearance` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `backstory` TEXT NOT NULL DEFAULT ''");
database.execSQL("ALTER TABLE monsters ADD COLUMN `allies_and_organizations` TEXT NOT NULL DEFAULT ''");
}
};
private static final Migration MIGRATION_7_8 = new Migration(7, 8) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE IF NOT EXISTS `new_monsters` (`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 '', `player_name` TEXT NOT NULL DEFAULT '', `background` TEXT NOT NULL DEFAULT '', `personality_traits` TEXT NOT NULL DEFAULT '', `ideals` TEXT NOT NULL DEFAULT '', `bonds` TEXT NOT NULL DEFAULT '', `flaws` TEXT NOT NULL DEFAULT '', `age` TEXT NOT NULL DEFAULT '', `height` TEXT NOT NULL DEFAULT '', `weight` TEXT NOT NULL DEFAULT '', `eyes` TEXT NOT NULL DEFAULT '', `skin` TEXT NOT NULL DEFAULT '', `hair` TEXT NOT NULL DEFAULT '', `appearance` TEXT NOT NULL DEFAULT '', `backstory` TEXT NOT NULL DEFAULT '', `allies_and_organizations` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))");
database.execSQL("INSERT INTO `new_monsters` SELECT id, name, size, type, subtype, alignment, strength_score, strength_saving_throw_advantage, strength_saving_throw_proficiency, dexterity_score, dexterity_saving_throw_advantage, dexterity_saving_throw_proficiency, constitution_score, constitution_saving_throw_advantage, constitution_saving_throw_proficiency, intelligence_score, intelligence_saving_throw_advantage, intelligence_saving_throw_proficiency, wisdom_score, wisdom_saving_throw_advantage, wisdom_saving_throw_proficiency, charisma_score, charisma_saving_throw_advantage, charisma_saving_throw_proficiency, armor_type, shield_bonus, natural_armor_bonus, other_armor_description, hit_dice, has_custom_hit_points, custom_hit_points_description, walk_speed, burrow_speed, climb_speed, fly_speed, can_hover, swim_speed, has_custom_speed, custom_speed_description, challenge_rating, custom_challenge_rating_description, custom_proficiency_bonus, telepathy_range, understands_but_description, senses, skills, damage_immunities, damage_resistances, damage_vulnerabilities, condition_immunities, languages, abilities, actions, reactions, lair_actions, legendary_actions, regional_actions, source_url, bonus_actions, mythic_actions, legendary_actions_description, lair_actions_description, lair_actions_end_note, regional_actions_description, regional_actions_end_note, mythic_actions_description, COALESCE(player_name, ''), COALESCE(background, ''), COALESCE(personality_traits, ''), COALESCE(ideals, ''), COALESCE(bonds, ''), COALESCE(flaws, ''), COALESCE(age, ''), COALESCE(height, ''), COALESCE(weight, ''), COALESCE(eyes, ''), COALESCE(skin, ''), COALESCE(hair, ''), COALESCE(appearance, ''), COALESCE(backstory, ''), COALESCE(allies_and_organizations, '') FROM `monsters`");
database.execSQL("DROP TABLE `monsters`");
database.execSQL("ALTER TABLE `new_monsters` RENAME TO `monsters`");
}
};
private MonsterRepository m_monsterLibraryRepository; private MonsterRepository m_monsterLibraryRepository;
@@ -53,12 +112,15 @@ public class MonsterCardsApplication extends Application {
super.onCreate(); super.onCreate();
// Required initialization logic here! // Required initialization logic here!
FlipperInitializer.init(this);
// .fallbackToDestructiveMigration() // .fallbackToDestructiveMigration()
AppDatabase m_db = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "monsters") AppDatabase m_db = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "monsters")
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_2_3) .addMigrations(MIGRATION_2_3)
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.fallbackToDestructiveMigrationOnDowngrade() .fallbackToDestructiveMigrationOnDowngrade()
// .fallbackToDestructiveMigration() // .fallbackToDestructiveMigration()
.build(); .build();

View File

@@ -0,0 +1,55 @@
package com.majinnaibu.monstercards.data;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Update;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.CollectionMonster;
import com.majinnaibu.monstercards.models.CollectionWithCount;
import com.majinnaibu.monstercards.models.Monster;
import java.util.List;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
@Dao
public interface CollectionDAO {
@Query("SELECT * FROM collections")
Flowable<List<Collection>> getAll();
@Query("SELECT collections.*, COUNT(collection_monsters.id) as monsterCount FROM collections LEFT JOIN collection_monsters ON collections.id = collection_monsters.collection_id GROUP BY collections.id")
Flowable<List<CollectionWithCount>> getCollectionsWithCount();
@Query("SELECT * FROM collections WHERE id = :id LIMIT 1")
Flowable<Collection> getById(String id);
@Insert(onConflict = OnConflictStrategy.REPLACE)
Completable save(Collection... collections);
@Delete
Completable delete(Collection collection);
@Query("SELECT monsters.* FROM monsters INNER JOIN collection_monsters ON monsters.id = collection_monsters.monster_id WHERE collection_monsters.collection_id = :collectionId ORDER BY collection_monsters.ordinal ASC, collection_monsters.id ASC")
Flowable<List<Monster>> getMonstersForCollection(String collectionId);
@Query("SELECT * FROM collection_monsters WHERE collection_id = :collectionId ORDER BY ordinal ASC, id ASC")
Flowable<List<CollectionMonster>> getCollectionMonstersForCollection(String collectionId);
@Insert
Completable addMonsterToCollection(CollectionMonster... collectionMonsters);
@Query("DELETE FROM collection_monsters WHERE collection_id = :collectionId AND monster_id = :monsterId")
Completable removeMonsterFromCollection(String collectionId, String monsterId);
@Query("DELETE FROM collection_monsters WHERE id = :id")
Completable removeCollectionMonsterById(long id);
@Update
Completable updateCollectionMonsters(List<CollectionMonster> collectionMonsters);
}

View File

@@ -0,0 +1,39 @@
package com.majinnaibu.monstercards.data;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Update;
import com.majinnaibu.monstercards.models.DashboardMonster;
import com.majinnaibu.monstercards.models.Monster;
import java.util.List;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
@Dao
public interface DashboardDAO {
@Query("SELECT monsters.* FROM monsters INNER JOIN dashboard_monsters ON monsters.id = dashboard_monsters.monster_id ORDER BY dashboard_monsters.ordinal ASC, dashboard_monsters.id ASC")
Flowable<List<Monster>> getDashboardMonsters();
@Query("SELECT * FROM dashboard_monsters ORDER BY ordinal ASC, id ASC")
Flowable<List<DashboardMonster>> getDashboardMonsterEntries();
@Insert
Completable addMonsterToDashboard(DashboardMonster... dashboardMonsters);
@Query("DELETE FROM dashboard_monsters WHERE id = :id")
Completable removeDashboardMonsterById(long id);
@Query("DELETE FROM dashboard_monsters WHERE monster_id = :monsterId")
Completable removeMonsterFromDashboard(String monsterId);
@Query("DELETE FROM dashboard_monsters")
Completable clearDashboard();
@Update
Completable updateDashboardMonsters(List<DashboardMonster> dashboardMonsters);
}

View File

@@ -4,7 +4,12 @@ import androidx.annotation.NonNull;
import com.majinnaibu.monstercards.AppDatabase; import com.majinnaibu.monstercards.AppDatabase;
import com.majinnaibu.monstercards.helpers.StringHelper; import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.CollectionMonster;
import com.majinnaibu.monstercards.models.CollectionWithCount;
import com.majinnaibu.monstercards.models.DashboardMonster;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.SearchResultItem;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -47,6 +52,43 @@ public class MonsterRepository {
.observeOn(AndroidSchedulers.mainThread()); .observeOn(AndroidSchedulers.mainThread());
} }
public Flowable<List<SearchResultItem>> searchAll(String searchText) {
Flowable<List<Monster>> monstersFlowable = m_db.monsterDAO()
.getAll()
.map(monsters -> {
ArrayList<Monster> filteredMonsters = new ArrayList<>();
for (Monster monster : monsters) {
if (Helpers.monsterMatchesSearch(monster, searchText)) {
filteredMonsters.add(monster);
}
}
return (List<Monster>) filteredMonsters;
});
Flowable<List<Collection>> collectionsFlowable = m_db.collectionDAO()
.getAll()
.map(collections -> {
ArrayList<Collection> filteredCollections = new ArrayList<>();
for (Collection collection : collections) {
if (StringHelper.isNullOrEmpty(searchText) || StringHelper.containsCaseInsensitive(collection.name, searchText)) {
filteredCollections.add(collection);
}
}
return (List<Collection>) filteredCollections;
});
return Flowable.combineLatest(monstersFlowable, collectionsFlowable, (monsters, collections) -> {
ArrayList<SearchResultItem> results = new ArrayList<>();
for (Monster monster : monsters) {
results.add(new SearchResultItem(monster));
}
for (Collection collection : collections) {
results.add(new SearchResultItem(collection));
}
return (List<SearchResultItem>) results;
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
}
public Flowable<Monster> getMonster(@NonNull UUID monsterId) { public Flowable<Monster> getMonster(@NonNull UUID monsterId) {
return m_db.monsterDAO() return m_db.monsterDAO()
.loadAllByIds(new String[]{monsterId.toString()}) .loadAllByIds(new String[]{monsterId.toString()})
@@ -80,6 +122,144 @@ public class MonsterRepository {
return result; return result;
} }
public Flowable<List<Collection>> getCollections() {
return m_db.collectionDAO()
.getAll()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Flowable<List<CollectionWithCount>> getCollectionsWithCount() {
return m_db.collectionDAO()
.getCollectionsWithCount()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Flowable<Collection> getCollection(@NonNull UUID collectionId) {
return m_db.collectionDAO()
.getById(collectionId.toString())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Completable saveCollection(Collection collection) {
Completable result = m_db.collectionDAO().save(collection);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Completable deleteCollection(Collection collection) {
Completable result = m_db.collectionDAO().delete(collection);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Flowable<List<Monster>> getMonstersForCollection(@NonNull UUID collectionId) {
return m_db.collectionDAO()
.getMonstersForCollection(collectionId.toString())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Flowable<List<CollectionMonster>> getCollectionMonstersForCollection(@NonNull UUID collectionId) {
return m_db.collectionDAO()
.getCollectionMonstersForCollection(collectionId.toString())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Completable addMonsterToCollection(@NonNull UUID collectionId, @NonNull UUID monsterId) {
return addMonsterToCollection(collectionId, monsterId, 0);
}
public Completable addMonsterToCollection(@NonNull UUID collectionId, @NonNull UUID monsterId, int ordinal) {
CollectionMonster collectionMonster = new CollectionMonster(collectionId, monsterId, ordinal);
Completable result = m_db.collectionDAO().addMonsterToCollection(collectionMonster);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Completable removeMonsterFromCollection(@NonNull UUID collectionId, @NonNull UUID monsterId) {
Completable result = m_db.collectionDAO().removeMonsterFromCollection(collectionId.toString(), monsterId.toString());
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Completable removeCollectionMonsterById(long junctionId) {
Completable result = m_db.collectionDAO().removeCollectionMonsterById(junctionId);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Completable updateCollectionMonsters(List<CollectionMonster> collectionMonsters) {
Completable result = m_db.collectionDAO().updateCollectionMonsters(collectionMonsters);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Flowable<List<Monster>> getDashboardMonsters() {
return m_db.dashboardDAO()
.getDashboardMonsters()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Flowable<List<DashboardMonster>> getDashboardMonsterEntries() {
return m_db.dashboardDAO()
.getDashboardMonsterEntries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Completable addMonsterToDashboard(@NonNull UUID monsterId) {
DashboardMonster entry = new DashboardMonster(monsterId, 0);
Completable result = m_db.dashboardDAO().addMonsterToDashboard(entry);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Completable addCollectionToDashboard(@NonNull UUID collectionId) {
return m_db.collectionDAO().getMonstersForCollection(collectionId.toString())
.firstOrError()
.flatMapCompletable(monsters -> {
if (monsters.isEmpty()) {
return Completable.complete();
}
DashboardMonster[] entries = new DashboardMonster[monsters.size()];
for (int i = 0; i < monsters.size(); i++) {
entries[i] = new DashboardMonster(monsters.get(i).id, i);
}
return m_db.dashboardDAO().addMonsterToDashboard(entries);
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public Completable removeMonsterFromDashboard(@NonNull UUID monsterId) {
Completable result = m_db.dashboardDAO().removeMonsterFromDashboard(monsterId.toString());
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Completable removeDashboardMonsterById(long id) {
Completable result = m_db.dashboardDAO().removeDashboardMonsterById(id);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Completable clearDashboard() {
Completable result = m_db.dashboardDAO().clearDashboard();
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
public Completable updateDashboardMonsters(List<DashboardMonster> items) {
Completable result = m_db.dashboardDAO().updateDashboardMonsters(items);
result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
return result;
}
private static class Helpers { private static class Helpers {
static boolean monsterMatchesSearch(Monster monster, String searchText) { static boolean monsterMatchesSearch(Monster monster, String searchText) {
if (StringHelper.isNullOrEmpty(searchText)) { if (StringHelper.isNullOrEmpty(searchText)) {

View File

@@ -0,0 +1,287 @@
package com.majinnaibu.monstercards.exporters;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.majinnaibu.monstercards.data.enums.AbilityScore;
import com.majinnaibu.monstercards.data.enums.ArmorType;
import com.majinnaibu.monstercards.data.enums.ProficiencyType;
import com.majinnaibu.monstercards.models.Language;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.Skill;
import com.majinnaibu.monstercards.models.Trait;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
public class Open5eExporter {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@NonNull
public String exportMonster(@NonNull Monster monster) {
JsonObject root = new JsonObject();
root.addProperty("$schema", "../../../schema/entity.json");
root.addProperty("uuid", monster.id != null ? monster.id.toString() : UUID.randomUUID().toString());
root.addProperty("ruleset_id", "open5e");
root.addProperty("entity_type", "character");
root.addProperty("display_name", monster.name != null && !monster.name.isEmpty() ? monster.name : "Unnamed Entity");
root.addProperty("description", buildDescription(monster));
root.addProperty("version", "1.0.0");
root.addProperty("template_id", "stat_block");
JsonObject props = new JsonObject();
props.addProperty("name", monster.name != null ? monster.name : "");
props.addProperty("size", monster.size != null ? monster.size : "");
props.addProperty("type", monster.type != null ? monster.type : "");
props.addProperty("subtype", monster.subtype != null ? monster.subtype : "");
props.addProperty("alignment", monster.alignment != null ? monster.alignment : "");
props.addProperty("armor_class", monster.getArmorClassValue());
props.addProperty("armor_description", monster.getArmorClass());
if (monster.armorType != null && !monster.armorType.equals(ArmorType.NONE)) {
JsonObject armorObj = new JsonObject();
armorObj.addProperty("id", monster.armorType.stringValue);
armorObj.addProperty("name", monster.armorType.displayName);
armorObj.addProperty("armor_category", getArmorCategory(monster.armorType));
armorObj.addProperty("ac_base", monster.armorType.baseArmorClass);
Integer dexCap = getDexCap(monster.armorType);
if (dexCap != null) {
armorObj.addProperty("dex_cap", dexCap);
} else {
armorObj.add("dex_cap", JsonNull.INSTANCE);
}
armorObj.addProperty("stealth_disadvantage", isStealthDisadvantage(monster.armorType));
props.add("equipped_armor", armorObj);
} else {
props.add("equipped_armor", JsonNull.INSTANCE);
}
if (monster.shieldBonus > 0) {
JsonObject shieldObj = new JsonObject();
shieldObj.addProperty("id", "shield");
shieldObj.addProperty("name", "Shield");
shieldObj.addProperty("ac_bonus", monster.shieldBonus);
props.add("equipped_shield", shieldObj);
} else {
props.add("equipped_shield", JsonNull.INSTANCE);
}
JsonObject hpObj = new JsonObject();
hpObj.addProperty("max", monster.getHitPointsValue());
hpObj.addProperty("current", monster.getHitPointsValue());
hpObj.addProperty("formula", monster.hasCustomHP && !monster.customHPDescription.isEmpty() ? monster.customHPDescription : monster.getHitPoints());
hpObj.addProperty("hit_dice", monster.hitDice + "d8");
props.add("hit_points", hpObj);
props.addProperty("speed", monster.walkSpeed);
props.addProperty("speed_desc", monster.getSpeedText());
props.addProperty("challenge_rating", monster.getChallengeRatingDescription());
props.addProperty("cr", monster.challengeRating != null ? monster.challengeRating.stringValue : "1");
JsonObject abilitiesObj = new JsonObject();
abilitiesObj.addProperty("strength", monster.strengthScore);
abilitiesObj.addProperty("dexterity", monster.dexterityScore);
abilitiesObj.addProperty("constitution", monster.constitutionScore);
abilitiesObj.addProperty("intelligence", monster.intelligenceScore);
abilitiesObj.addProperty("wisdom", monster.wisdomScore);
abilitiesObj.addProperty("charisma", monster.charismaScore);
props.add("abilities", abilitiesObj);
if (monster.playerName != null && !monster.playerName.isEmpty()) props.addProperty("player_name", monster.playerName);
if (monster.background != null && !monster.background.isEmpty()) props.addProperty("background", monster.background);
if (monster.personalityTraits != null && !monster.personalityTraits.isEmpty()) props.addProperty("personality_traits", monster.personalityTraits);
if (monster.ideals != null && !monster.ideals.isEmpty()) props.addProperty("ideals", monster.ideals);
if (monster.bonds != null && !monster.bonds.isEmpty()) props.addProperty("bonds", monster.bonds);
if (monster.flaws != null && !monster.flaws.isEmpty()) props.addProperty("flaws", monster.flaws);
if (hasDetails(monster)) {
JsonObject detailsObj = new JsonObject();
if (monster.age != null && !monster.age.isEmpty()) detailsObj.addProperty("age", monster.age);
if (monster.height != null && !monster.height.isEmpty()) detailsObj.addProperty("height", monster.height);
if (monster.weight != null && !monster.weight.isEmpty()) detailsObj.addProperty("weight", monster.weight);
if (monster.eyes != null && !monster.eyes.isEmpty()) detailsObj.addProperty("eyes", monster.eyes);
if (monster.skin != null && !monster.skin.isEmpty()) detailsObj.addProperty("skin", monster.skin);
if (monster.hair != null && !monster.hair.isEmpty()) detailsObj.addProperty("hair", monster.hair);
if (monster.appearance != null && !monster.appearance.isEmpty()) detailsObj.addProperty("appearance", monster.appearance);
if (monster.backstory != null && !monster.backstory.isEmpty()) detailsObj.addProperty("backstory", monster.backstory);
if (monster.alliesAndOrganizations != null && !monster.alliesAndOrganizations.isEmpty()) detailsObj.addProperty("allies_and_organizations", monster.alliesAndOrganizations);
props.add("character_details", detailsObj);
}
JsonArray savesArr = buildSavingThrowsArray(monster);
if (savesArr.size() > 0) props.add("saving_throws", savesArr);
JsonArray skillsArr = buildSkillsArray(monster);
if (skillsArr.size() > 0) props.add("skills", skillsArr);
if (monster.damageImmunities != null && !monster.damageImmunities.isEmpty()) props.add("damage_immunities", toJsonArray(monster.damageImmunities));
if (monster.damageResistances != null && !monster.damageResistances.isEmpty()) props.add("damage_resistances", toJsonArray(monster.damageResistances));
if (monster.damageVulnerabilities != null && !monster.damageVulnerabilities.isEmpty()) props.add("damage_vulnerabilities", toJsonArray(monster.damageVulnerabilities));
if (monster.conditionImmunities != null && !monster.conditionImmunities.isEmpty()) props.add("condition_immunities", toJsonArray(monster.conditionImmunities));
props.addProperty("senses", monster.getSensesDescription());
if (monster.languages != null && !monster.languages.isEmpty()) {
JsonArray langArr = new JsonArray();
for (Language l : monster.languages) {
if (l != null && l.getName() != null && !l.getName().isEmpty()) {
langArr.add(l.getName());
}
}
props.add("languages", langArr);
}
if (monster.abilities != null && !monster.abilities.isEmpty()) props.add("traits", traitsToJsonArray(monster.abilities));
if (monster.actions != null && !monster.actions.isEmpty()) props.add("actions", traitsToJsonArray(monster.actions));
if (monster.bonusActions != null && !monster.bonusActions.isEmpty()) props.add("bonus_actions", traitsToJsonArray(monster.bonusActions));
if (monster.reactions != null && !monster.reactions.isEmpty()) props.add("reactions", traitsToJsonArray(monster.reactions));
if (monster.legendaryActions != null && !monster.legendaryActions.isEmpty()) props.add("legendary_actions", traitsToJsonArray(monster.legendaryActions));
if (monster.legendaryActionsDescription != null && !monster.legendaryActionsDescription.isEmpty()) props.addProperty("legendary_desc", monster.legendaryActionsDescription);
if (monster.lairActions != null && !monster.lairActions.isEmpty()) props.add("lair_actions", traitsToJsonArray(monster.lairActions));
if (monster.lairActionsDescription != null && !monster.lairActionsDescription.isEmpty()) props.addProperty("lair_desc", monster.lairActionsDescription);
if (monster.regionalActions != null && !monster.regionalActions.isEmpty()) props.add("regional_effects", traitsToJsonArray(monster.regionalActions));
if (monster.regionalActionsDescription != null && !monster.regionalActionsDescription.isEmpty()) props.addProperty("regional_desc", monster.regionalActionsDescription);
if (monster.mythicActions != null && !monster.mythicActions.isEmpty()) props.add("mythic_actions", traitsToJsonArray(monster.mythicActions));
if (monster.mythicActionsDescription != null && !monster.mythicActionsDescription.isEmpty()) props.addProperty("mythic_desc", monster.mythicActionsDescription);
if (monster.sourceUrl != null && !monster.sourceUrl.isEmpty()) props.addProperty("source_url", monster.sourceUrl);
root.add("properties", props);
return GSON.toJson(root);
}
private String getArmorCategory(ArmorType armorType) {
switch (armorType) {
case PADDED:
case LEATHER:
case STUDDED_LEATHER:
return "light";
case HIDE:
case CHAIN_SHIRT:
case SCALE_MAIL:
case BREASTPLATE:
case HALF_PLATE:
return "medium";
case RING_MAIL:
case CHAIN_MAIL:
case SPLINT_MAIL:
case PLATE_MAIL:
return "heavy";
default:
return "natural";
}
}
private Integer getDexCap(ArmorType armorType) {
switch (armorType) {
case HIDE:
case CHAIN_SHIRT:
case SCALE_MAIL:
case BREASTPLATE:
case HALF_PLATE:
return 2;
case RING_MAIL:
case CHAIN_MAIL:
case SPLINT_MAIL:
case PLATE_MAIL:
return 0;
default:
return null;
}
}
private boolean isStealthDisadvantage(ArmorType armorType) {
switch (armorType) {
case PADDED:
case SCALE_MAIL:
case HALF_PLATE:
case RING_MAIL:
case CHAIN_MAIL:
case SPLINT_MAIL:
case PLATE_MAIL:
return true;
default:
return false;
}
}
private boolean hasDetails(Monster monster) {
return (monster.age != null && !monster.age.isEmpty()) ||
(monster.height != null && !monster.height.isEmpty()) ||
(monster.weight != null && !monster.weight.isEmpty()) ||
(monster.eyes != null && !monster.eyes.isEmpty()) ||
(monster.skin != null && !monster.skin.isEmpty()) ||
(monster.hair != null && !monster.hair.isEmpty()) ||
(monster.appearance != null && !monster.appearance.isEmpty()) ||
(monster.backstory != null && !monster.backstory.isEmpty()) ||
(monster.alliesAndOrganizations != null && !monster.alliesAndOrganizations.isEmpty());
}
private String buildDescription(Monster monster) {
StringBuilder sb = new StringBuilder();
if (monster.size != null && !monster.size.isEmpty()) sb.append(monster.size).append(" ");
if (monster.type != null && !monster.type.isEmpty()) sb.append(monster.type);
if (monster.subtype != null && !monster.subtype.isEmpty()) sb.append(" (").append(monster.subtype).append(")");
if (monster.alignment != null && !monster.alignment.isEmpty()) sb.append(", ").append(monster.alignment);
return sb.toString().trim();
}
private JsonArray buildSavingThrowsArray(Monster monster) {
JsonArray arr = new JsonArray();
checkSave(arr, "Strength", monster.getSavingThrowProficiencyType(AbilityScore.STRENGTH), monster.getAbilityModifier(AbilityScore.STRENGTH) + monster.getProficiencyBonus(monster.getSavingThrowProficiencyType(AbilityScore.STRENGTH)));
checkSave(arr, "Dexterity", monster.getSavingThrowProficiencyType(AbilityScore.DEXTERITY), monster.getAbilityModifier(AbilityScore.DEXTERITY) + monster.getProficiencyBonus(monster.getSavingThrowProficiencyType(AbilityScore.DEXTERITY)));
checkSave(arr, "Constitution", monster.getSavingThrowProficiencyType(AbilityScore.CONSTITUTION), monster.getAbilityModifier(AbilityScore.CONSTITUTION) + monster.getProficiencyBonus(monster.getSavingThrowProficiencyType(AbilityScore.CONSTITUTION)));
checkSave(arr, "Intelligence", monster.getSavingThrowProficiencyType(AbilityScore.INTELLIGENCE), monster.getAbilityModifier(AbilityScore.INTELLIGENCE) + monster.getProficiencyBonus(monster.getSavingThrowProficiencyType(AbilityScore.INTELLIGENCE)));
checkSave(arr, "Wisdom", monster.getSavingThrowProficiencyType(AbilityScore.WISDOM), monster.getAbilityModifier(AbilityScore.WISDOM) + monster.getProficiencyBonus(monster.getSavingThrowProficiencyType(AbilityScore.WISDOM)));
checkSave(arr, "Charisma", monster.getSavingThrowProficiencyType(AbilityScore.CHARISMA), monster.getAbilityModifier(AbilityScore.CHARISMA) + monster.getProficiencyBonus(monster.getSavingThrowProficiencyType(AbilityScore.CHARISMA)));
return arr;
}
private void checkSave(JsonArray arr, String statName, ProficiencyType prof, int bonus) {
if (prof != null && !prof.equals(ProficiencyType.NONE)) {
arr.add(String.format(Locale.ROOT, "%s %+d", statName, bonus));
}
}
private JsonArray buildSkillsArray(Monster monster) {
JsonArray arr = new JsonArray();
if (monster.skills != null) {
for (Skill s : monster.skills) {
if (s != null && s.proficiencyType != null && !s.proficiencyType.equals(ProficiencyType.NONE)) {
int bonus = s.getSkillBonus(monster);
arr.add(String.format(Locale.ROOT, "%s %+d", s.name.toLowerCase(Locale.ROOT), bonus));
}
}
}
return arr;
}
private JsonArray toJsonArray(Collection<String> items) {
JsonArray arr = new JsonArray();
for (String s : items) {
if (s != null && !s.isEmpty()) arr.add(s);
}
return arr;
}
private JsonArray traitsToJsonArray(List<Trait> traits) {
JsonArray arr = new JsonArray();
for (Trait t : traits) {
if (t != null && t.name != null && !t.name.isEmpty()) {
JsonObject obj = new JsonObject();
obj.addProperty("name", t.name);
obj.addProperty("desc", t.description != null ? t.description : "");
arr.add(obj);
}
}
return arr;
}
}

View File

@@ -2,310 +2,39 @@ package com.majinnaibu.monstercards.helpers;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.google.gson.JsonArray; import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.google.gson.JsonElement; import com.majinnaibu.monstercards.importers.EntityImporter;
import com.google.gson.JsonObject; import com.majinnaibu.monstercards.importers.MonsterJsonImporter;
import com.google.gson.JsonParser; import com.majinnaibu.monstercards.importers.Open5eImporter;
import com.google.gson.JsonPrimitive; import com.majinnaibu.monstercards.importers.TetraCubeMonsterImporter;
import com.majinnaibu.monstercards.data.converters.ArmorTypeConverter;
import com.majinnaibu.monstercards.data.converters.ChallengeRatingConverter;
import com.majinnaibu.monstercards.data.enums.AbilityScore;
import com.majinnaibu.monstercards.data.enums.AdvantageType;
import com.majinnaibu.monstercards.data.enums.ProficiencyType;
import com.majinnaibu.monstercards.models.Language;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.Skill;
import com.majinnaibu.monstercards.models.Trait;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Set;
public class MonsterImportHelper { public class MonsterImportHelper {
private static final List<EntityImporter<Monster>> IMPORTERS = new ArrayList<>();
static {
IMPORTERS.add(new MonsterJsonImporter());
IMPORTERS.add(new Open5eImporter());
IMPORTERS.add(new TetraCubeMonsterImporter());
IMPORTERS.add(new DnDBeyondImporter());
}
@NonNull @NonNull
public static Monster fromJSON(String json) { public static Monster fromJSON(String json) {
JsonObject rootDict = JsonParser.parseString(json).getAsJsonObject(); if (json != null) {
for (EntityImporter<Monster> importer : IMPORTERS) {
Monster monster = new Monster(); if (importer.canImport(json)) {
monster.name = Helpers.getString(rootDict, "name"); try {
monster.size = Helpers.getString(rootDict, "size"); return importer.parse(json);
monster.type = Helpers.getString(rootDict, "type"); } catch (Exception e) {
monster.subtype = Helpers.getString(rootDict, "tag"); throw new IllegalArgumentException("Failed to parse monster JSON", e);
monster.alignment = Helpers.getString(rootDict, "alignment");
monster.hitDice = Helpers.getInt(rootDict, "hitDice");
monster.armorType = ArmorTypeConverter.armorTypeFromStringValue(Helpers.getString(rootDict, "armorName"));
monster.shieldBonus = Helpers.getInt(rootDict, "shieldBonus");
monster.naturalArmorBonus = Helpers.getInt(rootDict, "natArmorBonus");
monster.otherArmorDescription = Helpers.getString(rootDict, "otherArmorDesc");
monster.walkSpeed = Helpers.getInt(rootDict, "speed");
monster.burrowSpeed = Helpers.getInt(rootDict, "burrowSpeed");
monster.climbSpeed = Helpers.getInt(rootDict, "climbSpeed");
monster.flySpeed = Helpers.getInt(rootDict, "flySpeed");
monster.canHover = Helpers.getBool(rootDict, "hover");
monster.swimSpeed = Helpers.getInt(rootDict, "swimSpeed");
monster.hasCustomHP = Helpers.getBool(rootDict, "customHP");
monster.hasCustomSpeed = Helpers.getBool(rootDict, "customSpeed");
monster.customHPDescription = Helpers.getString(rootDict, "hpText");
monster.customSpeedDescription = Helpers.getString(rootDict, "speedDesc");
monster.strengthScore = Helpers.getInt(rootDict, "strPoints");
monster.dexterityScore = Helpers.getInt(rootDict, "dexPoints");
monster.constitutionScore = Helpers.getInt(rootDict, "conPoints");
monster.intelligenceScore = Helpers.getInt(rootDict, "intPoints");
monster.wisdomScore = Helpers.getInt(rootDict, "wisPoints");
monster.charismaScore = Helpers.getInt(rootDict, "chaPoints");
Helpers.addSense(monster, rootDict, "blindsight");
// Helpers.getBool(rootDict, "blind");
Helpers.addSense(monster, rootDict, "darkvision");
Helpers.addSense(monster, rootDict, "tremorsense");
Helpers.addSense(monster, rootDict, "truesight");
monster.telepathyRange = Helpers.getInt(rootDict, "telepathy");
monster.challengeRating = ChallengeRatingConverter.challengeRatingFromStringValue(Helpers.getString(rootDict, "cr"));
monster.customChallengeRatingDescription = Helpers.getString(rootDict, "customCr");
monster.customProficiencyBonus = Helpers.getInt(rootDict, "customProf");
// Helpers.getBool(rootDict, "isLegendary");
// Helpers.getString(rootDict, "legendariesDescription");
// Helpers.getBool(rootDict, "isLair");
// Helpers.getString(rootDict, "lairDescription");
// Helpers.getString(rootDict, "lairDescriptionEnd");
// Helpers.getBool(rootDict, "isRegional");
// Helpers.getString(rootDict, "regionalDescription");
// Helpers.getString(rootDict, "regionalDescriptionEnd");
// properties: []
monster.abilities = Helpers.getListOfTraits(rootDict, "abilities");
monster.actions = Helpers.getListOfTraits(rootDict, "actions");
monster.reactions = Helpers.getListOfTraits(rootDict, "reactions");
monster.legendaryActions = Helpers.getListOfTraits(rootDict, "legendaries");
monster.lairActions = Helpers.getListOfTraits(rootDict, "lairs");
monster.regionalActions = Helpers.getListOfTraits(rootDict, "regionals");
Helpers.addSavingThrows(monster, rootDict);
// skills: []
monster.skills = Helpers.getSetOfSkills(rootDict);
// damagetypes: []
// specialdamage: []
monster.damageImmunities = Helpers.getSetOfDamageTypes(rootDict, "damageTypes", "i");
monster.damageImmunities.addAll(Helpers.getSetOfDamageTypes(rootDict, "specialdamage", "i"));
monster.damageResistances = Helpers.getSetOfDamageTypes(rootDict, "damageTypes", "r");
monster.damageResistances.addAll(Helpers.getSetOfDamageTypes(rootDict, "specialdamage", "r"));
monster.damageVulnerabilities = Helpers.getSetOfDamageTypes(rootDict, "damageTypes", "v");
monster.damageVulnerabilities.addAll(Helpers.getSetOfDamageTypes(rootDict, "specialdamage", "v"));
// conditions: []
monster.conditionImmunities = Helpers.getSetOfDamageTypes(rootDict, "conditions");
// languages: []
monster.languages = Helpers.getSetOfLanguages(rootDict, "languages");
// understandsBut: ""
monster.understandsButDescription = Helpers.getString(rootDict, "understandsBut");
// shortName: ""
// doubleColumns: true
// separationPoint: -1
// damage: []
// pluralName: ""
return monster;
}
public static class Helpers {
public static String getString(JsonObject dict, String name) {
return getString(dict, name, "");
}
public static String getString(@NonNull JsonObject dict, String name, String defaultValue) {
if (dict.has(name)) {
return dict.get(name).getAsString();
}
return defaultValue;
}
public static int getInt(JsonObject dict, String name) {
return getInt(dict, name, 0);
}
public static int getInt(@NonNull JsonObject dict, String name, int defaultValue) {
if (dict.has(name)) {
JsonElement element = dict.get(name);
if (element.isJsonPrimitive()) {
JsonPrimitive rawValue = element.getAsJsonPrimitive();
if (rawValue.isNumber()) {
return rawValue.getAsInt();
} else {
try {
return rawValue.getAsInt();
} catch (Exception ex) {
return defaultValue;
}
}
}
}
return defaultValue;
}
public static boolean getBool(JsonObject dict, String name) {
return getBool(dict, name, false);
}
public static boolean getBool(@NonNull JsonObject dict, String name, boolean defaultValue) {
if (dict.has(name)) {
JsonElement element = dict.get(name);
if (element.isJsonPrimitive()) {
JsonPrimitive rawValue = element.getAsJsonPrimitive();
if (rawValue.isBoolean()) {
return rawValue.getAsBoolean();
} else {
try {
return rawValue.getAsBoolean();
} catch (Exception ex) {
return defaultValue;
}
}
}
}
return defaultValue;
}
@NonNull
public static String formatDistance(String name, int distance) {
// TODO: consider moving this to a string resource so it can be localized
return String.format(Locale.getDefault(), "%s %d ft.", name, distance);
}
public static void addSense(Monster monster, JsonObject root, String name) {
int distance = Helpers.getInt(root, name);
if (distance > 0) {
monster.senses.add(Helpers.formatDistance(name, distance));
}
}
@NonNull
public static List<Trait> getListOfTraits(@NonNull JsonObject dict, String name) {
ArrayList<Trait> traits = new ArrayList<>();
if (dict.has(name)) {
JsonElement arrayElement = dict.get(name);
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String traitName = Helpers.getString(jsonObject, "name");
String description = Helpers.getString(jsonObject, "desc");
Trait trait = new Trait(traitName, description);
traits.add(trait);
}
}
}
}
return traits;
}
public static void addSavingThrows(Monster monster, @NonNull JsonObject root) {
if (root.has("sthrows")) {
JsonElement arrayElement = root.get("sthrows");
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String name = Helpers.getString(jsonObject, "name");
if ("str".equals(name)) {
monster.strengthSavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("dex".equals(name)) {
monster.dexteritySavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("con".equals(name)) {
monster.constitutionSavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("int".equals(name)) {
monster.intelligenceSavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("wis".equals(name)) {
monster.wisdomSavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("cha".equals(name)) {
monster.charismaSavingThrowProficiency = ProficiencyType.PROFICIENT;
}
}
} }
} }
} }
} }
throw new IllegalArgumentException("No importer found capable of parsing the given JSON payload.");
@NonNull
public static Set<Skill> getSetOfSkills(@NonNull JsonObject root) {
HashSet<Skill> skills = new HashSet<>();
if (root.has("skills")) {
JsonElement arrayElement = root.get("skills");
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String name = Helpers.getString(jsonObject, "name");
String stat = Helpers.getString(jsonObject, "stat");
String note = Helpers.getString(jsonObject, "note");
Skill skill = new Skill(name, AbilityScore.valueOfString(stat), AdvantageType.NONE, " (ex)".equals(note) ? ProficiencyType.EXPERTISE : ProficiencyType.PROFICIENT);
skills.add(skill);
}
}
}
}
return skills;
}
@NonNull
public static Set<String> getSetOfDamageTypes(JsonObject rootDict, String name) {
return getSetOfDamageTypes(rootDict, name, null);
}
@NonNull
public static Set<String> getSetOfDamageTypes(@NonNull JsonObject root, String name, String type) {
HashSet<String> damageTypes = new HashSet<>();
if (root.has(name)) {
JsonElement arrayElement = root.get(name);
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String dtName = Helpers.getString(jsonObject, "name");
String dtType = Helpers.getString(jsonObject, "type");
if (type == null || type.equals(dtType)) {
damageTypes.add(dtName);
}
}
}
}
}
return damageTypes;
}
@NonNull
public static Set<Language> getSetOfLanguages(@NonNull JsonObject root, String name) {
HashSet<Language> languages = new HashSet<>();
if (root.has(name)) {
JsonElement arrayElement = root.get(name);
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String languageName = Helpers.getString(jsonObject, "name");
boolean canSpeak = Helpers.getBool(jsonObject, "speaks");
Language language = new Language(languageName, canSpeak);
languages.add(language);
}
}
}
}
return languages;
}
} }
} }

View File

@@ -0,0 +1,418 @@
package com.majinnaibu.monstercards.importers;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.majinnaibu.monstercards.data.enums.AbilityScore;
import com.majinnaibu.monstercards.data.enums.AdvantageType;
import com.majinnaibu.monstercards.data.enums.ProficiencyType;
import com.majinnaibu.monstercards.models.Language;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.Skill;
import com.majinnaibu.monstercards.models.Trait;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.HttpsURLConnection;
public class DnDBeyondImporter implements EntityImporter<Monster> {
private static final Pattern DNDBEYOND_URL_PATTERN = Pattern.compile("https?://(?:www\\.)?dndbeyond\\.com/characters/(\\d+)(?:/([a-zA-Z0-9]+))?", Pattern.CASE_INSENSITIVE);
private static final Pattern CHARACTER_ID_PATTERN = Pattern.compile("^\\d+$");
@Override
public boolean canImport(@NonNull String input) {
String trimmed = input.trim();
if (trimmed.isEmpty()) {
return false;
}
if (DNDBEYOND_URL_PATTERN.matcher(trimmed).find() || CHARACTER_ID_PATTERN.matcher(trimmed).matches()) {
return true;
}
try {
JsonElement element = JsonParser.parseString(trimmed);
if (element.isJsonObject()) {
JsonObject root = element.getAsJsonObject();
if (root.has("data") && root.getAsJsonObject("data").has("baseHitPoints")) {
return true;
}
}
} catch (Exception ignored) {
}
return false;
}
@NonNull
@Override
public Monster parse(@NonNull String input) throws Exception {
String trimmed = input.trim();
JsonObject characterData;
String sourceUrl = "";
if (trimmed.startsWith("{")) {
JsonObject root = JsonParser.parseString(trimmed).getAsJsonObject();
characterData = root.has("data") ? root.getAsJsonObject("data") : root;
} else {
String characterId = extractCharacterId(trimmed);
if (characterId == null) {
throw new IllegalArgumentException("Could not extract D&D Beyond character ID from input: " + input);
}
sourceUrl = trimmed.startsWith("http") ? trimmed : "https://www.dndbeyond.com/characters/" + characterId;
String jsonPayload = fetchCharacterJson(characterId);
JsonObject root = JsonParser.parseString(jsonPayload).getAsJsonObject();
if (!root.has("data") || root.get("data").isJsonNull()) {
throw new IllegalArgumentException("D&D Beyond response did not contain character data.");
}
characterData = root.getAsJsonObject("data");
}
return buildMonsterFromCharacterData(characterData, sourceUrl);
}
@Nullable
public static String extractCharacterId(@NonNull String input) {
String trimmed = input.trim();
Matcher urlMatcher = DNDBEYOND_URL_PATTERN.matcher(trimmed);
if (urlMatcher.find()) {
return urlMatcher.group(1);
}
if (CHARACTER_ID_PATTERN.matcher(trimmed).matches()) {
return trimmed;
}
return null;
}
@NonNull
private String fetchCharacterJson(@NonNull String characterId) throws Exception {
String serviceUrl = "https://character-service.dndbeyond.com/character/v5/character/" + characterId + "?includeCustomItems=true";
URL url = new URL(serviceUrl);
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Android; Mobile)");
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
int code = conn.getResponseCode();
if (code != 200) {
throw new IllegalStateException("D&D Beyond service returned HTTP " + code);
}
StringBuilder sb = new StringBuilder();
try (InputStream in = conn.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
}
return sb.toString();
}
@NonNull
private Monster buildMonsterFromCharacterData(@NonNull JsonObject data, @NonNull String sourceUrl) {
Monster monster = new Monster();
monster.sourceUrl = sourceUrl;
String rawName = getString(data, "name");
if (rawName.startsWith("\"") && rawName.endsWith("\"") && rawName.length() > 1) {
rawName = rawName.substring(1, rawName.length() - 1);
}
monster.name = rawName.isEmpty() ? "D&D Beyond Character" : rawName;
monster.size = "Medium";
monster.type = "Humanoid";
parseClassesAndLevel(data, monster);
parseStats(data, monster);
parseHitPoints(data, monster);
parseSpeedAndSenses(data, monster);
parseModifiers(data, monster);
parseTraitsAndFeatures(data, monster);
parseActionsAndAttacks(data, monster);
return monster;
}
private void parseClassesAndLevel(@NonNull JsonObject data, @NonNull Monster monster) {
StringBuilder classSummary = new StringBuilder();
int totalLevel = 0;
if (data.has("classes") && data.get("classes").isJsonArray()) {
JsonArray classes = data.getAsJsonArray("classes");
for (int i = 0; i < classes.size(); i++) {
JsonObject cls = classes.get(i).getAsJsonObject();
int level = getInt(cls, "level", 1);
totalLevel += level;
String className = "";
if (cls.has("definition") && cls.get("definition").isJsonObject()) {
className = getString(cls.getAsJsonObject("definition"), "name");
}
String subclassName = "";
if (cls.has("subclassDefinition") && cls.get("subclassDefinition").isJsonObject()) {
subclassName = getString(cls.getAsJsonObject("subclassDefinition"), "name");
}
if (classSummary.length() > 0) {
classSummary.append(" / ");
}
classSummary.append(className);
if (!subclassName.isEmpty()) {
classSummary.append(" (").append(subclassName).append(")");
}
classSummary.append(" ").append(level);
}
}
monster.hitDice = totalLevel > 0 ? totalLevel : 1;
String raceName = "";
if (data.has("race") && data.get("race").isJsonObject()) {
JsonObject race = data.getAsJsonObject("race");
raceName = getString(race, "fullName");
if (raceName.isEmpty()) {
raceName = getString(race, "baseRaceName");
}
}
StringBuilder subtypeSb = new StringBuilder();
if (totalLevel > 0) {
subtypeSb.append("Level ").append(totalLevel);
}
if (!raceName.isEmpty()) {
if (subtypeSb.length() > 0) subtypeSb.append(" ");
subtypeSb.append(raceName);
}
if (classSummary.length() > 0) {
if (subtypeSb.length() > 0) subtypeSb.append(" ");
subtypeSb.append(classSummary);
}
monster.subtype = subtypeSb.toString();
}
private void parseStats(@NonNull JsonObject data, @NonNull Monster monster) {
if (data.has("stats") && data.get("stats").isJsonArray()) {
JsonArray stats = data.getAsJsonArray("stats");
for (int i = 0; i < stats.size(); i++) {
JsonObject stat = stats.get(i).getAsJsonObject();
int id = getInt(stat, "id");
int val = getInt(stat, "value", 10);
switch (id) {
case 1: monster.strengthScore = val; break;
case 2: monster.dexterityScore = val; break;
case 3: monster.constitutionScore = val; break;
case 4: monster.intelligenceScore = val; break;
case 5: monster.wisdomScore = val; break;
case 6: monster.charismaScore = val; break;
}
}
}
}
private void parseHitPoints(@NonNull JsonObject data, @NonNull Monster monster) {
int baseHp = getInt(data, "baseHitPoints");
int overrideHp = getInt(data, "overrideHitPoints");
int maxHp = overrideHp > 0 ? overrideHp : baseHp;
if (maxHp > 0) {
monster.hasCustomHP = true;
monster.customHPDescription = String.format(Locale.ROOT, "%d (%dd%d)", maxHp, monster.hitDice, 8);
}
}
private void parseSpeedAndSenses(@NonNull JsonObject data, @NonNull Monster monster) {
if (data.has("race") && data.get("race").isJsonObject()) {
JsonObject race = data.getAsJsonObject("race");
if (race.has("weightSpeeds") && race.get("weightSpeeds").isJsonObject()) {
JsonObject weightSpeeds = race.getAsJsonObject("weightSpeeds");
if (weightSpeeds.has("normal") && weightSpeeds.get("normal").isJsonObject()) {
JsonObject normal = weightSpeeds.getAsJsonObject("normal");
monster.walkSpeed = getInt(normal, "walk", 30);
monster.flySpeed = getInt(normal, "fly", 0);
monster.burrowSpeed = getInt(normal, "burrow", 0);
monster.swimSpeed = getInt(normal, "swim", 0);
monster.climbSpeed = getInt(normal, "climb", 0);
}
}
}
if (monster.walkSpeed == 0) {
monster.walkSpeed = 30;
}
}
private void parseModifiers(@NonNull JsonObject data, @NonNull Monster monster) {
if (!data.has("modifiers") || !data.get("modifiers").isJsonObject()) {
return;
}
JsonObject modifiersObj = data.getAsJsonObject("modifiers");
String[] categories = new String[]{"race", "class", "background", "item", "feat"};
for (String cat : categories) {
if (modifiersObj.has(cat) && modifiersObj.get(cat).isJsonArray()) {
JsonArray mods = modifiersObj.getAsJsonArray(cat);
for (int i = 0; i < mods.size(); i++) {
JsonObject mod = mods.get(i).getAsJsonObject();
String type = getString(mod, "type");
String subType = getString(mod, "subType");
int fixedVal = getInt(mod, "fixedValue");
if ("set-base".equals(type) && "darkvision".equals(subType)) {
int dist = fixedVal > 0 ? fixedVal : 60;
monster.senses.add("darkvision " + dist + " ft.");
} else if ("language".equals(type)) {
String langName = capitalize(subType.replace("-", " "));
monster.languages.add(new Language(langName, true));
} else if ("proficiency".equals(type)) {
if (subType.endsWith("-saving-throws")) {
String stat = subType.replace("-saving-throws", "");
applySavingThrowProficiency(monster, stat);
} else {
applySkillProficiency(monster, subType, ProficiencyType.PROFICIENT);
}
} else if ("expertise".equals(type)) {
applySkillProficiency(monster, subType, ProficiencyType.EXPERTISE);
} else if ("immunity".equals(type)) {
monster.damageImmunities.add(capitalize(subType.replace("-", " ")));
} else if ("resistance".equals(type)) {
monster.damageResistances.add(capitalize(subType.replace("-", " ")));
} else if ("vulnerability".equals(type)) {
monster.damageVulnerabilities.add(capitalize(subType.replace("-", " ")));
}
}
}
}
}
private void applySavingThrowProficiency(@NonNull Monster monster, String stat) {
if ("strength".equals(stat) || "str".equals(stat)) monster.strengthSavingThrowProficiency = ProficiencyType.PROFICIENT;
else if ("dexterity".equals(stat) || "dex".equals(stat)) monster.dexteritySavingThrowProficiency = ProficiencyType.PROFICIENT;
else if ("constitution".equals(stat) || "con".equals(stat)) monster.constitutionSavingThrowProficiency = ProficiencyType.PROFICIENT;
else if ("intelligence".equals(stat) || "int".equals(stat)) monster.intelligenceSavingThrowProficiency = ProficiencyType.PROFICIENT;
else if ("wisdom".equals(stat) || "wis".equals(stat)) monster.wisdomSavingThrowProficiency = ProficiencyType.PROFICIENT;
else if ("charisma".equals(stat) || "cha".equals(stat)) monster.charismaSavingThrowProficiency = ProficiencyType.PROFICIENT;
}
private void applySkillProficiency(@NonNull Monster monster, String subType, ProficiencyType profType) {
String skillName = capitalize(subType.replace("-", " "));
AbilityScore abilityScore = AbilityScore.STRENGTH;
if ("Acrobatics".equalsIgnoreCase(skillName) || "Sleight Of Hand".equalsIgnoreCase(skillName) || "Stealth".equalsIgnoreCase(skillName)) {
abilityScore = AbilityScore.DEXTERITY;
} else if ("Arcana".equalsIgnoreCase(skillName) || "History".equalsIgnoreCase(skillName) || "Investigation".equalsIgnoreCase(skillName) || "Nature".equalsIgnoreCase(skillName) || "Religion".equalsIgnoreCase(skillName)) {
abilityScore = AbilityScore.INTELLIGENCE;
} else if ("Animal Handling".equalsIgnoreCase(skillName) || "Insight".equalsIgnoreCase(skillName) || "Medicine".equalsIgnoreCase(skillName) || "Perception".equalsIgnoreCase(skillName) || "Survival".equalsIgnoreCase(skillName)) {
abilityScore = AbilityScore.WISDOM;
} else if ("Deception".equalsIgnoreCase(skillName) || "Intimidation".equalsIgnoreCase(skillName) || "Performance".equalsIgnoreCase(skillName) || "Persuasion".equalsIgnoreCase(skillName)) {
abilityScore = AbilityScore.CHARISMA;
} else if ("Athletics".equalsIgnoreCase(skillName)) {
abilityScore = AbilityScore.STRENGTH;
}
monster.skills.add(new Skill(skillName, abilityScore, AdvantageType.NONE, profType));
}
private void parseTraitsAndFeatures(@NonNull JsonObject data, @NonNull Monster monster) {
if (data.has("race") && data.get("race").isJsonObject()) {
JsonObject race = data.getAsJsonObject("race");
if (race.has("racialTraits") && race.get("racialTraits").isJsonArray()) {
JsonArray traits = race.getAsJsonArray("racialTraits");
for (int i = 0; i < traits.size(); i++) {
JsonObject tr = traits.get(i).getAsJsonObject();
if (tr.has("definition") && tr.get("definition").isJsonObject()) {
JsonObject def = tr.getAsJsonObject("definition");
String name = getString(def, "name");
String desc = stripHtml(getString(def, "snippet"));
if (desc.isEmpty()) {
desc = stripHtml(getString(def, "description"));
}
if (!name.isEmpty() && !desc.isEmpty()) {
monster.abilities.add(new Trait(name, desc));
}
}
}
}
}
}
private void parseActionsAndAttacks(@NonNull JsonObject data, @NonNull Monster monster) {
if (data.has("actions") && data.get("actions").isJsonObject()) {
JsonObject actionsObj = data.getAsJsonObject("actions");
String[] categories = new String[]{"class", "race", "background", "item", "feat"};
for (String cat : categories) {
if (actionsObj.has(cat) && actionsObj.get(cat).isJsonArray()) {
JsonArray acts = actionsObj.getAsJsonArray(cat);
for (int i = 0; i < acts.size(); i++) {
JsonObject act = acts.get(i).getAsJsonObject();
String name = getString(act, "name");
String desc = stripHtml(getString(act, "snippet"));
if (desc.isEmpty()) {
desc = stripHtml(getString(act, "description"));
}
int actType = 1;
if (act.has("activation") && act.get("activation").isJsonObject()) {
actType = getInt(act.getAsJsonObject("activation"), "activationType", 1);
}
if (!name.isEmpty()) {
Trait trait = new Trait(name, desc);
if (actType == 3) {
monster.bonusActions.add(trait);
} else if (actType == 4) {
monster.reactions.add(trait);
} else {
monster.actions.add(trait);
}
}
}
}
}
}
}
private static String getString(@NonNull JsonObject obj, String key) {
return getString(obj, key, "");
}
private static String getString(@NonNull JsonObject obj, String key, String defaultVal) {
if (obj.has(key) && !obj.get(key).isJsonNull()) {
return obj.get(key).getAsString();
}
return defaultVal;
}
private static int getInt(@NonNull JsonObject obj, String key) {
return getInt(obj, key, 0);
}
private static int getInt(@NonNull JsonObject obj, String key, int defaultVal) {
if (obj.has(key) && !obj.get(key).isJsonNull()) {
try {
return obj.get(key).getAsInt();
} catch (Exception ignored) {
}
}
return defaultVal;
}
private static String stripHtml(String html) {
if (html == null) return "";
return html.replaceAll("<[^>]*>", "").replaceAll("&nbsp;", " ").trim();
}
private static String capitalize(String str) {
if (str == null || str.isEmpty()) return str;
String[] words = str.split("\\s+");
StringBuilder sb = new StringBuilder();
for (String w : words) {
if (!w.isEmpty()) {
if (sb.length() > 0) sb.append(" ");
sb.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1).toLowerCase(Locale.ROOT));
}
}
return sb.toString();
}
}

View File

@@ -0,0 +1,16 @@
package com.majinnaibu.monstercards.importers;
import androidx.annotation.NonNull;
public interface EntityImporter<T> {
/**
* Determines whether the given input string or payload can be handled by this importer.
*/
boolean canImport(@NonNull String input);
/**
* Parses the raw input string into internal domain memory objects (e.g., Monster).
*/
@NonNull
T parse(@NonNull String input) throws Exception;
}

View File

@@ -0,0 +1,37 @@
package com.majinnaibu.monstercards.importers;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.majinnaibu.monstercards.models.Monster;
public class MonsterJsonImporter implements EntityImporter<Monster> {
private static final Gson GSON = new Gson();
@Override
public boolean canImport(@NonNull String input) {
try {
JsonElement el = JsonParser.parseString(input);
if (el.isJsonObject()) {
JsonObject obj = el.getAsJsonObject();
return obj.has("name") && (obj.has("strengthScore") || obj.has("hitDice") || obj.has("size"));
}
} catch (Exception ignored) {
}
return false;
}
@NonNull
@Override
public Monster parse(@NonNull String input) throws Exception {
Monster monster = GSON.fromJson(input, Monster.class);
if (monster == null) {
throw new IllegalArgumentException("Failed to deserialize Monster JSON");
}
return monster;
}
}

View File

@@ -0,0 +1,387 @@
package com.majinnaibu.monstercards.importers;
import androidx.annotation.NonNull;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.majinnaibu.monstercards.data.enums.AbilityScore;
import com.majinnaibu.monstercards.data.enums.AdvantageType;
import com.majinnaibu.monstercards.data.enums.ArmorType;
import com.majinnaibu.monstercards.data.enums.ChallengeRating;
import com.majinnaibu.monstercards.data.enums.ProficiencyType;
import com.majinnaibu.monstercards.models.Language;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.Skill;
import com.majinnaibu.monstercards.models.Trait;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Open5eImporter implements EntityImporter<Monster> {
private static final Pattern HIT_DICE_PATTERN = Pattern.compile("(\\d+)d(\\d+)");
@Override
public boolean canImport(@NonNull String input) {
String trimmed = input.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
try {
JsonElement element = JsonParser.parseString(trimmed);
if (!element.isJsonObject()) {
return false;
}
JsonObject root = element.getAsJsonObject();
if (root.has("ruleset_id") && root.get("ruleset_id").getAsString().equals("open5e")) {
return true;
}
if (root.has("properties") && root.getAsJsonObject("properties").has("abilities")) {
return true;
}
} catch (Exception ignored) {
}
return false;
}
@NonNull
@Override
public Monster parse(@NonNull String input) throws Exception {
JsonObject root = JsonParser.parseString(input.trim()).getAsJsonObject();
Monster monster = new Monster();
if (root.has("uuid") && !root.get("uuid").isJsonNull()) {
try {
monster.id = UUID.fromString(root.get("uuid").getAsString());
} catch (Exception ignored) {
}
}
if (root.has("display_name") && !root.get("display_name").isJsonNull()) {
monster.name = root.get("display_name").getAsString();
}
JsonObject props = root.has("properties") ? root.getAsJsonObject("properties") : root;
if (monster.name == null || monster.name.isEmpty()) {
monster.name = getString(props, "name", "Unnamed Entity");
}
monster.size = getString(props, "size");
monster.type = getString(props, "type");
monster.subtype = getString(props, "subtype");
monster.alignment = getString(props, "alignment");
parseArmorClass(props, monster);
parseHitPoints(props, monster);
monster.walkSpeed = getInt(props, "speed", 30);
if (props.has("speed_desc")) {
String speedDesc = getString(props, "speed_desc");
if (!speedDesc.isEmpty()) {
monster.hasCustomSpeed = true;
monster.customSpeedDescription = speedDesc;
}
}
parseChallengeRating(props, monster);
parseAbilities(props, monster);
parseIdentityFields(props, monster);
parseSavingThrows(props, monster);
parseSkills(props, monster);
parseSenses(props, monster);
parseLanguages(props, monster);
parseDamageAndConditions(props, monster);
parseTraitsAndActions(props, monster);
if (props.has("source_url")) {
monster.sourceUrl = getString(props, "source_url");
}
return monster;
}
private void parseArmorClass(JsonObject props, Monster monster) {
if (props.has("equipped_armor") && props.get("equipped_armor").isJsonObject()) {
JsonObject armorObj = props.getAsJsonObject("equipped_armor");
String armorId = getString(armorObj, "id");
ArmorType armorType = ArmorType.valueOfString(armorId);
if (armorType != ArmorType.NONE) {
monster.armorType = armorType;
}
}
if (props.has("equipped_shield") && props.get("equipped_shield").isJsonObject()) {
JsonObject shieldObj = props.getAsJsonObject("equipped_shield");
monster.shieldBonus = getInt(shieldObj, "ac_bonus", 2);
}
if (monster.armorType == ArmorType.NONE && props.has("armor_description")) {
String armorDesc = getString(props, "armor_description");
if (!armorDesc.isEmpty()) {
monster.otherArmorDescription = armorDesc;
}
}
}
private void parseHitPoints(JsonObject props, Monster monster) {
if (props.has("hit_points") && props.get("hit_points").isJsonObject()) {
JsonObject hpObj = props.getAsJsonObject("hit_points");
int maxHp = getInt(hpObj, "max", getInt(hpObj, "current", 10));
if (hpObj.has("hit_dice") && !hpObj.get("hit_dice").isJsonNull()) {
String hitDiceStr = hpObj.get("hit_dice").getAsString();
Matcher m = HIT_DICE_PATTERN.matcher(hitDiceStr);
if (m.find()) {
monster.hitDice = Integer.parseInt(m.group(1));
}
}
if (hpObj.has("formula") && !hpObj.get("formula").isJsonNull()) {
monster.hasCustomHP = true;
monster.customHPDescription = String.format(Locale.ROOT, "%d (%s)", maxHp, hpObj.get("formula").getAsString());
}
}
}
private void parseChallengeRating(JsonObject props, Monster monster) {
String crStr = "";
if (props.has("cr") && !props.get("cr").isJsonNull()) {
crStr = props.get("cr").getAsString();
} else if (props.has("challenge_rating") && !props.get("challenge_rating").isJsonNull()) {
crStr = props.get("challenge_rating").getAsString();
}
if (!crStr.isEmpty()) {
monster.challengeRating = ChallengeRating.valueOfString(crStr);
}
}
private void parseAbilities(JsonObject props, Monster monster) {
if (!props.has("abilities") || !props.get("abilities").isJsonObject()) {
return;
}
JsonObject abs = props.getAsJsonObject("abilities");
monster.strengthScore = extractAbilityScore(abs, "strength");
monster.dexterityScore = extractAbilityScore(abs, "dexterity");
monster.constitutionScore = extractAbilityScore(abs, "constitution");
monster.intelligenceScore = extractAbilityScore(abs, "intelligence");
monster.wisdomScore = extractAbilityScore(abs, "wisdom");
monster.charismaScore = extractAbilityScore(abs, "charisma");
}
private int extractAbilityScore(JsonObject abs, String key) {
if (!abs.has(key) || abs.get(key).isJsonNull()) {
return 10;
}
JsonElement el = abs.get(key);
if (el.isJsonPrimitive()) {
return el.getAsInt();
} else if (el.isJsonObject() && el.getAsJsonObject().has("value")) {
return el.getAsJsonObject().get("value").getAsInt();
}
return 10;
}
private void parseIdentityFields(JsonObject props, Monster monster) {
monster.playerName = getString(props, "player_name");
monster.background = getString(props, "background");
monster.personalityTraits = getString(props, "personality_traits");
monster.ideals = getString(props, "ideals");
monster.bonds = getString(props, "bonds");
monster.flaws = getString(props, "flaws");
if (props.has("character_details") && props.get("character_details").isJsonObject()) {
JsonObject details = props.getAsJsonObject("character_details");
monster.age = getString(details, "age");
monster.height = getString(details, "height");
monster.weight = getString(details, "weight");
monster.eyes = getString(details, "eyes");
monster.skin = getString(details, "skin");
monster.hair = getString(details, "hair");
monster.appearance = getString(details, "appearance");
monster.backstory = getString(details, "backstory");
monster.alliesAndOrganizations = getString(details, "allies_and_organizations");
}
}
private void parseSavingThrows(JsonObject props, Monster monster) {
if (!props.has("saving_throws")) return;
JsonElement savesEl = props.get("saving_throws");
if (savesEl.isJsonArray()) {
JsonArray arr = savesEl.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String saveStr = arr.get(i).getAsString().toLowerCase(Locale.ROOT);
applySaveProficiency(monster, saveStr);
}
}
}
private void applySaveProficiency(Monster monster, String saveStr) {
if (saveStr.contains("str")) monster.strengthSavingThrowProficiency = ProficiencyType.PROFICIENT;
else if (saveStr.contains("dex")) monster.dexteritySavingThrowProficiency = ProficiencyType.PROFICIENT;
else if (saveStr.contains("con")) monster.constitutionSavingThrowProficiency = ProficiencyType.PROFICIENT;
else if (saveStr.contains("int")) monster.intelligenceSavingThrowProficiency = ProficiencyType.PROFICIENT;
else if (saveStr.contains("wis")) monster.wisdomSavingThrowProficiency = ProficiencyType.PROFICIENT;
else if (saveStr.contains("cha")) monster.charismaSavingThrowProficiency = ProficiencyType.PROFICIENT;
}
private void parseSkills(JsonObject props, Monster monster) {
if (!props.has("skills")) return;
JsonElement skillsEl = props.get("skills");
if (skillsEl.isJsonArray()) {
JsonArray arr = skillsEl.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String skillStr = arr.get(i).getAsString();
String skillName = skillStr.replaceAll("[+-]\\d+", "").trim();
if (!skillName.isEmpty()) {
AbilityScore stat = getAbilityForSkill(skillName);
monster.skills.add(new Skill(capitalize(skillName), stat, AdvantageType.NONE, ProficiencyType.PROFICIENT));
}
}
}
}
private AbilityScore getAbilityForSkill(String skillName) {
String lower = skillName.toLowerCase(Locale.ROOT);
if (lower.contains("acro") || lower.contains("sleight") || lower.contains("stealth")) return AbilityScore.DEXTERITY;
if (lower.contains("arca") || lower.contains("hist") || lower.contains("inve") || lower.contains("natu") || lower.contains("reli")) return AbilityScore.INTELLIGENCE;
if (lower.contains("anim") || lower.contains("insi") || lower.contains("medi") || lower.contains("perc") || lower.contains("surv")) return AbilityScore.WISDOM;
if (lower.contains("dece") || lower.contains("inti") || lower.contains("perf") || lower.contains("pers")) return AbilityScore.CHARISMA;
return AbilityScore.STRENGTH;
}
private void parseSenses(JsonObject props, Monster monster) {
if (props.has("senses")) {
String sensesStr = getString(props, "senses");
if (!sensesStr.isEmpty()) {
String[] parts = sensesStr.split(",");
for (String p : parts) {
if (!p.trim().isEmpty()) {
monster.senses.add(p.trim());
}
}
}
}
}
private void parseLanguages(JsonObject props, Monster monster) {
if (props.has("languages")) {
JsonElement langEl = props.get("languages");
if (langEl.isJsonArray()) {
JsonArray arr = langEl.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String langName = arr.get(i).getAsString();
if (!langName.isEmpty()) {
monster.languages.add(new Language(langName, true));
}
}
} else if (langEl.isJsonPrimitive()) {
String langStr = langEl.getAsString();
for (String l : langStr.split(",")) {
if (!l.trim().isEmpty()) {
monster.languages.add(new Language(l.trim(), true));
}
}
}
}
}
private void parseDamageAndConditions(JsonObject props, Monster monster) {
parseStringSet(props, "damage_immunities", monster.damageImmunities);
parseStringSet(props, "damage_resistances", monster.damageResistances);
parseStringSet(props, "damage_vulnerabilities", monster.damageVulnerabilities);
parseStringSet(props, "condition_immunities", monster.conditionImmunities);
}
private void parseStringSet(JsonObject props, String key, java.util.Set<String> set) {
if (!props.has(key)) return;
JsonElement el = props.get(key);
if (el.isJsonArray()) {
JsonArray arr = el.getAsJsonArray();
for (int i = 0; i < arr.size(); i++) {
String val = arr.get(i).getAsString();
if (!val.isEmpty()) set.add(capitalize(val));
}
} else if (el.isJsonPrimitive()) {
for (String val : el.getAsString().split(",")) {
if (!val.trim().isEmpty()) set.add(capitalize(val.trim()));
}
}
}
private void parseTraitsAndActions(JsonObject props, Monster monster) {
parseTraitList(props, "traits", monster.abilities);
parseTraitList(props, "actions", monster.actions);
parseTraitList(props, "bonus_actions", monster.bonusActions);
parseTraitList(props, "reactions", monster.reactions);
parseTraitList(props, "legendary_actions", monster.legendaryActions);
parseTraitList(props, "lair_actions", monster.lairActions);
parseTraitList(props, "regional_effects", monster.regionalActions);
parseTraitList(props, "mythic_actions", monster.mythicActions);
if (props.has("legendary_desc")) monster.legendaryActionsDescription = getString(props, "legendary_desc");
if (props.has("lair_desc")) monster.lairActionsDescription = getString(props, "lair_desc");
if (props.has("lair_end_note")) monster.lairActionsEndNote = getString(props, "lair_end_note");
if (props.has("regional_desc")) monster.regionalActionsDescription = getString(props, "regional_desc");
if (props.has("regional_end_note")) monster.regionalActionsEndNote = getString(props, "regional_end_note");
if (props.has("mythic_desc")) monster.mythicActionsDescription = getString(props, "mythic_desc");
}
private void parseTraitList(JsonObject props, String key, List<Trait> list) {
if (!props.has(key) || !props.get(key).isJsonArray()) return;
JsonArray arr = props.getAsJsonArray(key);
for (int i = 0; i < arr.size(); i++) {
JsonElement el = arr.get(i);
if (el.isJsonObject()) {
JsonObject obj = el.getAsJsonObject();
String name = getString(obj, "name");
String desc = getString(obj, "desc");
if (desc.isEmpty()) desc = getString(obj, "description");
if (!name.isEmpty()) {
list.add(new Trait(name, desc));
}
}
}
}
private static String getString(JsonObject obj, String key) {
return getString(obj, key, "");
}
private static String getString(JsonObject obj, String key, String defaultVal) {
if (obj.has(key) && !obj.get(key).isJsonNull()) {
return obj.get(key).getAsString();
}
return defaultVal;
}
private static int getInt(JsonObject obj, String key, int defaultVal) {
if (obj.has(key) && !obj.get(key).isJsonNull()) {
try {
return obj.get(key).getAsInt();
} catch (Exception ignored) {
}
}
return defaultVal;
}
private static String capitalize(String str) {
if (str == null || str.isEmpty()) return str;
String[] words = str.split("\\s+");
StringBuilder sb = new StringBuilder();
for (String w : words) {
if (!w.isEmpty()) {
if (sb.length() > 0) sb.append(" ");
sb.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1).toLowerCase(Locale.ROOT));
}
}
return sb.toString();
}
}

View File

@@ -0,0 +1,358 @@
package com.majinnaibu.monstercards.importers;
import androidx.annotation.NonNull;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
import com.majinnaibu.monstercards.data.converters.ArmorTypeConverter;
import com.majinnaibu.monstercards.data.converters.ChallengeRatingConverter;
import com.majinnaibu.monstercards.data.enums.AbilityScore;
import com.majinnaibu.monstercards.data.enums.AdvantageType;
import com.majinnaibu.monstercards.data.enums.ProficiencyType;
import com.majinnaibu.monstercards.models.Language;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.Skill;
import com.majinnaibu.monstercards.models.Trait;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public class TetraCubeMonsterImporter implements EntityImporter<Monster> {
@Override
public boolean canImport(@NonNull String input) {
if (input.trim().isEmpty()) {
return false;
}
try {
JsonElement element = JsonParser.parseString(input);
if (!element.isJsonObject()) {
return false;
}
JsonObject rootDict = element.getAsJsonObject();
return rootDict.has("armorName") || rootDict.has("strPoints")
|| rootDict.has("isLegendary") || rootDict.has("isMythic")
|| rootDict.has("legendariesDescription");
} catch (Exception e) {
return false;
}
}
@NonNull
@Override
public Monster parse(@NonNull String input) throws Exception {
JsonObject rootDict = JsonParser.parseString(input).getAsJsonObject();
Monster monster = new Monster();
monster.name = getString(rootDict, "name");
monster.size = getString(rootDict, "size");
monster.type = getString(rootDict, "type");
monster.subtype = getString(rootDict, "tag");
monster.alignment = getString(rootDict, "alignment");
monster.hitDice = getInt(rootDict, "hitDice");
monster.armorType = ArmorTypeConverter.armorTypeFromStringValue(getString(rootDict, "armorName"));
monster.shieldBonus = getInt(rootDict, "shieldBonus");
monster.naturalArmorBonus = getInt(rootDict, "natArmorBonus");
monster.otherArmorDescription = getString(rootDict, "otherArmorDesc");
monster.walkSpeed = getInt(rootDict, "speed");
monster.burrowSpeed = getInt(rootDict, "burrowSpeed");
monster.climbSpeed = getInt(rootDict, "climbSpeed");
monster.flySpeed = getInt(rootDict, "flySpeed");
monster.canHover = getBool(rootDict, "hover");
monster.swimSpeed = getInt(rootDict, "swimSpeed");
monster.hasCustomHP = getBool(rootDict, "customHP");
monster.hasCustomSpeed = getBool(rootDict, "customSpeed");
monster.customHPDescription = getString(rootDict, "hpText");
monster.customSpeedDescription = getString(rootDict, "speedDesc");
monster.strengthScore = getInt(rootDict, "strPoints");
monster.dexterityScore = getInt(rootDict, "dexPoints");
monster.constitutionScore = getInt(rootDict, "conPoints");
monster.intelligenceScore = getInt(rootDict, "intPoints");
monster.wisdomScore = getInt(rootDict, "wisPoints");
monster.charismaScore = getInt(rootDict, "chaPoints");
addSense(monster, rootDict, "blindsight");
addSense(monster, rootDict, "darkvision");
addSense(monster, rootDict, "tremorsense");
addSense(monster, rootDict, "truesight");
monster.telepathyRange = getInt(rootDict, "telepathy");
monster.challengeRating = ChallengeRatingConverter.challengeRatingFromStringValue(getString(rootDict, "cr"));
monster.customChallengeRatingDescription = getString(rootDict, "customCr");
monster.customProficiencyBonus = getInt(rootDict, "customProf");
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");
monster.damageImmunities.addAll(getSetOfDamageTypes(rootDict, "specialdamage", "i"));
monster.damageResistances = getSetOfDamageTypes(rootDict, "damageTypes", "r");
monster.damageResistances.addAll(getSetOfDamageTypes(rootDict, "specialdamage", "r"));
monster.damageVulnerabilities = getSetOfDamageTypes(rootDict, "damageTypes", "v");
monster.damageVulnerabilities.addAll(getSetOfDamageTypes(rootDict, "specialdamage", "v"));
monster.conditionImmunities = getSetOfDamageTypes(rootDict, "conditions");
monster.languages = getSetOfLanguages(rootDict, "languages");
monster.understandsButDescription = getString(rootDict, "understandsBut");
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, "");
}
private static String getString(@NonNull JsonObject dict, String name, String defaultValue) {
if (dict.has(name)) {
return dict.get(name).getAsString();
}
return defaultValue;
}
private static int getInt(JsonObject dict, String name) {
return getInt(dict, name, 0);
}
private static int getInt(@NonNull JsonObject dict, String name, int defaultValue) {
if (dict.has(name)) {
JsonElement element = dict.get(name);
if (element.isJsonPrimitive()) {
JsonPrimitive rawValue = element.getAsJsonPrimitive();
if (rawValue.isNumber()) {
return rawValue.getAsInt();
} else {
try {
return rawValue.getAsInt();
} catch (Exception ex) {
return defaultValue;
}
}
}
}
return defaultValue;
}
private static boolean getBool(JsonObject dict, String name) {
return getBool(dict, name, false);
}
private static boolean getBool(@NonNull JsonObject dict, String name, boolean defaultValue) {
if (dict.has(name)) {
JsonElement element = dict.get(name);
if (element.isJsonPrimitive()) {
JsonPrimitive rawValue = element.getAsJsonPrimitive();
if (rawValue.isBoolean()) {
return rawValue.getAsBoolean();
} else {
try {
return rawValue.getAsBoolean();
} catch (Exception ex) {
return defaultValue;
}
}
}
}
return defaultValue;
}
@NonNull
private static String formatDistance(String name, int distance) {
return String.format(Locale.getDefault(), "%s %d ft.", name, distance);
}
private static void addSense(Monster monster, JsonObject root, String name) {
int distance = getInt(root, name);
if (distance > 0) {
String senseStr = formatDistance(name, distance);
if ("blindsight".equals(name) && getBool(root, "blind")) {
senseStr += " (blind beyond this radius)";
}
monster.senses.add(senseStr);
}
}
@NonNull
private static List<Trait> getListOfTraits(@NonNull JsonObject dict, String name) {
ArrayList<Trait> traits = new ArrayList<>();
if (dict.has(name)) {
JsonElement arrayElement = dict.get(name);
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String traitName = getString(jsonObject, "name");
String description = getString(jsonObject, "desc");
Trait trait = new Trait(traitName, description);
traits.add(trait);
}
}
}
}
return traits;
}
private static void addSavingThrows(Monster monster, @NonNull JsonObject root) {
if (root.has("sthrows")) {
JsonElement arrayElement = root.get("sthrows");
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String name = getString(jsonObject, "name");
if ("str".equals(name)) {
monster.strengthSavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("dex".equals(name)) {
monster.dexteritySavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("con".equals(name)) {
monster.constitutionSavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("int".equals(name)) {
monster.intelligenceSavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("wis".equals(name)) {
monster.wisdomSavingThrowProficiency = ProficiencyType.PROFICIENT;
} else if ("cha".equals(name)) {
monster.charismaSavingThrowProficiency = ProficiencyType.PROFICIENT;
}
}
}
}
}
}
@NonNull
private static Set<Skill> getSetOfSkills(@NonNull JsonObject root) {
HashSet<Skill> skills = new HashSet<>();
if (root.has("skills")) {
JsonElement arrayElement = root.get("skills");
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String name = getString(jsonObject, "name");
String stat = getString(jsonObject, "stat");
String note = getString(jsonObject, "note");
Skill skill = new Skill(name, AbilityScore.valueOfString(stat), AdvantageType.NONE, " (ex)".equals(note) ? ProficiencyType.EXPERTISE : ProficiencyType.PROFICIENT);
skills.add(skill);
}
}
}
}
return skills;
}
@NonNull
private static Set<String> getSetOfDamageTypes(JsonObject rootDict, String name) {
return getSetOfDamageTypes(rootDict, name, null);
}
@NonNull
private static Set<String> getSetOfDamageTypes(JsonObject rootDict, String name, String type) {
HashSet<String> damageTypes = new HashSet<>();
if (rootDict.has(name)) {
JsonElement arrayElement = rootDict.get(name);
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String dtName = getString(jsonObject, "name");
String dtType = getString(jsonObject, "type");
if (type == null || type.equals(dtType)) {
damageTypes.add(dtName);
}
}
}
}
}
return damageTypes;
}
@NonNull
private static Set<Language> getSetOfLanguages(@NonNull JsonObject root, String name) {
HashSet<Language> languages = new HashSet<>();
if (root.has(name)) {
JsonElement arrayElement = root.get(name);
if (arrayElement.isJsonArray()) {
JsonArray array = arrayElement.getAsJsonArray();
int size = array.size();
for (int index = 0; index < size; index++) {
JsonElement jsonElement = array.get(index);
if (jsonElement.isJsonObject()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
String languageName = getString(jsonObject, "name");
boolean canSpeak = getBool(jsonObject, "speaks");
Language language = new Language(languageName, canSpeak);
languages.add(language);
}
}
}
}
return languages;
}
}

View File

@@ -0,0 +1,53 @@
package com.majinnaibu.monstercards.models;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
import java.util.Objects;
import java.util.UUID;
@Entity(tableName = "collections")
public class Collection {
@PrimaryKey
@NonNull
public UUID id;
@NonNull
@ColumnInfo(defaultValue = "")
public String name;
@NonNull
@ColumnInfo(defaultValue = "")
public String description;
public Collection() {
this.id = UUID.randomUUID();
this.name = "";
this.description = "";
}
public Collection(@NonNull String name, @NonNull String description) {
this.id = UUID.randomUUID();
this.name = name;
this.description = description;
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Collection that = (Collection) obj;
return Objects.equals(id, that.id) &&
Objects.equals(name, that.name) &&
Objects.equals(description, that.description);
}
@Override
public int hashCode() {
return Objects.hash(id, name, description);
}
}

View File

@@ -0,0 +1,60 @@
package com.majinnaibu.monstercards.models;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import java.util.UUID;
@Entity(
tableName = "collection_monsters",
foreignKeys = {
@ForeignKey(
entity = Collection.class,
parentColumns = "id",
childColumns = "collection_id",
onDelete = ForeignKey.CASCADE
),
@ForeignKey(
entity = Monster.class,
parentColumns = "id",
childColumns = "monster_id",
onDelete = ForeignKey.CASCADE
)
},
indices = {
@Index(value = {"collection_id"}),
@Index(value = {"monster_id"})
}
)
public class CollectionMonster {
@PrimaryKey(autoGenerate = true)
public long id;
@NonNull
@ColumnInfo(name = "collection_id")
public UUID collectionId;
@NonNull
@ColumnInfo(name = "monster_id")
public UUID monsterId;
@ColumnInfo(name = "ordinal", defaultValue = "0")
public int ordinal;
public CollectionMonster() {
this.collectionId = UUID.fromString("00000000-0000-0000-0000-000000000000");
this.monsterId = UUID.fromString("00000000-0000-0000-0000-000000000000");
this.ordinal = 0;
}
public CollectionMonster(@NonNull UUID collectionId, @NonNull UUID monsterId, int ordinal) {
this.collectionId = collectionId;
this.monsterId = monsterId;
this.ordinal = ordinal;
}
}

View File

@@ -0,0 +1,20 @@
package com.majinnaibu.monstercards.models;
import androidx.room.Embedded;
public class CollectionWithCount {
@Embedded
public Collection collection;
public int monsterCount;
public CollectionWithCount() {
this.collection = new Collection();
this.monsterCount = 0;
}
public CollectionWithCount(Collection collection, int monsterCount) {
this.collection = collection;
this.monsterCount = monsterCount;
}
}

View File

@@ -0,0 +1,43 @@
package com.majinnaibu.monstercards.models;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import java.util.UUID;
@Entity(
tableName = "dashboard_monsters",
foreignKeys = @ForeignKey(
entity = Monster.class,
parentColumns = "id",
childColumns = "monster_id",
onDelete = ForeignKey.CASCADE
),
indices = @Index(value = {"monster_id"})
)
public class DashboardMonster {
@PrimaryKey(autoGenerate = true)
public long id;
@NonNull
@ColumnInfo(name = "monster_id")
public UUID monsterId;
@ColumnInfo(name = "ordinal", defaultValue = "0")
public int ordinal;
public DashboardMonster() {
this.monsterId = UUID.fromString("00000000-0000-0000-0000-000000000000");
this.ordinal = 0;
}
public DashboardMonster(@NonNull UUID monsterId, int ordinal) {
this.monsterId = monsterId;
this.ordinal = ordinal;
}
}

View File

@@ -206,6 +206,93 @@ public class Monster {
@ColumnInfo(name = "regional_actions", defaultValue = "[]") @ColumnInfo(name = "regional_actions", defaultValue = "[]")
public List<Trait> regionalActions; 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;
@NonNull
@ColumnInfo(name = "player_name", defaultValue = "")
public String playerName;
@NonNull
@ColumnInfo(name = "background", defaultValue = "")
public String background;
@NonNull
@ColumnInfo(name = "personality_traits", defaultValue = "")
public String personalityTraits;
@NonNull
@ColumnInfo(name = "ideals", defaultValue = "")
public String ideals;
@NonNull
@ColumnInfo(name = "bonds", defaultValue = "")
public String bonds;
@NonNull
@ColumnInfo(name = "flaws", defaultValue = "")
public String flaws;
@NonNull
@ColumnInfo(name = "age", defaultValue = "")
public String age;
@NonNull
@ColumnInfo(name = "height", defaultValue = "")
public String height;
@NonNull
@ColumnInfo(name = "weight", defaultValue = "")
public String weight;
@NonNull
@ColumnInfo(name = "eyes", defaultValue = "")
public String eyes;
@NonNull
@ColumnInfo(name = "skin", defaultValue = "")
public String skin;
@NonNull
@ColumnInfo(name = "hair", defaultValue = "")
public String hair;
@NonNull
@ColumnInfo(name = "appearance", defaultValue = "")
public String appearance;
@NonNull
@ColumnInfo(name = "backstory", defaultValue = "")
public String backstory;
@NonNull
@ColumnInfo(name = "allies_and_organizations", defaultValue = "")
public String alliesAndOrganizations;
public Monster() { public Monster() {
id = UUID.randomUUID(); id = UUID.randomUUID();
name = ""; name = "";
@@ -251,6 +338,21 @@ public class Monster {
wisdomSavingThrowProficiency = ProficiencyType.NONE; wisdomSavingThrowProficiency = ProficiencyType.NONE;
charismaSavingThrowAdvantage = AdvantageType.NONE; charismaSavingThrowAdvantage = AdvantageType.NONE;
charismaSavingThrowProficiency = ProficiencyType.NONE; charismaSavingThrowProficiency = ProficiencyType.NONE;
playerName = "";
background = "";
personalityTraits = "";
ideals = "";
bonds = "";
flaws = "";
age = "";
height = "";
weight = "";
eyes = "";
skin = "";
hair = "";
appearance = "";
backstory = "";
alliesAndOrganizations = "";
skills = new HashSet<>(); skills = new HashSet<>();
@@ -266,6 +368,15 @@ public class Monster {
lairActions = new ArrayList<>(); lairActions = new ArrayList<>();
legendaryActions = new ArrayList<>(); legendaryActions = new ArrayList<>();
regionalActions = new ArrayList<>(); regionalActions = new ArrayList<>();
bonusActions = new ArrayList<>();
mythicActions = new ArrayList<>();
sourceUrl = "";
legendaryActionsDescription = "";
lairActionsDescription = "";
lairActionsEndNote = "";
regionalActionsDescription = "";
regionalActionsEndNote = "";
mythicActionsDescription = "";
} }
public String getMeta() { public String getMeta() {
@@ -783,6 +894,22 @@ public class Monster {
return actionDescriptions; 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() { public List<String> getReactionDescriptions() {
ArrayList<String> actionDescriptions = new ArrayList<>(); ArrayList<String> actionDescriptions = new ArrayList<>();
for (Trait action : reactions) { for (Trait action : reactions) {

View File

@@ -0,0 +1,32 @@
package com.majinnaibu.monstercards.models;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class SearchResultItem {
public enum Type {
MONSTER,
COLLECTION
}
@NonNull
public final Type type;
@Nullable
public final Monster monster;
@Nullable
public final Collection collection;
public SearchResultItem(@NonNull Monster monster) {
this.type = Type.MONSTER;
this.monster = monster;
this.collection = null;
}
public SearchResultItem(@NonNull Collection collection) {
this.type = Type.COLLECTION;
this.monster = null;
this.collection = collection;
}
}

View File

@@ -0,0 +1,214 @@
package com.majinnaibu.monstercards.ui.collections;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.dashboard.DashboardRecyclerViewAdapter;
import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.utils.Logger;
import java.util.List;
import java.util.UUID;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CollectionDetailFragment extends MCFragment {
private CollectionDetailViewModel mViewModel;
private UUID mCollectionId;
private DashboardRecyclerViewAdapter mAdapter;
private final CompositeDisposable mDisposables = new CompositeDisposable();
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
setHasOptionsMenu(true);
View root = inflater.inflate(R.layout.fragment_collection_detail, container, false);
Bundle arguments = getArguments();
assert arguments != null;
mCollectionId = UUID.fromString(CollectionDetailFragmentArgs.fromBundle(arguments).getCollectionId());
mViewModel = new ViewModelProvider(this).get(CollectionDetailViewModel.class);
TextView nameView = root.findViewById(R.id.detail_collection_name);
TextView descriptionView = root.findViewById(R.id.detail_collection_description);
RecyclerView recyclerView = root.findViewById(R.id.collection_monster_list);
FloatingActionButton fab = root.findViewById(R.id.fab_add_monster_to_collection);
if (fab != null) {
fab.setOnClickListener(v -> showAddMonsterDialog());
}
mViewModel.getCollection().observe(getViewLifecycleOwner(), collection -> {
if (collection != null) {
nameView.setText(collection.name);
setTitle(collection.name);
if (!TextUtils.isEmpty(collection.description)) {
descriptionView.setVisibility(View.VISIBLE);
descriptionView.setText(collection.description);
} else {
descriptionView.setVisibility(View.GONE);
}
}
});
mViewModel.getMonsters().observe(getViewLifecycleOwner(), monsters -> {
if (mAdapter != null) {
mAdapter.submitList(monsters);
}
});
setupRecyclerView(recyclerView);
loadCollectionData();
return root;
}
private void loadCollectionData() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.getCollection(mCollectionId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(collection -> {
if (collection != null) {
mViewModel.setCollection(collection);
}
}, Logger::logError));
mDisposables.add(repository.getMonstersForCollection(mCollectionId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> mViewModel.setMonsters(monsters), Logger::logError));
}
private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
int columnCount = Math.max(1, getResources().getConfiguration().screenWidthDp / 396);
Context context = requireContext();
GridLayoutManager layoutManager = new GridLayoutManager(context, columnCount);
recyclerView.setLayoutManager(layoutManager);
mAdapter = new DashboardRecyclerViewAdapter(monster -> {
if (monster != null) {
navigateToMonsterDetail(monster.id);
} else {
Logger.logError("Can't navigate to MonsterDetailFragment with a null monster");
}
});
recyclerView.setAdapter(mAdapter);
}
private void showAddMonsterDialog() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.getMonsters()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> {
if (monsters.isEmpty()) {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.snackbar_failed_to_create_monster), Snackbar.LENGTH_LONG).show();
}
return;
}
String[] monsterNames = new String[monsters.size()];
for (int i = 0; i < monsters.size(); i++) {
monsterNames[i] = monsters.get(i).name;
}
new AlertDialog.Builder(requireContext())
.setTitle(R.string.action_add_monster)
.setItems(monsterNames, (dialog, which) -> {
Monster selectedMonster = monsters.get(which);
addMonsterToCollection(selectedMonster);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}, Logger::logError));
}
private void addMonsterToCollection(Monster monster) {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.addMonsterToCollection(mCollectionId, monster.id)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
Collection collection = mViewModel.getCollection().getValue();
String collectionName = collection != null ? collection.name : "";
Snackbar.make(
view,
getString(R.string.snackbar_monster_added_to_collection, monster.name, collectionName),
Snackbar.LENGTH_LONG)
.show();
}
}, Logger::logError));
}
private void navigateToMonsterDetail(@NonNull UUID monsterId) {
NavDirections action = CollectionDetailFragmentDirections.actionCollectionDetailFragmentToNavigationMonster(monsterId.toString());
Navigation.findNavController(requireView()).navigate(action);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.collection_detail_menu, menu);
super.onCreateOptionsMenu(menu, inflater);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.menu_action_add_collection_to_dashboard) {
addCollectionToDashboard();
return true;
}
return super.onOptionsItemSelected(item);
}
private void addCollectionToDashboard() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.addCollectionToDashboard(mCollectionId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
Collection collection = mViewModel.getCollection().getValue();
String collectionName = collection != null ? collection.name : "";
Snackbar.make(view, getString(R.string.snackbar_collection_added_to_dashboard, collectionName), Snackbar.LENGTH_LONG).show();
}
}, Logger::logError));
}
@Override
public void onDestroyView() {
super.onDestroyView();
mDisposables.clear();
}
}

View File

@@ -0,0 +1,36 @@
package com.majinnaibu.monstercards.ui.collections;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster;
import java.util.ArrayList;
import java.util.List;
public class CollectionDetailViewModel extends ViewModel {
private final MutableLiveData<Collection> mCollection = new MutableLiveData<>();
private final MutableLiveData<List<Monster>> mMonsters = new MutableLiveData<>(new ArrayList<>());
public CollectionDetailViewModel() {
}
public LiveData<Collection> getCollection() {
return mCollection;
}
public void setCollection(Collection collection) {
mCollection.setValue(collection);
}
public LiveData<List<Monster>> getMonsters() {
return mMonsters;
}
public void setMonsters(List<Monster> monsters) {
mMonsters.setValue(monsters);
}
}

View File

@@ -1,27 +1,158 @@
package com.majinnaibu.monstercards.ui.collections; package com.majinnaibu.monstercards.ui.collections;
import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.TextView; import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.ViewModelProvider; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.R; import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.ui.shared.MCFragment; import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.ui.shared.SwipeToDeleteCallback;
import com.majinnaibu.monstercards.utils.Logger;
import java.util.UUID;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.observers.DisposableCompletableObserver;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CollectionsFragment extends MCFragment { public class CollectionsFragment extends MCFragment {
private CollectionsViewModel collectionsViewModel; @Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) { @Nullable ViewGroup container,
collectionsViewModel = new ViewModelProvider(this).get(CollectionsViewModel.class); @Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_collections, container, false); View root = inflater.inflate(R.layout.fragment_collections, container, false);
final TextView textView = root.findViewById(R.id.text_collections);
collectionsViewModel.getText().observe(getViewLifecycleOwner(), textView::setText); FloatingActionButton fab = root.findViewById(R.id.fab_add_collection);
if (fab != null) {
fab.setOnClickListener(v -> showCreateCollectionDialog());
}
RecyclerView recyclerView = root.findViewById(R.id.collection_list);
if (recyclerView != null) {
setupRecyclerView(recyclerView);
}
return root; return root;
} }
private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
Context context = requireContext();
MonsterRepository repository = getMonsterRepository();
CollectionsRecyclerViewAdapter adapter = new CollectionsRecyclerViewAdapter(
context,
repository.getCollectionsWithCount(),
collection -> navigateToCollectionDetail(collection.id),
collection -> repository.deleteCollection(collection)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new DisposableCompletableObserver() {
@Override
public void onComplete() {
}
@Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError(e);
}
})
);
recyclerView.setAdapter(adapter);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(context, layoutManager.getOrientation());
recyclerView.addItemDecoration(dividerItemDecoration);
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new SwipeToDeleteCallback(context, (position, direction) -> adapter.deleteItem(position), null));
itemTouchHelper.attachToRecyclerView(recyclerView);
}
private void showCreateCollectionDialog() {
Context context = requireContext();
LinearLayout layout = new LinearLayout(context);
layout.setOrientation(LinearLayout.VERTICAL);
int padding = (int) (16 * getResources().getDisplayMetrics().density);
layout.setPadding(padding, padding, padding, padding);
final EditText nameInput = new EditText(context);
nameInput.setHint(R.string.label_collection_name);
layout.addView(nameInput);
final EditText descInput = new EditText(context);
descInput.setHint(R.string.label_collection_description);
layout.addView(descInput);
new AlertDialog.Builder(context)
.setTitle(R.string.title_new_collection)
.setView(layout)
.setPositiveButton(R.string.dialog_create, (dialog, which) -> {
String name = nameInput.getText().toString().trim();
String description = descInput.getText().toString().trim();
if (TextUtils.isEmpty(name)) {
name = getString(R.string.title_new_collection);
}
createCollection(name, description);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}
private void createCollection(@NonNull String name, @NonNull String description) {
Collection collection = new Collection(name, description);
MonsterRepository repository = getMonsterRepository();
repository.saveCollection(collection)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new DisposableCompletableObserver() {
@Override
public void onComplete() {
View view = getView();
if (view != null) {
Snackbar.make(
view,
getString(R.string.snackbar_collection_created, collection.name),
Snackbar.LENGTH_LONG)
.setAction("View", v -> navigateToCollectionDetail(collection.id))
.show();
}
}
@Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError("Error creating collection", e);
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.snackbar_failed_to_create_collection), Snackbar.LENGTH_LONG).show();
}
}
});
}
private void navigateToCollectionDetail(@NonNull UUID collectionId) {
NavDirections action = CollectionsFragmentDirections.actionNavigationCollectionsToCollectionDetailFragment(collectionId.toString());
Navigation.findNavController(requireView()).navigate(action);
}
} }

View File

@@ -0,0 +1,137 @@
package com.majinnaibu.monstercards.ui.collections;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.CollectionWithCount;
import java.util.ArrayList;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CollectionsRecyclerViewAdapter extends RecyclerView.Adapter<CollectionsRecyclerViewAdapter.ViewHolder> {
private final Context mContext;
private final CollectionCallback mOnClick;
private final CollectionCallback mOnDelete;
private final Flowable<List<CollectionWithCount>> mItemsObservable;
private List<CollectionWithCount> mValues;
private Disposable mDisposable;
private final View.OnClickListener mOnClickListener = new View.OnClickListener() {
@Override
public void onClick(@NonNull View view) {
Collection collection = (Collection) view.getTag();
if (mOnClick != null) {
mOnClick.onCallback(collection);
}
}
};
public CollectionsRecyclerViewAdapter(Context context,
Flowable<List<CollectionWithCount>> itemsObservable,
CollectionCallback onClick,
CollectionCallback onDelete) {
mContext = context;
mItemsObservable = itemsObservable;
mOnClick = onClick;
mOnDelete = onDelete;
mValues = new ArrayList<>();
mDisposable = null;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.collection_list_item, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
CollectionWithCount item = mValues.get(position);
Collection collection = item.collection;
holder.mNameView.setText(collection.name);
String countText;
if (item.monsterCount == 1) {
countText = mContext.getString(R.string.format_monster_count_one);
} else {
countText = mContext.getString(R.string.format_monster_count_other, item.monsterCount);
}
holder.mCountView.setText(countText);
holder.mCountView.setVisibility(View.VISIBLE);
if (!TextUtils.isEmpty(collection.description)) {
holder.mDescriptionView.setVisibility(View.VISIBLE);
holder.mDescriptionView.setText(collection.description);
} else {
holder.mDescriptionView.setVisibility(View.GONE);
}
holder.itemView.setTag(collection);
holder.itemView.setOnClickListener(mOnClickListener);
}
@Override
public int getItemCount() {
return mValues.size();
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
mDisposable = mItemsObservable
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(collections -> {
mValues = collections;
notifyDataSetChanged();
});
}
@Override
public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
super.onDetachedFromRecyclerView(recyclerView);
if (mDisposable != null) {
mDisposable.dispose();
}
}
public void deleteItem(int position) {
if (mOnDelete != null && position >= 0 && position < mValues.size()) {
Collection collection = mValues.get(position).collection;
mOnDelete.onCallback(collection);
}
}
public interface CollectionCallback {
void onCallback(Collection collection);
}
static class ViewHolder extends RecyclerView.ViewHolder {
final TextView mNameView;
final TextView mDescriptionView;
final TextView mCountView;
ViewHolder(View view) {
super(view);
mNameView = view.findViewById(R.id.collection_name);
mDescriptionView = view.findViewById(R.id.collection_description);
mCountView = view.findViewById(R.id.collection_count);
}
}
}

View File

@@ -3,52 +3,77 @@ package com.majinnaibu.monstercards.ui.dashboard;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavDirections; import androidx.navigation.NavDirections;
import androidx.navigation.Navigation; import androidx.navigation.Navigation;
import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.R; import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.shared.MCFragment; import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.utils.Logger; import com.majinnaibu.monstercards.utils.Logger;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
public class DashboardFragment extends MCFragment { public class DashboardFragment extends MCFragment {
private DashboardViewModel mViewModel; private DashboardViewModel mViewModel;
private ViewHolder mHolder; private ViewHolder mHolder;
private DashboardRecyclerViewAdapter mAdapter; private DashboardRecyclerViewAdapter mAdapter;
private final CompositeDisposable mDisposables = new CompositeDisposable();
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) { @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
setHasOptionsMenu(true);
mViewModel = new ViewModelProvider(this).get(DashboardViewModel.class); mViewModel = new ViewModelProvider(this).get(DashboardViewModel.class);
View root = inflater.inflate(R.layout.fragment_dashboard, container, false); View root = inflater.inflate(R.layout.fragment_dashboard, container, false);
mHolder = new ViewHolder(root); mHolder = new ViewHolder(root);
setupRecyclerView(mHolder.list); FloatingActionButton fab = root.findViewById(R.id.fab_add_to_dashboard);
if (fab != null) {
fab.setOnClickListener(v -> showAddToDashboardOptionsDialog());
}
getMonsterRepository() setupRecyclerView(mHolder.list);
.getMonsters() loadDashboardMonsters();
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> mViewModel.setMonsters(monsters));
return root; return root;
} }
private void loadDashboardMonsters() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.getDashboardMonsters()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> mViewModel.setMonsters(monsters), Logger::logError));
}
private void setupRecyclerView(@NonNull RecyclerView recyclerView) { private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
int columnCount = Math.max(1, getResources().getConfiguration().screenWidthDp / 396); int columnCount = Math.max(1, getResources().getConfiguration().screenWidthDp / 360);
Logger.logWTF(String.format(Locale.US, "Setting column count to %d", columnCount)); Logger.logWTF(String.format(Locale.US, "Setting column count to %d", columnCount));
Context context = requireContext(); Context context = requireContext();
GridLayoutManager layoutManager = new GridLayoutManager(context, columnCount); GridLayoutManager layoutManager = new GridLayoutManager(context, columnCount);
@@ -66,6 +91,165 @@ public class DashboardFragment extends MCFragment {
monsterData.observe(getViewLifecycleOwner(), monsters -> mAdapter.submitList(monsters)); monsterData.observe(getViewLifecycleOwner(), monsters -> mAdapter.submitList(monsters));
} }
recyclerView.setAdapter(mAdapter); recyclerView.setAdapter(mAdapter);
ItemTouchHelper.SimpleCallback touchHelperCallback = new ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT,
ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
) {
@Override
public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) {
int fromPos = viewHolder.getAdapterPosition();
int toPos = target.getAdapterPosition();
if (fromPos != RecyclerView.NO_POSITION && toPos != RecyclerView.NO_POSITION) {
List<Monster> currentList = new ArrayList<>(mAdapter.getCurrentList());
if (fromPos < currentList.size() && toPos < currentList.size()) {
Monster moved = currentList.remove(fromPos);
currentList.add(toPos, moved);
mAdapter.submitList(currentList);
}
}
return true;
}
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
int position = viewHolder.getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
List<Monster> currentList = mAdapter.getCurrentList();
if (position < currentList.size()) {
Monster monster = currentList.get(position);
removeMonsterFromDashboard(monster);
}
}
}
};
new ItemTouchHelper(touchHelperCallback).attachToRecyclerView(recyclerView);
}
private void showAddToDashboardOptionsDialog() {
String[] options = new String[]{
getString(R.string.action_add_single_monster),
getString(R.string.action_add_collection_option),
getString(R.string.action_clear_dashboard)
};
new AlertDialog.Builder(requireContext())
.setTitle(R.string.dialog_add_to_dashboard)
.setItems(options, (dialog, which) -> {
if (which == 0) {
showAddMonsterPicker();
} else if (which == 1) {
showAddCollectionPicker();
} else if (which == 2) {
clearDashboard();
}
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}
private void showAddMonsterPicker() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.getMonsters()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(monsters -> {
if (monsters.isEmpty()) {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.no_monsters_available), Snackbar.LENGTH_LONG).show();
}
return;
}
String[] names = new String[monsters.size()];
for (int i = 0; i < monsters.size(); i++) {
names[i] = monsters.get(i).name;
}
new AlertDialog.Builder(requireContext())
.setTitle(R.string.action_add_single_monster)
.setItems(names, (dialog, which) -> {
Monster selected = monsters.get(which);
addMonsterToDashboard(selected);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}, Logger::logError));
}
private void showAddCollectionPicker() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.getCollections()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(collections -> {
if (collections.isEmpty()) {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.no_collections_available), Snackbar.LENGTH_LONG).show();
}
return;
}
String[] names = new String[collections.size()];
for (int i = 0; i < collections.size(); i++) {
names[i] = collections.get(i).name;
}
new AlertDialog.Builder(requireContext())
.setTitle(R.string.action_add_collection_option)
.setItems(names, (dialog, which) -> {
Collection selected = collections.get(which);
addCollectionToDashboard(selected);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
}, Logger::logError));
}
private void addMonsterToDashboard(@NonNull Monster monster) {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.addMonsterToDashboard(monster.id)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.snackbar_added_to_dashboard, monster.name), Snackbar.LENGTH_LONG).show();
}
}, Logger::logError));
}
private void addCollectionToDashboard(@NonNull Collection collection) {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.addCollectionToDashboard(collection.id)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.snackbar_collection_added_to_dashboard, collection.name), Snackbar.LENGTH_LONG).show();
}
}, Logger::logError));
}
private void removeMonsterFromDashboard(@NonNull Monster monster) {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.removeMonsterFromDashboard(monster.id)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {}, Logger::logError));
}
private void clearDashboard() {
MonsterRepository repository = getMonsterRepository();
mDisposables.add(repository.clearDashboard()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.snackbar_dashboard_cleared), Snackbar.LENGTH_LONG).show();
}
}, Logger::logError));
} }
private void navigateToMonsterDetail(Monster monster) { private void navigateToMonsterDetail(Monster monster) {
@@ -73,6 +257,33 @@ public class DashboardFragment extends MCFragment {
Navigation.findNavController(requireView()).navigate(action); Navigation.findNavController(requireView()).navigate(action);
} }
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.dashboard_menu, menu);
super.onCreateOptionsMenu(menu, inflater);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.menu_action_add_single_monster) {
showAddMonsterPicker();
return true;
} else if (item.getItemId() == R.id.menu_action_add_collection_option) {
showAddCollectionPicker();
return true;
} else if (item.getItemId() == R.id.menu_action_clear_dashboard) {
clearDashboard();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void onDestroyView() {
super.onDestroyView();
mDisposables.clear();
}
private static class ViewHolder { private static class ViewHolder {
final RecyclerView list; final RecyclerView list;
@@ -80,5 +291,4 @@ public class DashboardFragment extends MCFragment {
list = root.findViewById(R.id.list); list = root.findViewById(R.id.list);
} }
} }
} }

View File

@@ -38,7 +38,7 @@ public class DashboardRecyclerViewAdapter extends ListAdapter<Monster, Dashboard
}; };
private final ItemCallback mOnClick; private final ItemCallback mOnClick;
protected DashboardRecyclerViewAdapter(ItemCallback onClick) { public DashboardRecyclerViewAdapter(ItemCallback onClick) {
super(DIFF_CALLBACK); super(DIFF_CALLBACK);
mOnClick = onClick; mOnClick = onClick;
} }

View File

@@ -1,12 +1,20 @@
package com.majinnaibu.monstercards.ui.library; package com.majinnaibu.monstercards.ui.library;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.EditText;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.navigation.NavController;
import androidx.navigation.NavDirections; import androidx.navigation.NavDirections;
import androidx.navigation.Navigation; import androidx.navigation.Navigation;
import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.DividerItemDecoration;
@@ -16,9 +24,13 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.MainActivity;
import com.majinnaibu.monstercards.R; import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository; import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.importers.DnDBeyondImporter;
import com.majinnaibu.monstercards.importers.Open5eImporter;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.monster.MonsterDetailFragmentDirections;
import com.majinnaibu.monstercards.ui.shared.MCFragment; import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.ui.shared.SwipeToDeleteCallback; import com.majinnaibu.monstercards.ui.shared.SwipeToDeleteCallback;
import com.majinnaibu.monstercards.utils.Logger; import com.majinnaibu.monstercards.utils.Logger;
@@ -33,6 +45,7 @@ public class LibraryFragment extends MCFragment {
public View onCreateView(@NonNull LayoutInflater inflater, public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) { ViewGroup container, Bundle savedInstanceState) {
setHasOptionsMenu(true);
View root = inflater.inflate(R.layout.fragment_library, container, false); View root = inflater.inflate(R.layout.fragment_library, container, false);
FloatingActionButton fab = root.findViewById(R.id.fab); FloatingActionButton fab = root.findViewById(R.id.fab);
@@ -46,6 +59,62 @@ public class LibraryFragment extends MCFragment {
return root; return root;
} }
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.library_menu, menu);
super.onCreateOptionsMenu(menu, inflater);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.menu_action_import_from_url) {
showImportUrlDialog();
return true;
}
return super.onOptionsItemSelected(item);
}
private void showImportUrlDialog() {
Context context = requireContext();
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(R.string.dialog_import_url_title);
builder.setMessage(R.string.dialog_import_url_message);
final EditText input = new EditText(context);
input.setHint(R.string.dialog_import_url_hint);
input.setSingleLine(true);
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard != null && clipboard.hasPrimaryClip()) {
ClipData clip = clipboard.getPrimaryClip();
if (clip != null && clip.getItemCount() > 0) {
CharSequence clipText = clip.getItemAt(0).getText();
if (clipText != null) {
String clipStr = clipText.toString().trim();
if (new DnDBeyondImporter().canImport(clipStr) || new Open5eImporter().canImport(clipStr)) {
input.setText(clipStr);
input.selectAll();
}
}
}
}
builder.setView(input);
builder.setPositiveButton(R.string.dialog_import, (dialog, which) -> {
String urlOrId = input.getText().toString().trim();
if (!urlOrId.isEmpty()) {
if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).importMonsterFromInputAndNavigate(urlOrId);
}
}
});
builder.setNegativeButton(R.string.dialog_cancel, (dialog, which) -> dialog.cancel());
builder.show();
}
private void setupRecyclerView(@NonNull RecyclerView recyclerView) { private void setupRecyclerView(@NonNull RecyclerView recyclerView) {
Context context = requireContext(); Context context = requireContext();
MonsterRepository repository = this.getMonsterRepository(); MonsterRepository repository = this.getMonsterRepository();
@@ -92,23 +161,16 @@ public class LibraryFragment extends MCFragment {
new DisposableCompletableObserver() { new DisposableCompletableObserver() {
@Override @Override
public void onComplete() { public void onComplete() {
View view = getView(); navigateToEditMonster(monster.id);
assert view != null;
Snackbar.make(
view,
getString(R.string.snackbar_monster_created, monster.name),
Snackbar.LENGTH_LONG)
.setAction("Action", (_view) -> navigateToMonsterDetail(monster.id))
.show();
} }
@Override @Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) { public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError("Error creating monster", e); Logger.logError("Error creating monster", e);
View view = getView(); View view = getView();
assert view != null; if (view != null) {
Snackbar.make(view, getString(R.string.snackbar_failed_to_create_monster), Snackbar.LENGTH_LONG) Snackbar.make(view, getString(R.string.snackbar_failed_to_create_monster), Snackbar.LENGTH_LONG).show();
.setAction("Action", null).show(); }
} }
}); });
}); });
@@ -118,4 +180,12 @@ public class LibraryFragment extends MCFragment {
NavDirections action = LibraryFragmentDirections.actionNavigationLibraryToNavigationMonster(monsterId.toString()); NavDirections action = LibraryFragmentDirections.actionNavigationLibraryToNavigationMonster(monsterId.toString());
Navigation.findNavController(requireView()).navigate(action); Navigation.findNavController(requireView()).navigate(action);
} }
protected void navigateToEditMonster(@NonNull UUID monsterId) {
NavController navController = Navigation.findNavController(requireView());
NavDirections action = LibraryFragmentDirections.actionNavigationLibraryToNavigationMonster(monsterId.toString());
navController.navigate(action);
action = MonsterDetailFragmentDirections.actionNavigationMonsterToEditMonsterFragment(monsterId.toString());
navController.navigate(action);
}
} }

View File

@@ -1,8 +1,16 @@
package com.majinnaibu.monstercards.ui.monster; package com.majinnaibu.monstercards.ui.monster;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.content.res.Resources; import android.content.res.Resources;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import androidx.core.content.FileProvider;
import com.majinnaibu.monstercards.exporters.Open5eExporter;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import android.text.Html; import android.text.Html;
import android.text.Spanned; import android.text.Spanned;
import android.util.DisplayMetrics; import android.util.DisplayMetrics;
@@ -18,14 +26,17 @@ import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavDirections; import androidx.navigation.NavDirections;
import androidx.navigation.Navigation; import androidx.navigation.Navigation;
import com.google.android.material.snackbar.Snackbar;
import com.majinnaibu.monstercards.R; import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository; import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.helpers.CommonMarkHelper; import com.majinnaibu.monstercards.helpers.CommonMarkHelper;
import com.majinnaibu.monstercards.helpers.StringHelper; import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.ui.shared.MCFragment; import com.majinnaibu.monstercards.ui.shared.MCFragment;
import com.majinnaibu.monstercards.utils.Logger; import com.majinnaibu.monstercards.utils.Logger;
@@ -33,7 +44,10 @@ import com.majinnaibu.monstercards.utils.Logger;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.observers.DisposableCompletableObserver;
import io.reactivex.rxjava3.observers.DisposableSingleObserver; import io.reactivex.rxjava3.observers.DisposableSingleObserver;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class MonsterDetailFragment extends MCFragment { public class MonsterDetailFragment extends MCFragment {
private ViewHolder mHolder; private ViewHolder mHolder;
@@ -172,10 +186,150 @@ public class MonsterDetailFragment extends MCFragment {
Logger.logWTF("monsterId cannot be null."); Logger.logWTF("monsterId cannot be null.");
} }
return true; return true;
} else if (item.getItemId() == R.id.menu_action_add_to_collection) {
showAddToCollectionDialog();
return true;
} else if (item.getItemId() == R.id.menu_action_add_to_dashboard) {
addCurrentMonsterToDashboard();
return true;
} else if (item.getItemId() == R.id.menu_action_share_monster) {
shareCurrentMonster();
return true;
} }
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
private void shareCurrentMonster() {
Monster monster = mViewModel.getMonster();
if (monster == null) {
return;
}
try {
Open5eExporter exporter = new Open5eExporter();
String jsonCard = exporter.exportMonster(monster);
Context context = requireContext();
String safeName = (monster.name != null && !monster.name.trim().isEmpty())
? monster.name.replaceAll("[^a-zA-Z0-9._-]", "_")
: "monster";
File file = new File(context.getCacheDir(), safeName + ".card");
try (FileOutputStream out = new FileOutputStream(file)) {
out.write(jsonCard.getBytes(StandardCharsets.UTF_8));
}
Uri uri = FileProvider.getUriForFile(
context,
context.getPackageName() + ".fileprovider",
file
);
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("application/octet-stream");
shareIntent.putExtra(Intent.EXTRA_STREAM, uri);
shareIntent.putExtra(Intent.EXTRA_TEXT, jsonCard);
shareIntent.putExtra(Intent.EXTRA_SUBJECT, monster.name);
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(Intent.createChooser(shareIntent, getString(R.string.action_share_monster)));
} catch (Exception e) {
Logger.logError("Failed to share monster", e);
View view = getView();
if (view != null) {
Snackbar.make(view, R.string.failed_to_share_monster, Snackbar.LENGTH_LONG).show();
}
}
}
private void addCurrentMonsterToDashboard() {
UUID monsterId = mViewModel.getId().getValue();
if (monsterId == null) {
return;
}
MonsterRepository repository = getMonsterRepository();
repository.addMonsterToDashboard(monsterId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
View view = getView();
if (view != null) {
String monsterName = mViewModel.getName().getValue();
Snackbar.make(view, getString(R.string.snackbar_added_to_dashboard, monsterName), Snackbar.LENGTH_LONG).show();
}
}, Logger::logError);
}
private void showAddToCollectionDialog() {
UUID monsterId = mViewModel.getId().getValue();
if (monsterId == null) {
return;
}
MonsterRepository repository = getMonsterRepository();
repository.getCollections()
.firstOrError()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new DisposableSingleObserver<List<Collection>>() {
@Override
public void onSuccess(@io.reactivex.rxjava3.annotations.NonNull List<Collection> collections) {
if (collections.isEmpty()) {
View view = getView();
if (view != null) {
Snackbar.make(view, getString(R.string.no_collections_available), Snackbar.LENGTH_LONG).show();
}
dispose();
return;
}
String[] collectionNames = new String[collections.size()];
for (int i = 0; i < collections.size(); i++) {
collectionNames[i] = collections.get(i).name;
}
new AlertDialog.Builder(requireContext())
.setTitle(R.string.title_select_collection)
.setItems(collectionNames, (dialog, which) -> {
Collection selectedCollection = collections.get(which);
addMonsterToCollection(selectedCollection, monsterId);
})
.setNegativeButton(R.string.dialog_cancel, null)
.show();
dispose();
}
@Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError(e);
dispose();
}
});
}
private void addMonsterToCollection(@NonNull Collection collection, @NonNull UUID monsterId) {
MonsterRepository repository = getMonsterRepository();
repository.addMonsterToCollection(collection.id, monsterId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new DisposableCompletableObserver() {
@Override
public void onComplete() {
View view = getView();
if (view != null) {
String monsterName = mViewModel.getName().getValue();
Snackbar.make(
view,
getString(R.string.snackbar_monster_added_to_collection, monsterName, collection.name),
Snackbar.LENGTH_LONG)
.show();
}
}
@Override
public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) {
Logger.logError(e);
}
});
}
private static class ViewHolder { private static class ViewHolder {
final TextView name; final TextView name;
final TextView meta; final TextView meta;

View File

@@ -181,6 +181,10 @@ public class MonsterDetailViewModel extends ViewModel {
return mMonsterId; return mMonsterId;
} }
public Monster getMonster() {
return mMonster;
}
public void setMonster(@NonNull Monster monster) { public void setMonster(@NonNull Monster monster) {
mMonster = monster; mMonster = monster;
mAbilities.setValue(mMonster.getAbilityDescriptions()); mAbilities.setValue(mMonster.getAbilityDescriptions());

View File

@@ -162,12 +162,13 @@ public class MonsterImportFragment extends MCFragment {
repository.addMonster(monster).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(new DisposableCompletableObserver() { repository.addMonster(monster).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(new DisposableCompletableObserver() {
@Override @Override
public void onComplete() { public void onComplete() {
Snackbar.make( if (getView() != null) {
mHolder.root, NavController navController = Navigation.findNavController(requireView());
getString(R.string.snackbar_monster_created, monster.name), NavDirections toLibraryAction = MonsterImportFragmentDirections.actionMonsterImportFragmentToNavigationLibrary();
Snackbar.LENGTH_LONG) navController.navigate(toLibraryAction);
.setAction("Action", (_view) -> navigateToEditMonster(monster.id)) NavDirections toMonsterDetailAction = LibraryFragmentDirections.actionNavigationLibraryToNavigationMonster(monster.id.toString());
.show(); navController.navigate(toMonsterDetailAction);
}
} }
@Override @Override
@@ -184,17 +185,6 @@ public class MonsterImportFragment extends MCFragment {
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
private void navigateToEditMonster(@NonNull UUID monsterId) {
NavController navController = Navigation.findNavController(requireView());
NavDirections action;
action = MonsterImportFragmentDirections.actionMonsterImportFragmentToNavigationLibrary();
navController.navigate(action);
action = LibraryFragmentDirections.actionNavigationLibraryToNavigationMonster(monsterId.toString());
navController.navigate(action);
action = MonsterDetailFragmentDirections.actionNavigationMonsterToEditMonsterFragment(monsterId.toString());
navController.navigate(action);
}
private static class ViewHolder { private static class ViewHolder {
final View root; final View root;
final TextView name; final TextView name;

View File

@@ -9,11 +9,14 @@ import android.view.ViewGroup;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.majinnaibu.monstercards.R; import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository; import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.models.SearchResultItem;
import com.majinnaibu.monstercards.ui.shared.MCFragment; import com.majinnaibu.monstercards.ui.shared.MCFragment;
public class SearchFragment extends MCFragment { public class SearchFragment extends MCFragment {
@@ -22,7 +25,17 @@ public class SearchFragment extends MCFragment {
ViewGroup container, Bundle savedInstanceState) { ViewGroup container, Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_search, container, false); View root = inflater.inflate(R.layout.fragment_search, container, false);
MonsterRepository repository = this.getMonsterRepository(); MonsterRepository repository = this.getMonsterRepository();
SearchResultsRecyclerViewAdapter adapter = new SearchResultsRecyclerViewAdapter(repository, null); SearchResultsRecyclerViewAdapter adapter = new SearchResultsRecyclerViewAdapter(repository, item -> {
if (item != null) {
if (item.type == SearchResultItem.Type.MONSTER && item.monster != null) {
NavDirections action = SearchFragmentDirections.actionNavigationSearchToNavigationMonster(item.monster.id.toString());
Navigation.findNavController(requireView()).navigate(action);
} else if (item.type == SearchResultItem.Type.COLLECTION && item.collection != null) {
NavDirections action = SearchFragmentDirections.actionNavigationSearchToCollectionDetailFragment(item.collection.id.toString());
Navigation.findNavController(requireView()).navigate(action);
}
}
});
final RecyclerView recyclerView = root.findViewById(R.id.monster_list); final RecyclerView recyclerView = root.findViewById(R.id.monster_list);
assert recyclerView != null; assert recyclerView != null;
setupRecyclerView(recyclerView, adapter); setupRecyclerView(recyclerView, adapter);

View File

@@ -3,6 +3,7 @@ package com.majinnaibu.monstercards.ui.search;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -10,7 +11,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.majinnaibu.monstercards.R; import com.majinnaibu.monstercards.R;
import com.majinnaibu.monstercards.data.MonsterRepository; import com.majinnaibu.monstercards.data.MonsterRepository;
import com.majinnaibu.monstercards.helpers.StringHelper;
import com.majinnaibu.monstercards.models.Collection;
import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.SearchResultItem;
import com.majinnaibu.monstercards.utils.Logger; import com.majinnaibu.monstercards.utils.Logger;
import java.util.ArrayList; import java.util.ArrayList;
@@ -23,7 +27,7 @@ public class SearchResultsRecyclerViewAdapter extends RecyclerView.Adapter<Searc
private final MonsterRepository mRepository; private final MonsterRepository mRepository;
private final ItemCallback mOnClickHandler; private final ItemCallback mOnClickHandler;
private String mSearchText; private String mSearchText;
private List<Monster> mValues; private List<SearchResultItem> mValues;
private Disposable mSubscriptionHandler; private Disposable mSubscriptionHandler;
public SearchResultsRecyclerViewAdapter(MonsterRepository repository, public SearchResultsRecyclerViewAdapter(MonsterRepository repository,
@@ -42,9 +46,9 @@ public class SearchResultsRecyclerViewAdapter extends RecyclerView.Adapter<Searc
mSubscriptionHandler.dispose(); mSubscriptionHandler.dispose();
} }
mSearchText = searchText; mSearchText = searchText;
Flowable<List<Monster>> foundMonsters = mRepository.searchMonsters(mSearchText); Flowable<List<SearchResultItem>> resultsFlowable = mRepository.searchAll(mSearchText);
mSubscriptionHandler = foundMonsters.subscribe(monsters -> { mSubscriptionHandler = resultsFlowable.subscribe(results -> {
mValues = monsters; mValues = results;
notifyDataSetChanged(); notifyDataSetChanged();
}, },
throwable -> Logger.logError("Error performing search", throwable)); throwable -> Logger.logError("Error performing search", throwable));
@@ -54,18 +58,52 @@ public class SearchResultsRecyclerViewAdapter extends RecyclerView.Adapter<Searc
@Override @Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()) View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.monster_list_content, parent, false); .inflate(R.layout.search_result_list_item, parent, false);
return new ViewHolder(view); return new ViewHolder(view);
} }
@Override @Override
public void onBindViewHolder(@NonNull final ViewHolder holder, int position) { public void onBindViewHolder(@NonNull final ViewHolder holder, int position) {
Monster monster = mValues.get(position); SearchResultItem item = mValues.get(position);
holder.mContentView.setText(monster.name); if (item.type == SearchResultItem.Type.MONSTER && item.monster != null) {
holder.itemView.setTag(monster); Monster monster = item.monster;
holder.mTitleView.setText(monster.name);
StringBuilder sb = new StringBuilder();
if (!StringHelper.isNullOrEmpty(monster.alignment)) {
sb.append(monster.alignment);
}
String cr = monster.getChallengeRatingDescription();
if (!StringHelper.isNullOrEmpty(cr)) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append("CR ").append(cr);
}
if (sb.length() > 0) {
holder.mSubtitleView.setText(sb.toString());
holder.mSubtitleView.setVisibility(View.VISIBLE);
} else {
holder.mSubtitleView.setVisibility(View.GONE);
}
holder.mIconView.setVisibility(View.GONE);
} else if (item.type == SearchResultItem.Type.COLLECTION && item.collection != null) {
Collection collection = item.collection;
holder.mTitleView.setText(collection.name);
if (!StringHelper.isNullOrEmpty(collection.description)) {
holder.mSubtitleView.setText(collection.description);
holder.mSubtitleView.setVisibility(View.VISIBLE);
} else {
holder.mSubtitleView.setVisibility(View.GONE);
}
holder.mIconView.setVisibility(View.VISIBLE);
}
holder.itemView.setTag(item);
holder.itemView.setOnClickListener(view -> { holder.itemView.setOnClickListener(view -> {
if (mOnClickHandler != null) { if (mOnClickHandler != null) {
mOnClickHandler.onItem(monster); mOnClickHandler.onItem(item);
} }
}); });
} }
@@ -76,15 +114,19 @@ public class SearchResultsRecyclerViewAdapter extends RecyclerView.Adapter<Searc
} }
public interface ItemCallback { public interface ItemCallback {
void onItem(Monster monster); void onItem(SearchResultItem item);
} }
public static class ViewHolder extends RecyclerView.ViewHolder { public static class ViewHolder extends RecyclerView.ViewHolder {
final TextView mContentView; final TextView mTitleView;
final TextView mSubtitleView;
final ImageView mIconView;
ViewHolder(View view) { ViewHolder(View view) {
super(view); super(view);
mContentView = view.findViewById(R.id.content); mTitleView = view.findViewById(R.id.title);
mSubtitleView = view.findViewById(R.id.subtitle);
mIconView = view.findViewById(R.id.icon);
} }
} }
} }

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.47 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.23 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
</vector>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/collection_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceListItem"
android:textSize="18sp"
android:textStyle="bold"
android:ellipsize="end"
android:maxLines="1" />
<TextView
android:id="@+id/collection_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textSize="14sp"
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>
<TextView
android:id="@+id/collection_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp"
android:maxLines="1"
android:ellipsize="end"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.collections.CollectionDetailFragment">
<LinearLayout
android:id="@+id/collection_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/detail_collection_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/detail_collection_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="14sp"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />
</LinearLayout>
<View
android:id="@+id/header_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"
app:layout_constraintTop_toBottomOf="@id/collection_header" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/collection_monster_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:padding="@dimen/padding_normal"
app:layout_constraintTop_toBottomOf="@id/header_divider"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/card_monster" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_monster_to_collection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:contentDescription="@string/action_add_monster"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@android:drawable/ic_input_add"
app:tint="@android:color/white" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -6,17 +6,26 @@
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.collections.CollectionsFragment"> tools:context=".ui.collections.CollectionsFragment">
<TextView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/text_collections" android:id="@+id/collection_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_marginStart="8dp" app:layoutManager="LinearLayoutManager"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:textAlignment="center"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/collection_list_item" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_collection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:contentDescription="@string/action_add_collection"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@android:drawable/ic_input_add"
app:tint="@android:color/white" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -20,4 +20,16 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/card_monster" /> tools:listitem="@layout/card_monster" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_to_dashboard"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:contentDescription="@string/action_add_to_dashboard"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@android:drawable/ic_input_add"
app:tint="@android:color/white" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="16dp"
android:background="?attr/selectableItemBackground">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceListItem"
android:textSize="18sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp"
android:maxLines="1"
android:ellipsize="end"
android:visibility="gone" />
</LinearLayout>
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="16dp"
android:contentDescription="@string/title_collections"
android:src="@drawable/ic_collections_black_24dp"
app:tint="?android:attr/textColorSecondary"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_action_add_collection_to_dashboard"
android:title="@string/action_add_collection_to_dashboard"
app:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_action_add_single_monster"
android:title="@string/action_add_single_monster"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_add_collection_option"
android:title="@string/action_add_collection_option"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_clear_dashboard"
android:title="@string/action_clear_dashboard"
app:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_action_import_from_url"
android:title="@string/action_import_from_url"
app:showAsAction="never" />
</menu>

View File

@@ -6,5 +6,21 @@
android:id="@+id/menu_action_edit_monster" android:id="@+id/menu_action_edit_monster"
android:icon="@drawable/ic_edit_24" android:icon="@drawable/ic_edit_24"
android:title="@string/action_edit" android:title="@string/action_edit"
app:showAsAction="always" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_action_share_monster"
android:icon="@drawable/ic_share_24"
android:title="@string/action_share_monster"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_action_add_to_collection"
android:title="@string/action_add_to_collection"
app:showAsAction="never" />
<item
android:id="@+id/menu_action_add_to_dashboard"
android:title="@string/action_add_to_dashboard"
app:showAsAction="never" />
</menu> </menu>

View File

@@ -13,6 +13,9 @@
<action <action
android:id="@+id/action_navigation_search_to_navigation_monster" android:id="@+id/action_navigation_search_to_navigation_monster"
app:destination="@id/navigation_monster" /> app:destination="@id/navigation_monster" />
<action
android:id="@+id/action_navigation_search_to_collectionDetailFragment"
app:destination="@id/collectionDetailFragment" />
</fragment> </fragment>
<fragment <fragment
android:id="@+id/navigation_dashboard" android:id="@+id/navigation_dashboard"
@@ -31,6 +34,21 @@
<action <action
android:id="@+id/action_navigation_collections_to_navigation_monster" android:id="@+id/action_navigation_collections_to_navigation_monster"
app:destination="@id/navigation_monster" /> app:destination="@id/navigation_monster" />
<action
android:id="@+id/action_navigation_collections_to_collectionDetailFragment"
app:destination="@id/collectionDetailFragment" />
</fragment>
<fragment
android:id="@+id/collectionDetailFragment"
android:name="com.majinnaibu.monstercards.ui.collections.CollectionDetailFragment"
android:label="@string/title_collection_details"
tools:layout="@layout/fragment_collection_detail">
<argument
android:name="collection_id"
app:argType="string" />
<action
android:id="@+id/action_collectionDetailFragment_to_navigation_monster"
app:destination="@id/navigation_monster" />
</fragment> </fragment>
<fragment <fragment
android:id="@+id/navigation_library" android:id="@+id/navigation_library"

View File

@@ -1,5 +1,7 @@
<resources> <resources>
<string name="action_add_ability">Add Ability</string> <string name="action_add_ability">Add Ability</string>
<string name="action_add_collection">Add collection</string>
<string name="action_add_to_collection">Add to Collection</string>
<string name="action_add_condition_immunity">Add Condition</string> <string name="action_add_condition_immunity">Add Condition</string>
<string name="action_add_damage_type">Add Damage Type</string> <string name="action_add_damage_type">Add Damage Type</string>
<string name="action_add_item">Add Item</string> <string name="action_add_item">Add Item</string>
@@ -137,4 +139,36 @@
<string name="title_monsterDetails_fmt">%1$s Details</string> <string name="title_monsterDetails_fmt">%1$s Details</string>
<string name="title_search">Search</string> <string name="title_search">Search</string>
<string name="wisdom_abbreviation">WIS</string> <string name="wisdom_abbreviation">WIS</string>
<string name="title_new_collection">New Collection</string>
<string name="title_collection_details">Collection Details</string>
<string name="title_select_collection">Select Collection</string>
<string name="label_collection_name">Collection Name</string>
<string name="label_collection_description">Description (optional)</string>
<string name="dialog_create">Create</string>
<string name="dialog_cancel">Cancel</string>
<string name="snackbar_collection_created">Collection %1$s created</string>
<string name="snackbar_monster_added_to_collection">Added %1$s to %2$s</string>
<string name="snackbar_failed_to_create_collection">Failed to create collection</string>
<string name="no_collections_available">No collections available. Create one first!</string>
<string name="format_monster_count_one">1 monster</string>
<string name="format_monster_count_other">%d monsters</string>
<string name="action_add_to_dashboard">Add to Dashboard</string>
<string name="action_add_collection_to_dashboard">Add Collection to Dashboard</string>
<string name="action_add_single_monster">Add Monster</string>
<string name="action_add_collection_option">Add Collection</string>
<string name="action_clear_dashboard">Clear Dashboard</string>
<string name="dialog_add_to_dashboard">Add to Dashboard</string>
<string name="snackbar_added_to_dashboard">Added %1$s to Dashboard</string>
<string name="snackbar_collection_added_to_dashboard">Added collection %1$s to Dashboard</string>
<string name="snackbar_dashboard_cleared">Dashboard cleared</string>
<string name="no_monsters_available">No monsters available in library. Create one first!</string>
<string name="action_import_from_url">Import URL...</string>
<string name="dialog_import_url_title">Import Entity or Character</string>
<string name="dialog_import_url_message">Enter or paste a D&amp;D Beyond URL, Character ID, or Open5e JSON payload:</string>
<string name="dialog_import_url_hint">e.g. D&amp;D Beyond URL or Open5e JSON</string>
<string name="dialog_import">Import</string>
<string name="toast_importing_url">Importing entity...</string>
<string name="failed_to_import_url">Failed to import entity. Please check the URL, ID, or JSON format.</string>
<string name="action_share_monster">Share / Export Monster (.card)</string>
<string name="failed_to_share_monster">Failed to share monster</string>
</resources> </resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="shared_cards" path="." />
<external-cache-path name="shared_cards_ext" path="." />
</paths>

View File

@@ -1,17 +0,0 @@
package com.majinnaibu.monstercards.init;
import android.content.Context;
import android.os.Bundle;
import androidx.navigation.NavController;
import androidx.navigation.NavDestination;
@SuppressWarnings("unused")
public class FlipperInitializer {
public static void init(Context ctx) {
}
public static void sendNavigationEvent(NavController controller, NavDestination destination, Bundle arguments) {
}
}

View File

@@ -0,0 +1,83 @@
package com.majinnaibu.monstercards.exporters;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.majinnaibu.monstercards.data.enums.AbilityScore;
import com.majinnaibu.monstercards.models.Language;
import com.majinnaibu.monstercards.models.Monster;
import com.majinnaibu.monstercards.models.Skill;
import com.majinnaibu.monstercards.models.Trait;
import org.junit.Before;
import org.junit.Test;
import java.util.UUID;
public class Open5eExporterTest {
private Open5eExporter exporter;
@Before
public void setUp() {
exporter = new Open5eExporter();
}
@Test
public void testExportMonster_BasicFieldsAndEnvelope() {
Monster monster = new Monster();
monster.id = UUID.fromString("e5b72e61-a83d-4c3d-b4b1-e28a5a41c101");
monster.name = "Goblin";
monster.size = "Small";
monster.type = "Humanoid";
monster.subtype = "goblinoid";
monster.alignment = "neutral evil";
monster.strengthScore = 8;
monster.dexterityScore = 14;
monster.constitutionScore = 10;
monster.intelligenceScore = 10;
monster.wisdomScore = 8;
monster.charismaScore = 8;
monster.walkSpeed = 30;
monster.playerName = "Player One";
monster.background = "Criminal";
monster.personalityTraits = "Always cautious";
monster.languages.add(new Language("Common", true));
monster.languages.add(new Language("Goblin", true));
monster.skills.add(new Skill("Stealth", AbilityScore.DEXTERITY));
monster.abilities.add(new Trait("Nimble Escape", "Disengage or Hide as bonus action."));
monster.actions.add(new Trait("Scimitar", "+4 to hit, 1d6+2 slashing."));
String json = exporter.exportMonster(monster);
assertNotNull(json);
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
assertEquals("open5e", root.get("ruleset_id").getAsString());
assertEquals("character", root.get("entity_type").getAsString());
assertEquals("Goblin", root.get("display_name").getAsString());
assertEquals("e5b72e61-a83d-4c3d-b4b1-e28a5a41c101", root.get("uuid").getAsString());
assertTrue(root.has("properties"));
JsonObject props = root.getAsJsonObject("properties");
assertEquals("Goblin", props.get("name").getAsString());
assertEquals("Small", props.get("size").getAsString());
assertEquals("Player One", props.get("player_name").getAsString());
assertEquals("Criminal", props.get("background").getAsString());
JsonObject abilities = props.getAsJsonObject("abilities");
assertEquals(8, abilities.get("strength").getAsInt());
assertEquals(14, abilities.get("dexterity").getAsInt());
assertTrue(props.has("skills"));
assertTrue(props.has("languages"));
assertTrue(props.has("traits"));
assertTrue(props.has("actions"));
}
}

View File

@@ -0,0 +1,72 @@
package com.majinnaibu.monstercards.importers;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import com.majinnaibu.monstercards.helpers.MonsterImportHelper;
import com.majinnaibu.monstercards.models.Monster;
import org.junit.Before;
import org.junit.Test;
import java.io.File;
import java.nio.file.Files;
public class DnDBeyondImporterTest {
private DnDBeyondImporter importer;
@Before
public void setUp() {
importer = new DnDBeyondImporter();
}
@Test
public void testCanImport_UrlAndNumericIdAndJson() {
assertTrue(importer.canImport("https://www.dndbeyond.com/characters/49074997/s7sLyX"));
assertTrue(importer.canImport("https://www.dndbeyond.com/characters/49074997"));
assertTrue(importer.canImport("49074997"));
assertTrue(importer.canImport(" 49074997 "));
assertFalse(importer.canImport("invalid url"));
assertFalse(importer.canImport(""));
}
@Test
public void testExtractCharacterId() {
assertEquals("49074997", DnDBeyondImporter.extractCharacterId("https://www.dndbeyond.com/characters/49074997/s7sLyX"));
assertEquals("49074997", DnDBeyondImporter.extractCharacterId("https://www.dndbeyond.com/characters/49074997"));
assertEquals("49074997", DnDBeyondImporter.extractCharacterId("49074997"));
}
@Test
public void testParseSampleCharacterJson() throws Exception {
File sampleFile = new File("../docs/dndbeyond-character-sample.json");
if (!sampleFile.exists()) {
sampleFile = new File("../../docs/dndbeyond-character-sample.json");
}
assertTrue("Sample character JSON file should exist", sampleFile.exists());
String json = new String(Files.readAllBytes(sampleFile.toPath()));
assertTrue(importer.canImport(json));
Monster monster = importer.parse(json);
assertNotNull(monster);
assertEquals("Joy", monster.name);
assertEquals(12, monster.strengthScore);
assertEquals(15, monster.dexterityScore);
assertEquals(14, monster.constitutionScore);
assertEquals(10, monster.intelligenceScore);
assertEquals(13, monster.wisdomScore);
assertEquals(17, monster.charismaScore);
assertTrue(monster.hasCustomHP);
assertEquals("28 (5d8)", monster.customHPDescription);
assertEquals(5, monster.hitDice);
Monster delegatedMonster = MonsterImportHelper.fromJSON(json);
assertNotNull(delegatedMonster);
assertEquals("Joy", delegatedMonster.name);
}
}

View File

@@ -0,0 +1,100 @@
package com.majinnaibu.monstercards.importers;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import com.majinnaibu.monstercards.exporters.Open5eExporter;
import com.majinnaibu.monstercards.helpers.MonsterImportHelper;
import com.majinnaibu.monstercards.models.Monster;
import org.junit.Before;
import org.junit.Test;
import java.io.File;
import java.nio.file.Files;
public class Open5eImporterTest {
private Open5eImporter importer;
private Open5eExporter exporter;
@Before
public void setUp() {
importer = new Open5eImporter();
exporter = new Open5eExporter();
}
@Test
public void testParseGoblinSampleJson() throws Exception {
File sampleFile = new File("/Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/examples/character/goblin.json");
if (!sampleFile.exists()) {
sampleFile = new File("../../../CharacterDataFiles/rulesets/open5e/examples/character/goblin.json");
}
if (!sampleFile.exists()) {
sampleFile = new File("../../CharacterDataFiles/rulesets/open5e/examples/character/goblin.json");
}
assertTrue("Goblin sample JSON file should exist", sampleFile.exists());
String json = new String(Files.readAllBytes(sampleFile.toPath()));
assertTrue(importer.canImport(json));
Monster monster = importer.parse(json);
assertNotNull(monster);
assertEquals("Goblin", monster.name);
assertEquals("small", monster.size);
assertEquals("Humanoid", monster.type);
assertEquals("goblinoid", monster.subtype);
assertEquals("neutral evil", monster.alignment);
assertEquals(8, monster.strengthScore);
assertEquals(14, monster.dexterityScore);
assertEquals(10, monster.constitutionScore);
assertEquals(10, monster.intelligenceScore);
assertEquals(8, monster.wisdomScore);
assertEquals(8, monster.charismaScore);
assertEquals(30, monster.walkSpeed);
assertEquals(2, monster.hitDice);
assertEquals(1, monster.abilities.size());
assertEquals("Nimble Escape", monster.abilities.get(0).name);
assertEquals(2, monster.actions.size());
assertEquals("Scimitar", monster.actions.get(0).name);
assertEquals("Shortbow", monster.actions.get(1).name);
Monster delegatedMonster = MonsterImportHelper.fromJSON(json);
assertNotNull(delegatedMonster);
assertEquals("Goblin", delegatedMonster.name);
}
@Test
public void testRoundTrip_ExportAndImport() throws Exception {
Monster monster = new Monster();
monster.name = "Custom Dragon";
monster.size = "Huge";
monster.type = "Dragon";
monster.strengthScore = 23;
monster.dexterityScore = 10;
monster.constitutionScore = 21;
monster.intelligenceScore = 14;
monster.wisdomScore = 13;
monster.charismaScore = 17;
monster.playerName = "DM";
monster.background = "Ancient";
String open5eJson = exporter.exportMonster(monster);
assertTrue(importer.canImport(open5eJson));
Monster importedMonster = importer.parse(open5eJson);
assertNotNull(importedMonster);
assertEquals("Custom Dragon", importedMonster.name);
assertEquals("Huge", importedMonster.size);
assertEquals("Dragon", importedMonster.type);
assertEquals(23, importedMonster.strengthScore);
assertEquals(21, importedMonster.constitutionScore);
assertEquals("DM", importedMonster.playerName);
assertEquals("Ancient", importedMonster.background);
}
}

View File

@@ -0,0 +1,179 @@
package com.majinnaibu.monstercards.importers;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import com.majinnaibu.monstercards.helpers.MonsterImportHelper;
import com.majinnaibu.monstercards.models.Monster;
import org.junit.Test;
public class TetraCubeMonsterImporterTest {
private final TetraCubeMonsterImporter importer = new TetraCubeMonsterImporter();
private static final String SAMPLE_TETRACUBE_JSON = "{"
+ "\"name\": \"Goblin\","
+ "\"size\": \"Small\","
+ "\"type\": \"humanoid\","
+ "\"tag\": \"goblinoid\","
+ "\"alignment\": \"neutral evil\","
+ "\"hitDice\": 2,"
+ "\"armorName\": \"leather\","
+ "\"shieldBonus\": 1,"
+ "\"speed\": 30,"
+ "\"strPoints\": 8,"
+ "\"dexPoints\": 14,"
+ "\"conPoints\": 10,"
+ "\"intPoints\": 10,"
+ "\"wisPoints\": 8,"
+ "\"chaPoints\": 8,"
+ "\"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));
}
@Test
public void canImport_invalidJson_returnsFalse() {
assertFalse(importer.canImport("not a json string"));
}
@Test
public void canImport_emptyString_returnsFalse() {
assertFalse(importer.canImport(""));
}
@Test
public void canImport_otherJson_returnsFalse() {
assertFalse(importer.canImport("{\"foo\": \"bar\"}"));
}
@Test
public void parse_validTetraCubeJson_createsMonster() throws Exception {
Monster monster = importer.parse(SAMPLE_TETRACUBE_JSON);
assertNotNull(monster);
assertEquals("Goblin", monster.name);
assertEquals("Small", monster.size);
assertEquals("humanoid", monster.type);
assertEquals("goblinoid", monster.subtype);
assertEquals("neutral evil", monster.alignment);
assertEquals(2, monster.hitDice);
assertEquals(30, monster.walkSpeed);
assertEquals(8, monster.strengthScore);
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);
assertNotNull(monster);
assertEquals("Goblin", monster.name);
}
}

View File

@@ -5,9 +5,9 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:4.2.2' classpath 'com.android.tools.build:gradle:9.3.2'
def nav_version = "2.3.0" def nav_version = "2.9.8"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
@@ -15,15 +15,6 @@ buildscript {
} }
} }
allprojects {
repositories {
google()
mavenCentral()
// maven { url "https://oss.jfrog.org/libs-snapshot" }
// maven { url 'http://repo1.maven.org/maven2' }
}
}
task clean(type: Delete) { task clean(type: Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@@ -6,14 +6,23 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html # http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. For more details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true # org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the # AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK # Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn # https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX # Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true android.enableJetifier=false
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.uniquePackageNames=false
android.dependency.useConstraints=false
android.javaCompile.suppressSourceTargetDeprecationWarning=true
android.r8.strictFullModeForKeepRules=false
org.gradle.configuration-cache=true

View File

@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

View File

@@ -1,6 +1,6 @@
#Sat May 01 23:00:30 PDT 2021 #Fri Aug 14 17:08:57 PDT 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip

View File

@@ -1,2 +1,26 @@
include ':app' pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MonsterCards" rootProject.name = "MonsterCards"
include ':app'

275
docs/Import-Export.md Normal file
View File

@@ -0,0 +1,275 @@
# Import & Export Architecture & Plan
This document outlines the modular architecture and step-by-step technical roadmap for handling monster stat block imports, exports, and sharing across multiple formats in the **Monster Cards** application.
---
## 1. File Formats & Importer Architecture
Monster Cards separates canonical native formats from third-party import sources:
1. **Universal Native Format (`.card` / `.card.txt`)**:
- Our native file format utilizing the **Universal 2-Tier Envelope Specification** (`$schema`, `uuid`, `ruleset_id`, `entity_type`, `display_name`, `template_id`, `properties`).
- Uses the **Open5e Ruleset** (`ruleset_id: "open5e"`) which matches our app's 5e monster and character needs ([`entities/character.json`](file:///Users/tom/Projects/TTRPG/CharacterDataFiles/rulesets/open5e/entities/character.json)).
- Supported for both **Export** and **Native Import**.
2. **Third-Party Import-Only Formats**:
- `.monster` / `.monster.txt`: Tetra-cube generator stat block JSON format (Import-only).
- D&D Beyond URLs / API payloads: D&D Beyond character web service format (Import-only).
All format parsers implement a shared interface:
```java
public interface EntityImporter<T> {
/**
* Determines whether the given input string or payload can be handled by this importer.
*/
boolean canImport(@NonNull String input);
/**
* Parses the raw input string into internal domain memory objects (e.g., Monster).
*/
@NonNull
T parse(@NonNull String input) throws Exception;
}
```
The output of any `EntityImporter` is an in-memory domain model (such as `Monster`), which is then passed to `MonsterImportFragment` for UI preview and Room database persistence.
---
## 2. Step-by-Step Implementation Roadmap
```
┌────────────────----------------────────────────────────┐
│ Step 0: Restrict File Extension Intent Filters │
└────────────────────────────────────────────────────────┘
│
v
┌────────────────----------------────────────────────────┐
│ Step 1: Refactor Import Code to Shared Interface │
└────────────────────────────────────────────────────────┘
│
v
┌────────────────----------------────────────────────────┐
│ Step 2: Update Tetra-cube Importer to Newest Format │
└────────────────────────────────────────────────────────┘
│
v
┌────────────────----------------────────────────────────┐
│ Step 3: Import from D&D Beyond Character URL │
└────────────────────────────────────────────────────────┘
│
v
┌────────────────----------------────────────────────────┐
│ Step 4: Export to Custom Internal Format (Open5e) │
└────────────────────────────────────────────────────────┘
│
v
┌────────────────----------------────────────────────────┐
│ Step 5: Import from Custom Internal Format (Open5e) │
└────────────────────────────────────────────────────────┘
│
v
┌────────────────----------------────────────────────────┐
│ Step 6: Generic Android Share Button Feature │
└────────────────────────────────────────────────────────┘
│
v
┌────────────────----------------────────────────────────┐
│ Step 7: Specific Share Targets (NFC, Bluetooth, URL) │
└────────────────────────────────────────────────────────┘
```
---
### Step 0: File Extension Intent Filters (.monster & .card)
Restrict the Android app so it reacts to supported stat block file extensions (`.monster`, `.monster.txt` for Tetra-cube format; `.card`, `.card.txt` for internal Open5e card format) instead of all generic `.txt` files.
1. **`AndroidManifest.xml`**:
Add `android:pathPattern` constraints (`.monster`, `.monster.txt`, `.card`, `.card.txt`) to the `<intent-filter>` for `MainActivity`.
2. **`MainActivity.java`**:
Implement runtime display name validation querying `OpenableColumns.DISPLAY_NAME` via `ContentResolver` to filter out non-monster files passed via `content://` URIs.
---
### Step 1: Refactor Import Code into Importer Architecture
Extract the current parsing logic out of `MonsterImportHelper.java` into a standalone importer class implementing `EntityImporter<Monster>`.
- **New Class**: `TetraCubeMonsterImporter implements EntityImporter<Monster>`
- **Responsibilities**:
- `canImport(json)`: Validates that the JSON string contains Tetra-cube properties (e.g. `hitDice`, `armorName`, `strPoints`).
- `parse(json)`: Converts the raw JSON payload into an in-memory `Monster` object.
---
### Step 2: Update Tetra-cube Importer to Newest Format
Update `TetraCubeMonsterImporter` to support newer fields from the Tetra-cube generator source (`js/statblock-script.js`):
- **New Fields to Parse**:
- `bonusActions`: Array of `{ name, desc }` objects $\rightarrow$ mapped to `monster.actions` or bonus action structure.
- `mythics` / `isMythic` / `mythicDescription`: Array of mythic action objects and intro text $\rightarrow$ mapped to `monster.legendaryActions`.
- `blind`: Parse boolean flag to append `"(blind beyond this radius)"` to `blindsight`.
- Section intro text: Parse `legendariesDescription`, `lairDescription`, `regionalDescription`.
---
### Step 3: Import from D&D Beyond URL & Web Services
Allow users to import characters, monsters, and future entity types directly from D&D Beyond URLs using a multi-channel ingestion architecture.
- **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 (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`
#### 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).
#### Open5e vs. Monster Cards Database Schema Comparison & Final Mappings
- **Database Storage Architecture (Room SQLite vs. Open5e JSON)**:
- **Ability Scores**: Kept as flat integer columns in Room (`strength_score`, `dexterity_score`, etc.) for maximum query performance. When exporting to Open5e JSON, `Open5eExporter` packages these into a `properties.abilities` JSON object `{ "strength": 12, ... }`. Modifiers are calculated dynamically using `floor((score - 10) / 2)`.
- **Variable-Length Lists (`traits`, `actions`, `skills`, `languages`)**: Stored as text columns using Room `@TypeConverter` JSON converters. In Open5e JSON export, traits and actions are formatted as `{ "name": "...", "desc": "..." }` arrays.
- **PC Identity Fields**: Added PC identity columns (`player_name`, `background`, `personality_traits`, `ideals`, `bonds`, `flaws`, `age`, `height`, `weight`, `eyes`, `skin`, `hair`, `appearance`, `backstory`, `allies_and_organizations`) directly to `Monster.java` (`Database version 7`, `MIGRATION_6_7`).
- **Omitted PC Fields**: `currency`, `inventory`, `death_saves`, and `spellcasting.slots` are ignored/omitted in monster stat block exports.
---
### Step 5: Import from Custom Internal Format (Open5e Specification)
Implement import capability for custom Open5e JSON files.
- **New Class**: `Open5eImporter implements EntityImporter<Monster>`
- **Pipeline**:
- `canImport(json)`: Validates `"ruleset_id": "open5e"` and `"entity_type": "character"`.
- `parse(json)`: Unpacks `properties` into internal `Monster` domain models.
---
### Step 6: Generic Android Share Button Feature
Implement a generic "Share" feature allowing users to export and share monsters or collections from detail screens.
- **UI Action**: Add a "Share" item to action menus in `MonsterDetailFragment` and `CollectionDetailFragment`.
- **Android Intent**: Uses standard Android `ACTION_SEND` intent with `Intent.EXTRA_STREAM` or `Intent.EXTRA_TEXT` to pass exported files to other apps (e.g. Email, Drive, Messaging, Files).
---
### Step 7: Specific Share Targets & Channels
Extend the generic sharing feature with specialized, direct sharing channels (to be implemented as individual sub-steps):
- **Sub-Step 7.1 (NFC Sharing)**: Share monster data directly between devices via NFC (NDEF records / Android Beam).
- **Sub-Step 7.2 (Bluetooth / Wi-Fi Direct)**: Share monster files directly between nearby Android devices via Bluetooth / Wi-Fi.
- **Sub-Step 7.3 (Web URL with Embedded Payload)**: Generate a shareable Web URL containing compressed/base64-encoded monster JSON data.
---
## 3. Implementation Checklist & Status
- [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.
- [x] **Step 2**: Update Tetra-cube importer class to support the newest Tetra-cube format (`bonusActions`, `mythics`, `blind`, intro descriptions).
- [x] **Step 3**: Import from D&D Beyond URL (`https://www.dndbeyond.com/characters/49074997` fetching from v5 character service endpoint).
- [x] **Step 4**: Export to internal format described by Open5e document (`Open5eExporter`).
- [x] **Step 5**: Import from internal format described by Open5e document (`Open5eImporter`).
- [x] **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.

243
docs/ImportFromMonster.md Normal file
View File

@@ -0,0 +1,243 @@
# Import Process & Schema Documentation
> **Platform Note**: This documentation describes the import process and internal database schema for the **Android Application** (`com.majinnaibu.monstercards`).
---
## 1. Overview of the Import Process
The Monster Cards Android application can import monster stat blocks formatted in JSON from `.monster` or `.monster.txt` files shared or opened from other applications (such as file managers, messaging apps, or browsers).
```
+-------------------------------------------------------+
| External .monster / .monster.txt file (JSON Payload) |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| MainActivity (Intent Handling: ACTION_VIEW / SEND) |
| - Extracts raw JSON string from Content/File URI |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| MonsterImportFragment & MonsterImportViewModel |
| - MonsterImportHelper.fromJSON(json) parses JSON |
| - Maps JSON fields to internal Monster domain model |
| - Renders UI preview with calculated AC, HP, & Markdown|
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| User clicks "Import" Menu Action |
| - Generates new UUID |
| - Persists Monster to Room Database (monsters table) |
| - Rebuilds FTS index (monsters_fts) |
+-------------------------------------------------------+
```
---
## 2. Source Format (.monster / .monster.txt) & File Extension Behavior
The `.monster` file format is generated by Tetra-cube's D&D 5e Statblock Generator (`statblock-script.js`).
### File Saving Behavior (`SavedData.SaveToFile`)
```javascript
SaveToFile: () => saveAs(new Blob([JSON.stringify(mon)], {
type: "text/plain;charset=utf-8"
}), mon.name.toLowerCase() + ".monster")
```
- **Desktop Browsers**: Save the file with the specified `.monster` filename.
- **Android Mobile Browsers & DownloadManager**: Because the `Blob` uses MIME type `text/plain;charset=utf-8`, Android file handlers append a `.txt` extension, creating `.monster.txt` files.
---
## 3. JSON Parsing & Field Mapping (`MonsterImportHelper.java`)
`MonsterImportHelper.fromJSON(String json)` uses Gson's `JsonParser` to parse the root JSON object and populate a `Monster` instance.
### Complete Field Mapping Reference
| JSON Field Name | Target `Monster` Field | Type / Default | Description / Conversion Logic |
| :--- | :--- | :--- | :--- |
| `name` | `monster.name` | `String` | Monster name |
| `size` | `monster.size` | `String` | Size string (e.g., `"medium"`, `"large"`) |
| `type` | `monster.type` | `String` | Creature type (e.g., `"humanoid"`, `"dragon"`) |
| `tag` | `monster.subtype` | `String` | Subtype / tag (e.g., `"elf"`, `"shapechanger"`) |
| `alignment` | `monster.alignment` | `String` | Alignment string (e.g., `"neutral good"`) |
| `hitDice` | `monster.hitDice` | `int` (default 1) | Number of hit dice |
| `armorName` | `monster.armorType` | `ArmorType` | Converted via `ArmorTypeConverter.armorTypeFromStringValue()` |
| `shieldBonus` | `monster.shieldBonus` | `int` | Shield AC bonus (+2 if present) |
| `natArmorBonus` | `monster.naturalArmorBonus` | `int` | Additional natural armor AC bonus |
| `otherArmorDesc` | `monster.otherArmorDescription` | `String` | Custom armor description string |
| `speed` | `monster.walkSpeed` | `int` | Walking speed in feet |
| `burrowSpeed` | `monster.burrowSpeed` | `int` | Burrowing speed in feet |
| `climbSpeed` | `monster.climbSpeed` | `int` | Climbing speed in feet |
| `flySpeed` | `monster.flySpeed` | `int` | Flying speed in feet |
| `hover` | `monster.canHover` | `boolean` | Whether flying creature can hover |
| `swimSpeed` | `monster.swimSpeed` | `int` | Swimming speed in feet |
| `customHP` | `monster.hasCustomHP` | `boolean` | Flag for override custom HP string |
| `customSpeed` | `monster.hasCustomSpeed` | `boolean` | Flag for override custom speed string |
| `hpText` | `monster.customHPDescription` | `String` | Custom HP text description |
| `speedDesc` | `monster.customSpeedDescription` | `String` | Custom speed text description |
| `strPoints` | `monster.strengthScore` | `int` (default 10) | Strength ability score |
| `dexPoints` | `monster.dexterityScore` | `int` (default 10) | Dexterity ability score |
| `conPoints` | `monster.constitutionScore` | `int` (default 10) | Constitution ability score |
| `intPoints` | `monster.intelligenceScore` | `int` (default 10) | Intelligence ability score |
| `wisPoints` | `monster.wisdomScore` | `int` (default 10) | Wisdom ability score |
| `chaPoints` | `monster.charismaScore` | `int` (default 10) | Charisma ability score |
| `blindsight` | `monster.senses` | `Set<String>` | Added as `"blindsight <N> ft."` if `N > 0` |
| `darkvision` | `monster.senses` | `Set<String>` | Added as `"darkvision <N> ft."` if `N > 0` |
| `tremorsense` | `monster.senses` | `Set<String>` | Added as `"tremorsense <N> ft."` if `N > 0` |
| `truesight` | `monster.senses` | `Set<String>` | Added as `"truesight <N> ft."` if `N > 0` |
| `telepathy` | `monster.telepathyRange` | `int` | Telepathy range in feet |
| `cr` | `monster.challengeRating` | `ChallengeRating` | Converted via `ChallengeRatingConverter` |
| `customCr` | `monster.customChallengeRatingDescription` | `String` | Custom CR text |
| `customProf` | `monster.customProficiencyBonus` | `int` | Custom proficiency bonus override |
| `abilities` | `monster.abilities` | `List<Trait>` | List of JSON objects `{ "name": "...", "desc": "..." }` |
| `actions` | `monster.actions` | `List<Trait>` | List of JSON objects `{ "name": "...", "desc": "..." }` |
| `bonusActions` | `monster.actions` *(Future)* | `List<Trait>` | Bonus actions array |
| `reactions` | `monster.reactions` | `List<Trait>` | List of JSON objects `{ "name": "...", "desc": "..." }` |
| `legendaries` | `monster.legendaryActions` | `List<Trait>` | List of JSON objects `{ "name": "...", "desc": "..." }` |
| `mythics` | `monster.legendaryActions` *(Future)* | `List<Trait>` | Mythic actions array |
| `lairs` | `monster.lairActions` | `List<Trait>` | List of JSON objects `{ "name": "...", "desc": "..." }` |
| `regionals` | `monster.regionalActions` | `List<Trait>` | List of JSON objects `{ "name": "...", "desc": "..." }` |
| `sthrows` | Saving throw proficiencies | `ProficiencyType` | Array of objects `{ "name": "str"\|"dex"\|"con"\|"int"\|"wis"\|"cha" }`. Sets proficiency to `PROFICIENT`. |
| `skills` | `monster.skills` | `Set<Skill>` | Array of `{ "name": "...", "stat": "...", "note": " (ex)" }`. `note == " (ex)"` sets `EXPERTISE`. |
| `damageTypes`, `specialdamage` | Immunities / Resistances / Vulnerabilities | `Set<String>` | Array of `{ "name": "...", "type": "i"\|"r"\|"v" }`. Sorted into immunities (`"i"`), resistances (`"r"`), or vulnerabilities (`"v"`). |
| `conditions` | `monster.conditionImmunities` | `Set<String>` | Array of `{ "name": "..." }` |
| `languages` | `monster.languages` | `Set<Language>` | Array of `{ "name": "...", "speaks": true\|false }` |
| `understandsBut` | `monster.understandsButDescription` | `String` | Language qualifier text |
---
## 4. Preview & Database Persistence
1. **ViewModel Computation (`MonsterImportViewModel.java`)**:
- Computes calculated stat block values:
- **AC**: Calculated based on `armorType`, dexterity modifier, shield bonus, natural armor bonus, or custom description.
- **HP**: Calculated as `floor(hitDice * ((dieSize + 1) / 2 + conModifier))` where `dieSize` is derived from size (e.g., Medium = d8, Large = d10).
- **Dynamic Placeholders**: Replaces placeholders in trait descriptions (e.g., `[STR ATK]`, `[WIS SAVE]`) with computed attack bonuses and spell save DCs based on ability scores and proficiency bonuses.
- **Markdown Rendering**: Trait and action descriptions are parsed through `CommonMarkHelper` to format inline HTML/Markdown for display.
2. **Persistence (`MonsterImportFragment.java`)**:
- When the user taps the **Import** action menu button (`R.id.menu_action_import_monster`):
1. Assigns a new `UUID` to `monster.id`.
2. Calls `MonsterRepository.addMonster(monster)` which executes an `INSERT` into the Room database asynchronously on an IO thread via RxJava3.
3. Shows a `Snackbar` confirming import success and navigates to the library/detail view for the imported monster.
---
## 5. Internal Database Schema (`monsters` Table)
The app uses **Room Database** (Database Name: `monsters`, Version: `5`).
### Table: `monsters`
| Column Name | SQLite Data Type | Default Value | Description / Room TypeConverter |
| :--- | :--- | :--- | :--- |
| `id` | `TEXT` | Primary Key | UUID string (`UUIDConverter`) |
| `name` | `TEXT` | `""` | Monster name |
| `size` | `TEXT` | `""` | Size category (e.g. `"medium"`) |
| `type` | `TEXT` | `""` | Creature type |
| `subtype` | `TEXT` | `""` | Creature tag / subtype |
| `alignment` | `TEXT` | `""` | Alignment |
| `strength_score` | `INTEGER` | `10` | Strength score |
| `strength_saving_throw_advantage` | `TEXT` | `"none"` | Advantage type (`"none"`, `"advantage"`, `"disadvantage"`) |
| `strength_saving_throw_proficiency` | `TEXT` | `"none"` | Proficiency type (`"none"`, `"proficient"`, `"expertise"`) |
| `dexterity_score` | `INTEGER` | `10` | Dexterity score |
| `dexterity_saving_throw_advantage` | `TEXT` | `"none"` | Dexterity saving throw advantage |
| `dexterity_saving_throw_proficiency` | `TEXT` | `"none"` | Dexterity saving throw proficiency |
| `constitution_score` | `INTEGER` | `10` | Constitution score |
| `constitution_saving_throw_advantage` | `TEXT` | `"none"` | Constitution saving throw advantage |
| `constitution_saving_throw_proficiency` | `TEXT` | `"none"` | Constitution saving throw proficiency |
| `intelligence_score` | `INTEGER` | `10` | Intelligence score |
| `intelligence_saving_throw_advantage` | `TEXT` | `"none"` | Intelligence saving throw advantage |
| `intelligence_saving_throw_proficiency` | `TEXT` | `"none"` | Intelligence saving throw proficiency |
| `wisdom_score` | `INTEGER` | `10` | Wisdom score |
| `wisdom_saving_throw_advantage` | `TEXT` | `"none"` | Wisdom saving throw advantage |
| `wisdom_saving_throw_proficiency` | `TEXT` | `"none"` | Wisdom saving throw proficiency |
| `charisma_score` | `INTEGER` | `10` | Charisma score |
| `charisma_saving_throw_advantage` | `TEXT` | `"none"` | Charisma saving throw advantage |
| `charisma_saving_throw_proficiency` | `TEXT` | `"none"` | Charisma saving throw proficiency |
| `armor_type` | `TEXT` | `"none"` | String representation of `ArmorType` (`ArmorTypeConverter`) |
| `shield_bonus` | `INTEGER` | `0` | Shield AC bonus |
| `natural_armor_bonus` | `INTEGER` | `0` | Natural armor bonus |
| `other_armor_description` | `TEXT` | `""` | Custom armor description |
| `hit_dice` | `INTEGER` | `1` | Hit dice count |
| `has_custom_hit_points` | `INTEGER` | `0` | Boolean flag (0 = false, 1 = true) |
| `custom_hit_points_description` | `TEXT` | `""` | Override HP string |
| `walk_speed` | `INTEGER` | `0` | Walk speed in ft |
| `burrow_speed` | `INTEGER` | `0` | Burrow speed in ft |
| `climb_speed` | `INTEGER` | `0` | Climb speed in ft |
| `fly_speed` | `INTEGER` | `0` | Fly speed in ft |
| `can_hover` | `INTEGER` | `0` | Boolean flag |
| `swim_speed` | `INTEGER` | `0` | Swim speed in ft |
| `has_custom_speed` | `INTEGER` | `0` | Boolean flag |
| `custom_speed_description` | `TEXT` | NULL | Override speed string |
| `challenge_rating` | `TEXT` | `"1"` | String representation of `ChallengeRating` (`ChallengeRatingConverter`) |
| `custom_challenge_rating_description` | `TEXT` | `""` | Custom CR text |
| `custom_proficiency_bonus` | `INTEGER` | `0` | Custom proficiency bonus override |
| `telepathy_range` | `INTEGER` | `0` | Telepathy range in ft |
| `understands_but_description` | `TEXT` | `""` | Language qualifier description |
| `senses` | `TEXT` | `"[]"` | JSON array of strings (`SetOfStringConverter`) |
| `skills` | `TEXT` | `"[]"` | JSON array of Skill objects (`SetOfSkillConverter`) |
| `damage_immunities` | `TEXT` | `"[]"` | JSON array of strings (`SetOfStringConverter`) |
| `damage_resistances` | `TEXT` | `"[]"` | JSON array of strings (`SetOfStringConverter`) |
| `damage_vulnerabilities` | `TEXT` | `"[]"` | JSON array of strings (`SetOfStringConverter`) |
| `condition_immunities` | `TEXT` | `"[]"` | JSON array of strings (`SetOfStringConverter`) |
| `languages` | `TEXT` | `"[]"` | JSON array of Language objects (`SetOfLanguageConverter`) |
| `abilities` | `TEXT` | `"[]"` | JSON array of Trait objects (`ListOfTraitsConverter`) |
| `actions` | `TEXT` | `"[]"` | JSON array of Trait objects (`ListOfTraitsConverter`) |
| `reactions` | `TEXT` | `"[]"` | JSON array of Trait objects (`ListOfTraitsConverter`) |
| `lair_actions` | `TEXT` | `"[]"` | JSON array of Trait objects (`ListOfTraitsConverter`) |
| `legendary_actions` | `TEXT` | `"[]"` | JSON array of Trait objects (`ListOfTraitsConverter`) |
| `regional_actions` | `TEXT` | `"[]"` | JSON array of Trait objects (`ListOfTraitsConverter`) |
### Virtual FTS Table: `monsters_fts`
Used for full-text search indexing:
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS `monsters_fts`
USING FTS4(`name` TEXT, `size` TEXT, `type` TEXT, `subtype` TEXT, `alignment` TEXT, content=`monsters`);
```
---
## 6. Component Objects Schema (JSON Serialized in DB)
### 1. `Trait`
Used in `abilities`, `actions`, `reactions`, `lair_actions`, `legendary_actions`, and `regional_actions`:
```json
{
"name": "Keen Smell",
"description": "The monster has advantage on Wisdom (Perception) checks that rely on smell."
}
```
### 2. `Skill`
Used in `skills`:
```json
{
"name": "Perception",
"abilityScore": "WISDOM",
"advantageType": "NONE",
"proficiencyType": "PROFICIENT"
}
```
- **`abilityScore`**: `"STRENGTH"`, `"DEXTERITY"`, `"CONSTITUTION"`, `"INTELLIGENCE"`, `"WISDOM"`, `"CHARISMA"`.
- **`advantageType`**: `"NONE"`, `"ADVANTAGE"`, `"DISADVANTAGE"`.
- **`proficiencyType`**: `"NONE"`, `"PROFICIENT"`, `"EXPERTISE"`.
### 3. `Language`
Used in `languages`:
```json
{
"mName": "Common",
"mSpeaks": true
}
```
- **`mName`**: Name of the language (e.g., `"Elvish"`).
- **`mSpeaks`**: `true` if the creature can speak it; `false` if it only understands it.

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

View File

@@ -0,0 +1 @@
{"name":"Acolyte","size":"medium","type":"Humanoid","tag":"any race","alignment":"any alignment","hitDice":2,"armorName":"none","shieldBonus":0,"natArmorBonus":7,"otherArmorDesc":"10","speed":30,"burrowSpeed":0,"climbSpeed":0,"flySpeed":0,"hover":false,"swimSpeed":0,"customHP":false,"customSpeed":false,"hpText":"2","speedDesc":"30 ft.","strPoints":10,"dexPoints":10,"conPoints":10,"intPoints":10,"wisPoints":14,"chaPoints":11,"blindsight":0,"blind":false,"darkvision":0,"tremorsense":0,"truesight":0,"telepathy":0,"cr":"1/4","customCr":"1/4 (50 XP)","customProf":2,"isLegendary":false,"legendariesDescription":"The acolyte 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 acolyte regains spent legendary actions at the start of its turn.","isLair":false,"lairDescription":"When fighting inside its lair, the acolyte can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the acolyte can take one lair action to cause one of the following effects:","lairDescriptionEnd":"The acolyte 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":false,"mythicDescription":"If the acolyte's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":false,"regionalDescription":"The region containing the acolyte's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the acolyte dies, the first two effects fade over the course of 3d10 days.","properties":[],"abilities":[{"name":"Spellcasting","desc":"The acolyte is a 1st-level spellcaster. Its spellcasting ability is Wisdom (spell save DC [WIS SAVE], [WIS ATK] to hit with spell attacks). The acolyte has following cleric spells prepared:\n\n\n> * Cantrips (at will): _light, sacred flame, thaumaturgy_\n> * 1st level (3 slots): _bless, cure wounds, sanctuary_"}],"actions":[{"name":"Club","desc":"_Melee Weapon Attack:_ +2 to hit, reach 5 ft., one target. _Hit:_ 2 (1d4) bludgeoning damage."}],"bonusActions":[],"reactions":[],"legendaries":[],"mythics":[],"lairs":[],"regionals":[],"sthrows":[],"skills":[{"name":"medicine","stat":"wis"},{"name":"religion","stat":"int"}],"damagetypes":[],"specialdamage":[],"conditions":[],"languages":[{"name":"any one language (usually Common)","speaks":true}],"understandsBut":"","shortName":"","pluralName":"","doubleColumns":false,"separationPoint":0,"damage":[]}

View File

@@ -0,0 +1,49 @@
Homebrewery V3
{{monster,frame
## Acolyte
*Medium Humanoid (any race), any alignment*
___
**Armor Class** :: 10
**Hit Points** :: 9 (2d8)
**Speed** :: 30 ft.
___
|STR|DEX|CON|INT|WIS|CHA|
|:---:|:---:|:---:|:---:|:---:|:---:|
|10 (+0)|10 (+0)|10 (+0)|10 (+0)|14 (+2)|11 (+0)|
___
**Skills** :: Medicine +4, Religion +2
**Senses** :: passive Perception 12
**Languages** :: any one language (usually Common)
**Challenge** :: 1/4 (50 XP)
___
***Spellcasting.*** The acolyte is a 1st-level spellcaster. Its spellcasting ability is Wisdom (spell save DC 12, +4 to hit with spell attacks). The acolyte has following cleric spells prepared:<br>
<br>
* Cantrips (at will): _light, sacred flame, thaumaturgy_<br>
* 1st level (3 slots): _bless, cure wounds, sanctuary_
### Actions
***Club.*** _Melee Weapon Attack:_ +2 to hit, reach 5 ft., one target. _Hit:_ 2 (1d4) bludgeoning damage.
}}
Homebrewery (Legacy)/GM Binder Markdown
___
> ## Acolyte
> *Medium Humanoid (any race), any alignment*
> ___
> - **Armor Class** 10
> - **Hit Points** 9 (2d8)
> - **Speed** 30 ft.
> ___
> |STR|DEX|CON|INT|WIS|CHA|
> |:---:|:---:|:---:|:---:|:---:|:---:|
> |10 (+0)|10 (+0)|10 (+0)|10 (+0)|14 (+2)|11 (+0)|
> ___
> - **Skills** Medicine +4, Religion +2
> - **Senses** passive Perception 12
> - **Languages** any one language (usually Common)
> - **Challenge** 1/4 (50 XP)
> ___
> ***Spellcasting.*** The acolyte is a 1st-level spellcaster. Its spellcasting ability is Wisdom (spell save DC 12, +4 to hit with spell attacks). The acolyte has following cleric spells prepared:<br>
> <br>
> * Cantrips (at will): _light, sacred flame, thaumaturgy_<br>
> * 1st level (3 slots): _bless, cure wounds, sanctuary_
> ### Actions
> ***Club.*** _Melee Weapon Attack:_ +2 to hit, reach 5 ft., one target. _Hit:_ 2 (1d4) bludgeoning damage.

View File

@@ -0,0 +1 @@
{"name":"Acolyte","size":"medium","type":"Humanoid","tag":"any race","alignment":"any alignment","hitDice":2,"armorName":"none","shieldBonus":0,"natArmorBonus":7,"otherArmorDesc":"10","speed":30,"burrowSpeed":0,"climbSpeed":0,"flySpeed":0,"hover":false,"swimSpeed":0,"customHP":false,"customSpeed":false,"hpText":"2","speedDesc":"30 ft.","strPoints":10,"dexPoints":10,"conPoints":10,"intPoints":10,"wisPoints":14,"chaPoints":11,"blindsight":0,"blind":false,"darkvision":0,"tremorsense":0,"truesight":0,"telepathy":0,"cr":"1/4","customCr":"1/4 (50 XP)","customProf":2,"isLegendary":false,"legendariesDescription":"The acolyte 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 acolyte regains spent legendary actions at the start of its turn.","isLair":false,"lairDescription":"When fighting inside its lair, the acolyte can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the acolyte can take one lair action to cause one of the following effects:","lairDescriptionEnd":"The acolyte 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":false,"mythicDescription":"If the acolyte's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":false,"regionalDescription":"The region containing the acolyte's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the acolyte dies, the first two effects fade over the course of 3d10 days.","properties":[],"abilities":[{"name":"Spellcasting","desc":"The acolyte is a 1st-level spellcaster. Its spellcasting ability is Wisdom (spell save DC [WIS SAVE], [WIS ATK] to hit with spell attacks). The acolyte has following cleric spells prepared:\n\n\n> * Cantrips (at will): _light, sacred flame, thaumaturgy_\n> * 1st level (3 slots): _bless, cure wounds, sanctuary_"}],"actions":[{"name":"Club","desc":"_Melee Weapon Attack:_ +2 to hit, reach 5 ft., one target. _Hit:_ 2 (1d4) bludgeoning damage."}],"bonusActions":[],"reactions":[],"legendaries":[],"mythics":[],"lairs":[],"regionals":[],"sthrows":[],"skills":[{"name":"medicine","stat":"wis"},{"name":"religion","stat":"int"}],"damagetypes":[],"specialdamage":[],"conditions":[],"languages":[{"name":"any one language (usually Common)","speaks":true}],"understandsBut":"","shortName":"","pluralName":"","doubleColumns":false,"separationPoint":0,"damage":[]}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
{"name":"Adult Black Dragon","size":"huge","type":"Dragon","tag":"","alignment":"chaotic evil","hitDice":17,"armorName":"natural armor","shieldBonus":0,"natArmorBonus":7,"otherArmorDesc":"19 (natural armor)","speed":40,"burrowSpeed":0,"climbSpeed":0,"flySpeed":80,"hover":false,"swimSpeed":40,"customHP":false,"customSpeed":false,"hpText":"17","speedDesc":"40 ft., fly 80 ft., swim 40 ft.","strPoints":23,"dexPoints":14,"conPoints":21,"intPoints":14,"wisPoints":13,"chaPoints":17,"blindsight":60,"blind":false,"darkvision":120,"tremorsense":0,"truesight":0,"telepathy":0,"cr":"14","customCr":"14 (11,500 XP)","customProf":5,"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":false,"lairDescription":"When fighting inside its lair, the adult black dragon can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the adult black dragon can take one lair action to cause one of the following effects:","lairDescriptionEnd":"The adult black dragon 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":false,"mythicDescription":"If the adult black dragon's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":false,"regionalDescription":"The region containing the adult black dragon's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the adult 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:_ +11 to hit, reach 10 ft., one target. _Hit:_ 17 (2d10 + 6) piercing damage plus 4 (1d8) acid damage."},{"name":"Claw","desc":"_Melee Weapon Attack:_ +11 to hit, reach 5 ft., one target. _Hit:_ 13 (2d6 + 6) slashing damage."},{"name":"Tail","desc":"_Melee Weapon Attack:_ +11 to hit, reach 15 ft., one target. _Hit:_ 15 (2d8 + 6) 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 16 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 60-foot line that is 5 feet wide. Each creature in that line must make a DC 18 Dexterity saving throw, taking 54 (12d8) 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 10 ft. of the dragon must succeed on a DC 19 Dexterity saving throw or take 13 (2d6 + 6) 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":[]}

View File

@@ -0,0 +1,87 @@
Homebrewery V3
{{monster,frame
## Adult Black Dragon
*Huge Dragon, chaotic evil*
___
**Armor Class** :: 19 (natural armor)
**Hit Points** :: 195 (17d12 + 85)
**Speed** :: 40 ft., fly 80 ft., swim 40 ft.
___
|STR|DEX|CON|INT|WIS|CHA|
|:---:|:---:|:---:|:---:|:---:|:---:|
|23 (+6)|14 (+2)|21 (+5)|14 (+2)|13 (+1)|17 (+3)|
___
**Saving Throws** :: Dex +7, Con +10, Wis +6, Cha +8
**Skills** :: Perception +11, Stealth +7
**Damage Immunities** :: acid
**Senses** :: blindsight 60 ft., darkvision 120 ft., passive Perception 21
**Languages** :: Common, Draconic
**Challenge** :: 14 (11,500 XP)
___
***Amphibious.*** The dragon can breathe air and water.
:
***Legendary Resistance (3/Day).*** If the dragon fails a saving throw, it can choose to succeed instead.
### Actions
***Multiattack.*** The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws.
:
***Bite.*** _Melee Weapon Attack:_ +11 to hit, reach 10 ft., one target. _Hit:_ 17 (2d10 + 6) piercing damage plus 4 (1d8) acid damage.
:
***Claw.*** _Melee Weapon Attack:_ +11 to hit, reach 5 ft., one target. _Hit:_ 13 (2d6 + 6) slashing damage.
:
***Tail.*** _Melee Weapon Attack:_ +11 to hit, reach 15 ft., one target. _Hit:_ 15 (2d8 + 6) bludgeoning damage.
:
***Frightful Presence.*** Each creature of the dragon's choice that is within 120 feet of the dragon and aware of it must succeed on a DC 16 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.
:
***Acid Breath (Recharge 5-6).*** The dragon exhales acid in a 60-foot line that is 5 feet wide. Each creature in that line must make a DC 18 Dexterity saving throw, taking 54 (12d8) acid damage on a failed save, or half as much damage on a successful one.
### Legendary Actions
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.
:
**Detect.** The dragon makes a Wisdom (Perception) check.
:
**Tail Attack.** The dragon makes a tail attack.
:
**Wing Attack (Costs 2 Actions).** The dragon beats its wings. Each creature within 10 ft. of the dragon must succeed on a DC 19 Dexterity saving throw or take 13 (2d6 + 6) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed.
}}
Homebrewery (Legacy)/GM Binder Markdown
___
> ## Adult Black Dragon
> *Huge Dragon, chaotic evil*
> ___
> - **Armor Class** 19 (natural armor)
> - **Hit Points** 195 (17d12 + 85)
> - **Speed** 40 ft., fly 80 ft., swim 40 ft.
> ___
> |STR|DEX|CON|INT|WIS|CHA|
> |:---:|:---:|:---:|:---:|:---:|:---:|
> |23 (+6)|14 (+2)|21 (+5)|14 (+2)|13 (+1)|17 (+3)|
> ___
> - **Saving Throws** Dex +7, Con +10, Wis +6, Cha +8
> - **Skills** Perception +11, Stealth +7
> - **Damage Immunities** acid
> - **Senses** blindsight 60 ft., darkvision 120 ft., passive Perception 21
> - **Languages** Common, Draconic
> - **Challenge** 14 (11,500 XP)
> ___
> ***Amphibious.*** The dragon can breathe air and water.
>
> ***Legendary Resistance (3/Day).*** If the dragon fails a saving throw, it can choose to succeed instead.
> ### Actions
> ***Multiattack.*** The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws.
>
> ***Bite.*** _Melee Weapon Attack:_ +11 to hit, reach 10 ft., one target. _Hit:_ 17 (2d10 + 6) piercing damage plus 4 (1d8) acid damage.
>
> ***Claw.*** _Melee Weapon Attack:_ +11 to hit, reach 5 ft., one target. _Hit:_ 13 (2d6 + 6) slashing damage.
>
> ***Tail.*** _Melee Weapon Attack:_ +11 to hit, reach 15 ft., one target. _Hit:_ 15 (2d8 + 6) bludgeoning damage.
>
> ***Frightful Presence.*** Each creature of the dragon's choice that is within 120 feet of the dragon and aware of it must succeed on a DC 16 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.
>
> ***Acid Breath (Recharge 5-6).*** The dragon exhales acid in a 60-foot line that is 5 feet wide. Each creature in that line must make a DC 18 Dexterity saving throw, taking 54 (12d8) acid damage on a failed save, or half as much damage on a successful one.
> ### Legendary Actions
> 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.
>
> **Detect.** The dragon makes a Wisdom (Perception) check.
>
> **Tail Attack.** The dragon makes a tail attack.
>
> **Wing Attack (Costs 2 Actions).** The dragon beats its wings. Each creature within 10 ft. of the dragon must succeed on a DC 19 Dexterity saving throw or take 13 (2d6 + 6) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed.

View File

@@ -0,0 +1 @@
{"name":"Adult Black Dragon","size":"huge","type":"Dragon","tag":"","alignment":"chaotic evil","hitDice":17,"armorName":"natural armor","shieldBonus":0,"natArmorBonus":7,"otherArmorDesc":"19 (natural armor)","speed":40,"burrowSpeed":0,"climbSpeed":0,"flySpeed":80,"hover":false,"swimSpeed":40,"customHP":false,"customSpeed":false,"hpText":"17","speedDesc":"40 ft., fly 80 ft., swim 40 ft.","strPoints":23,"dexPoints":14,"conPoints":21,"intPoints":14,"wisPoints":13,"chaPoints":17,"blindsight":60,"blind":false,"darkvision":120,"tremorsense":0,"truesight":0,"telepathy":0,"cr":"14","customCr":"14 (11,500 XP)","customProf":5,"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":false,"lairDescription":"When fighting inside its lair, the adult black dragon can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the adult black dragon can take one lair action to cause one of the following effects:","lairDescriptionEnd":"The adult black dragon 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":false,"mythicDescription":"If the adult black dragon's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":false,"regionalDescription":"The region containing the adult black dragon's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the adult 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:_ +11 to hit, reach 10 ft., one target. _Hit:_ 17 (2d10 + 6) piercing damage plus 4 (1d8) acid damage."},{"name":"Claw","desc":"_Melee Weapon Attack:_ +11 to hit, reach 5 ft., one target. _Hit:_ 13 (2d6 + 6) slashing damage."},{"name":"Tail","desc":"_Melee Weapon Attack:_ +11 to hit, reach 15 ft., one target. _Hit:_ 15 (2d8 + 6) 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 16 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 60-foot line that is 5 feet wide. Each creature in that line must make a DC 18 Dexterity saving throw, taking 54 (12d8) 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 10 ft. of the dragon must succeed on a DC 19 Dexterity saving throw or take 13 (2d6 + 6) 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: 809 KiB

View File

@@ -0,0 +1 @@
{"name":"Monster Goblin","size":"small","type":"Humanoid","tag":"goblinoid","alignment":"neutral evil","hitDice":2,"armorName":"leather armor","shieldBonus":2,"natArmorBonus":7,"otherArmorDesc":"15 (leather armor, shield)","speed":30,"burrowSpeed":0,"climbSpeed":0,"flySpeed":0,"hover":false,"swimSpeed":0,"customHP":false,"customSpeed":false,"hpText":"2","speedDesc":"30 ft.","strPoints":8,"dexPoints":14,"conPoints":10,"intPoints":10,"wisPoints":8,"chaPoints":8,"blindsight":0,"blind":false,"darkvision":60,"tremorsense":0,"truesight":0,"telepathy":0,"cr":"1/4","customCr":"1/4 (50 XP)","customProf":2,"isLegendary":false,"legendariesDescription":"The goblin 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 goblin regains spent legendary actions at the start of its turn.","isLair":false,"lairDescription":"When fighting inside its lair, the goblin can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the goblin 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":false,"mythicDescription":"If the goblin's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":false,"regionalDescription":"The region containing the goblin's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the goblin dies, the first two effects fade over the course of 3d10 days.","properties":[],"abilities":[{"name":"Nimble Escape","desc":"The goblin can take the Disengage or Hide action as a bonus action on each of its turns."}],"actions":[{"name":"Scimitar","desc":"_Melee Weapon Attack:_ +4 to hit, reach 5 ft., one target. _Hit:_ 5 (1d6 + 2) slashing damage."},{"name":"Shortbow","desc":"_Ranged Weapon Attack:_ +4 to hit, range 80/320 ft., one target. _Hit:_ 5 (1d6 + 2) piercing damage."}],"bonusActions":[],"reactions":[],"legendaries":[],"mythics":[],"lairs":[],"regionals":[],"sthrows":[],"skills":[{"name":"stealth","stat":"dex","note":" (ex)"}],"damagetypes":[],"specialdamage":[],"conditions":[],"languages":[{"name":"Common","speaks":true},{"name":"Goblin","speaks":true}],"understandsBut":"","shortName":"","pluralName":"","doubleColumns":false,"separationPoint":1,"damage":[]}

View File

@@ -0,0 +1 @@
{"name":"Text Monster Goblin","size":"small","type":"Humanoid","tag":"goblinoid","alignment":"neutral evil","hitDice":2,"armorName":"leather armor","shieldBonus":2,"natArmorBonus":7,"otherArmorDesc":"15 (leather armor, shield)","speed":30,"burrowSpeed":0,"climbSpeed":0,"flySpeed":0,"hover":false,"swimSpeed":0,"customHP":false,"customSpeed":false,"hpText":"2","speedDesc":"30 ft.","strPoints":8,"dexPoints":14,"conPoints":10,"intPoints":10,"wisPoints":8,"chaPoints":8,"blindsight":0,"blind":false,"darkvision":60,"tremorsense":0,"truesight":0,"telepathy":0,"cr":"1/4","customCr":"1/4 (50 XP)","customProf":2,"isLegendary":false,"legendariesDescription":"The goblin 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 goblin regains spent legendary actions at the start of its turn.","isLair":false,"lairDescription":"When fighting inside its lair, the goblin can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the goblin 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":false,"mythicDescription":"If the goblin's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":false,"regionalDescription":"The region containing the goblin's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the goblin dies, the first two effects fade over the course of 3d10 days.","properties":[],"abilities":[{"name":"Nimble Escape","desc":"The goblin can take the Disengage or Hide action as a bonus action on each of its turns."}],"actions":[{"name":"Scimitar","desc":"_Melee Weapon Attack:_ +4 to hit, reach 5 ft., one target. _Hit:_ 5 (1d6 + 2) slashing damage."},{"name":"Shortbow","desc":"_Ranged Weapon Attack:_ +4 to hit, range 80/320 ft., one target. _Hit:_ 5 (1d6 + 2) piercing damage."}],"bonusActions":[],"reactions":[],"legendaries":[],"mythics":[],"lairs":[],"regionals":[],"sthrows":[],"skills":[{"name":"stealth","stat":"dex","note":" (ex)"}],"damagetypes":[],"specialdamage":[],"conditions":[],"languages":[{"name":"Common","speaks":true},{"name":"Goblin","speaks":true}],"understandsBut":"","shortName":"","pluralName":"","doubleColumns":false,"separationPoint":1,"damage":[]}

View File

@@ -0,0 +1 @@
{"name":"Text Goblin","size":"small","type":"Humanoid","tag":"goblinoid","alignment":"neutral evil","hitDice":2,"armorName":"leather armor","shieldBonus":2,"natArmorBonus":7,"otherArmorDesc":"15 (leather armor, shield)","speed":30,"burrowSpeed":0,"climbSpeed":0,"flySpeed":0,"hover":false,"swimSpeed":0,"customHP":false,"customSpeed":false,"hpText":"2","speedDesc":"30 ft.","strPoints":8,"dexPoints":14,"conPoints":10,"intPoints":10,"wisPoints":8,"chaPoints":8,"blindsight":0,"blind":false,"darkvision":60,"tremorsense":0,"truesight":0,"telepathy":0,"cr":"1/4","customCr":"1/4 (50 XP)","customProf":2,"isLegendary":false,"legendariesDescription":"The goblin 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 goblin regains spent legendary actions at the start of its turn.","isLair":false,"lairDescription":"When fighting inside its lair, the goblin can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the goblin 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":false,"mythicDescription":"If the goblin's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":false,"regionalDescription":"The region containing the goblin's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the goblin dies, the first two effects fade over the course of 3d10 days.","properties":[],"abilities":[{"name":"Nimble Escape","desc":"The goblin can take the Disengage or Hide action as a bonus action on each of its turns."}],"actions":[{"name":"Scimitar","desc":"_Melee Weapon Attack:_ +4 to hit, reach 5 ft., one target. _Hit:_ 5 (1d6 + 2) slashing damage."},{"name":"Shortbow","desc":"_Ranged Weapon Attack:_ +4 to hit, range 80/320 ft., one target. _Hit:_ 5 (1d6 + 2) piercing damage."}],"bonusActions":[],"reactions":[],"legendaries":[],"mythics":[],"lairs":[],"regionals":[],"sthrows":[],"skills":[{"name":"stealth","stat":"dex","note":" (ex)"}],"damagetypes":[],"specialdamage":[],"conditions":[],"languages":[{"name":"Common","speaks":true},{"name":"Goblin","speaks":true}],"understandsBut":"","shortName":"","pluralName":"","doubleColumns":false,"separationPoint":1,"damage":[]}

View File

@@ -0,0 +1,47 @@
Homebrewery V3
{{monster,frame
## Goblin
*Small Humanoid (goblinoid), neutral evil*
___
**Armor Class** :: 15 (leather armor, shield)
**Hit Points** :: 7 (2d6)
**Speed** :: 30 ft.
___
|STR|DEX|CON|INT|WIS|CHA|
|:---:|:---:|:---:|:---:|:---:|:---:|
|8 (-1)|14 (+2)|10 (+0)|10 (+0)|8 (-1)|8 (-1)|
___
**Skills** :: Stealth +6
**Senses** :: darkvision 60 ft., passive Perception 9
**Languages** :: Common, Goblin
**Challenge** :: 1/4 (50 XP)
___
***Nimble Escape.*** The goblin can take the Disengage or Hide action as a bonus action on each of its turns.
### Actions
***Scimitar.*** _Melee Weapon Attack:_ +4 to hit, reach 5 ft., one target. _Hit:_ 5 (1d6 + 2) slashing damage.
:
***Shortbow.*** _Ranged Weapon Attack:_ +4 to hit, range 80/320 ft., one target. _Hit:_ 5 (1d6 + 2) piercing damage.
}}
Homebrewery (Legacy)/GM Binder Markdown
___
> ## Goblin
> *Small Humanoid (goblinoid), neutral evil*
> ___
> - **Armor Class** 15 (leather armor, shield)
> - **Hit Points** 7 (2d6)
> - **Speed** 30 ft.
> ___
> |STR|DEX|CON|INT|WIS|CHA|
> |:---:|:---:|:---:|:---:|:---:|:---:|
> |8 (-1)|14 (+2)|10 (+0)|10 (+0)|8 (-1)|8 (-1)|
> ___
> - **Skills** Stealth +6
> - **Senses** darkvision 60 ft., passive Perception 9
> - **Languages** Common, Goblin
> - **Challenge** 1/4 (50 XP)
> ___
> ***Nimble Escape.*** The goblin can take the Disengage or Hide action as a bonus action on each of its turns.
> ### Actions
> ***Scimitar.*** _Melee Weapon Attack:_ +4 to hit, reach 5 ft., one target. _Hit:_ 5 (1d6 + 2) slashing damage.
>
> ***Shortbow.*** _Ranged Weapon Attack:_ +4 to hit, range 80/320 ft., one target. _Hit:_ 5 (1d6 + 2) piercing damage.

View File

@@ -0,0 +1 @@
{"name":"Goblin","size":"small","type":"Humanoid","tag":"goblinoid","alignment":"neutral evil","hitDice":2,"armorName":"leather armor","shieldBonus":2,"natArmorBonus":7,"otherArmorDesc":"15 (leather armor, shield)","speed":30,"burrowSpeed":0,"climbSpeed":0,"flySpeed":0,"hover":false,"swimSpeed":0,"customHP":false,"customSpeed":false,"hpText":"2","speedDesc":"30 ft.","strPoints":8,"dexPoints":14,"conPoints":10,"intPoints":10,"wisPoints":8,"chaPoints":8,"blindsight":0,"blind":false,"darkvision":60,"tremorsense":0,"truesight":0,"telepathy":0,"cr":"1/4","customCr":"1/4 (50 XP)","customProf":2,"isLegendary":false,"legendariesDescription":"The goblin 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 goblin regains spent legendary actions at the start of its turn.","isLair":false,"lairDescription":"When fighting inside its lair, the goblin can invoke the ambient magic to take lair actions. On initiative count 20 (losing initiative ties), the goblin 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":false,"mythicDescription":"If the goblin's mythic trait is active, it can use the options below as legendary actions for 1 hour after using {Some Ability}.","isRegional":false,"regionalDescription":"The region containing the goblin's lair is warped by the creature's presence, which creates one or more of the following effects:","regionalDescriptionEnd":"If the goblin dies, the first two effects fade over the course of 3d10 days.","properties":[],"abilities":[{"name":"Nimble Escape","desc":"The goblin can take the Disengage or Hide action as a bonus action on each of its turns."}],"actions":[{"name":"Scimitar","desc":"_Melee Weapon Attack:_ +4 to hit, reach 5 ft., one target. _Hit:_ 5 (1d6 + 2) slashing damage."},{"name":"Shortbow","desc":"_Ranged Weapon Attack:_ +4 to hit, range 80/320 ft., one target. _Hit:_ 5 (1d6 + 2) piercing damage."}],"bonusActions":[],"reactions":[],"legendaries":[],"mythics":[],"lairs":[],"regionals":[],"sthrows":[],"skills":[{"name":"stealth","stat":"dex","note":" (ex)"}],"damagetypes":[],"specialdamage":[],"conditions":[],"languages":[{"name":"Common","speaks":true},{"name":"Goblin","speaks":true}],"understandsBut":"","shortName":"","pluralName":"","doubleColumns":false,"separationPoint":1,"damage":[]}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

View File

@@ -0,0 +1,51 @@
{
"$schema": "../../../../schema/entity.json",
"uuid": "f6a83d72-b94e-4e5c-a5c2-f39b6b52d202",
"ruleset_id": "open5e",
"entity_type": "character",
"display_name": "Acolyte",
"description": "Medium humanoid (any race), any alignment.",
"version": "1.0.0",
"template_id": "stat_block",
"tags": ["npc", "humanoid", "spellcaster", "cr-1/4"],
"properties": {
"size": "medium",
"type": "Humanoid",
"subtype": "any race",
"alignment": "any alignment",
"armor_class": 10,
"hit_points": {
"current": 9,
"max": 9,
"formula": "2d8 + 0",
"hit_dice": "2d8"
},
"speed": 30,
"speed_desc": "30 ft.",
"challenge_rating": "1/4 (50 XP)",
"cr": "1/4",
"abilities": {
"strength": 10,
"dexterity": 10,
"constitution": 10,
"intelligence": 10,
"wisdom": 14,
"charisma": 11
},
"skills": ["medicine +4", "religion +2"],
"senses": "passive Perception 12",
"languages": ["any one language (usually Common)"],
"traits": [
{
"name": "Spellcasting",
"desc": "The acolyte is a 1st-level spellcaster. Its spellcasting ability is Wisdom (spell save DC 12, +4 to hit with spell attacks). The acolyte has following cleric spells prepared:\n\n* Cantrips (at will): _light, sacred flame, thaumaturgy_\n* 1st level (3 slots): _bless, cure wounds, sanctuary_"
}
],
"actions": [
{
"name": "Club",
"desc": "_Melee Weapon Attack:_ +2 to hit, reach 5 ft., one target. _Hit:_ 2 (1d4) bludgeoning damage."
}
]
}
}

View File

@@ -0,0 +1,99 @@
{
"$schema": "../../../../schema/entity.json",
"uuid": "b8a91c34-d07f-4f8a-92c1-d41a8a91e404",
"ruleset_id": "open5e",
"entity_type": "character",
"display_name": "Adult Black Dragon",
"description": "Huge dragon, chaotic evil.",
"version": "1.0.0",
"template_id": "stat_block",
"tags": ["monster", "dragon", "legendary", "cr-14"],
"properties": {
"size": "huge",
"type": "Dragon",
"alignment": "chaotic evil",
"armor_class": 19,
"armor_description": "natural armor",
"hit_points": {
"current": 196,
"max": 196,
"formula": "17d12 + 85",
"hit_dice": "17d12"
},
"speed": 40,
"speed_desc": "40 ft., fly 80 ft., swim 40 ft.",
"challenge_rating": "14 (11,500 XP)",
"cr": "14",
"abilities": {
"strength": 23,
"dexterity": 14,
"constitution": 21,
"intelligence": 14,
"wisdom": 13,
"charisma": 17
},
"saving_throws": [
"Dexterity +7",
"Constitution +10",
"Wisdom +6",
"Charisma +8"
],
"skills": ["perception +16", "stealth +11"],
"damage_immunities": ["acid"],
"senses": "darkvision 120 ft., blindsight 60 ft., passive Perception 11",
"languages": ["Common", "Draconic"],
"traits": [
{
"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:_ +11 to hit, reach 10 ft., one target. _Hit:_ 17 (2d10 + 6) piercing damage plus 4 (1d8) acid damage."
},
{
"name": "Claw",
"desc": "_Melee Weapon Attack:_ +11 to hit, reach 5 ft., one target. _Hit:_ 13 (2d6 + 6) slashing damage."
},
{
"name": "Tail",
"desc": "_Melee Weapon Attack:_ +11 to hit, reach 15 ft., one target. _Hit:_ 15 (2d8 + 6) 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 16 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 60-foot line that is 5 feet wide. Each creature in that line must make a DC 18 Dexterity saving throw, taking 54 (12d8) acid damage on a failed save, or half as much damage on a successful one."
}
],
"legendary_actions": {
"description": "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.",
"actions": [
{
"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 10 ft. of the dragon must succeed on a DC 19 Dexterity saving throw or take 13 (2d6 + 6) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed."
}
]
}
}
}

View File

@@ -0,0 +1,306 @@
{
"$schema": "../../../schema/entity.json",
"uuid": "4c8f3824-3453-48bf-9a0e-a4c336b3bcf2",
"ruleset_id": "open5e",
"entity_type": "character",
"display_name": "Arannis",
"description": "Elven Ranger exploring ancient ruins.",
"version": "1.0.0",
"created_at": "2026-09-06T14:00:00Z",
"updated_at": "2026-09-06T18:00:00Z",
"tags": ["pc", "ranger", "elf"],
"properties": {
"player_name": "Tom",
"class_levels": {
"ranger": {
"name": "Ranger",
"level": 3,
"subclass": "Hunter",
"description": "Hunter archetype with Colossus Slayer"
}
},
"race": "Elf",
"size": "medium",
"type": "Humanoid",
"subtype": "Elf",
"background": "Outlander",
"alignment": "Chaotic Good",
"experience_points": 900,
"inspiration": true,
"proficiency_bonus": 2,
"initiative": 3,
"speed": 35,
"speed_desc": "35 ft.",
"hit_points": {
"current": 24,
"max": 24,
"formula": "3d10 + 6",
"temp": 0
},
"hit_dice": {
"total": 3,
"current": 3,
"dice": "1d10"
},
"death_saves": {
"successes": 0,
"failures": 0
},
"saving_throws": ["Strength +3", "Dexterity +5"],
"advantages": ["wis"],
"skills": ["Athletics +3", "Perception +5", "Stealth +5", "Survival +5"],
"passive_perception": 15,
"senses": "darkvision 60 ft., passive Perception 15",
"languages": ["Common", "Elvish", "Sylvan"],
"other_proficiencies_and_languages": {
"armor": ["Light armor", "Medium armor", "Shields"],
"weapons": ["Simple weapons", "Martial weapons"],
"tools": ["Herbalism kit", "Flute"],
"languages": ["Common", "Elvish", "Sylvan"]
},
"attacks": [
{
"name": "Longbow",
"atk_bonus": "+7",
"damage": "1d8 + 3",
"damage_type": "Piercing",
"range": "150/600 ft."
},
{
"name": "Shortsword",
"atk_bonus": "+5",
"damage": "1d6 + 3",
"damage_type": "Piercing",
"range": "5 ft."
}
],
"currency": {
"cp": 15,
"sp": 40,
"ep": 0,
"gp": 25,
"pp": 0
},
"personality_traits": "I feel far more comfortable among the ancient trees and wild beasts than in bustling cities.",
"ideals": "Nature: The natural world is more important than the constructs of civilization.",
"bonds": "An ancient ruined shrine in the northern forest holds a secret I have sworn to protect.",
"flaws": "I am slow to trust others and prefer solitude even in times of danger.",
"features_and_traits": [
{
"name": "Favored Enemy (Undead)",
"source": "Ranger 1",
"desc": "You have advantage on Wisdom (Survival) checks to track your favored enemies, as well as on Intelligence checks to recall information about them."
},
{
"name": "Natural Explorer (Forest)",
"source": "Ranger 1",
"desc": "When making an Intelligence or Wisdom check related to your favored terrain, your proficiency bonus is doubled if you are using a skill you're proficient in."
},
{
"name": "Colossus Slayer",
"source": "Hunter Archetype",
"desc": "Your tenacity can wear down the most potent foes. Once per turn when you hit a creature with a weapon attack, the creature takes an extra 1d8 damage if it's below its hit point maximum."
}
],
"spellcasting": {
"class": "Ranger",
"ability": "WISDOM",
"save_dc": 13,
"attack_bonus": 5,
"cantrips": [],
"slots": {
"1": {
"total": 3,
"expended": 1
}
},
"spells": [
{
"name": "Hunter's Mark",
"level": 1,
"prepared": true,
"desc": "You choose a creature you can see within range and mystically mark it as your quarry."
},
{
"name": "Cure Wounds",
"level": 1,
"prepared": true,
"desc": "A creature you touch regains a number of hit points equal to 1d8 + your spellcasting ability modifier."
}
]
},
"traits": [
{
"name": "Fey Ancestry",
"desc": "Arannis has advantage on saving throws against being charmed, and magic cannot put her to sleep."
},
{
"name": "Natural Explorer",
"desc": "Difficult terrain does not slow Arannis's group while traveling."
}
],
"actions": [
{
"name": "Longbow",
"desc": "_Ranged Weapon Attack:_ +7 to hit, range 150/600 ft., one target. _Hit:_ 7 (1d8 + 3) piercing damage."
},
{
"name": "Shortsword",
"desc": "_Melee Weapon Attack:_ +5 to hit, reach 5 ft., one target. _Hit:_ 6 (1d6 + 3) piercing damage."
}
],
"equipped_armor": {
"id": "armor_leather",
"name": "Leather Armor",
"armor_category": "light",
"ac_base": 11,
"ac_modifier": 1,
"dex_cap": null,
"stealth_disadvantage": false,
"strength_requirement": null
},
"equipped_shield": null,
"armor_class": {
"value": 15,
"base": 10,
"expr": "base + sum(modifiers)",
"modifiers": [
{
"source": "Leather Armor",
"value": 1,
"type": "armor"
},
{
"source": "Dexterity Modifier",
"value": 3,
"type": "ability"
},
{
"source": "Ring of Protection +1",
"value": 1,
"type": "bonus",
"active": true
}
]
},
"abilities": {
"strength": {
"display_name": "Strength",
"short_name": "Str",
"value": 12,
"modifier": 1,
"proficiency": true,
"saving_throw": 3
},
"dexterity": {
"display_name": "Dexterity",
"short_name": "Dex",
"value": 16,
"base": 14,
"modifier": 3,
"proficiency": true,
"saving_throw": 5,
"modifiers": [
{
"source": "Elven Agility",
"value": 2,
"type": "bonus"
}
]
},
"constitution": {
"display_name": "Constitution",
"short_name": "Con",
"value": 14,
"modifier": 2,
"proficiency": false,
"saving_throw": 2
},
"intelligence": {
"display_name": "Intelligence",
"short_name": "Int",
"value": 10,
"modifier": 0,
"proficiency": false,
"saving_throw": 0
},
"wisdom": {
"display_name": "Wisdom",
"short_name": "Wis",
"value": 15,
"modifier": 2,
"proficiency": false,
"saving_throw": 2,
"advantage": "Fey Ancestry (charm saves)"
},
"charisma": {
"display_name": "Charisma",
"short_name": "Cha",
"value": 8,
"modifier": -1,
"proficiency": false,
"saving_throw": -1
}
},
"inventory": [
{
"id": "armor_leather",
"entity_ref": "entities/armor.json",
"name": "Leather Armor",
"quantity": 1,
"weight": 10.0,
"equipped": true,
"ac_base": 11,
"dex_cap": null
},
{
"id": "ring_protection",
"entity_ref": "entities/item.json",
"name": "Ring of Protection +1",
"quantity": 1,
"weight": 0.1,
"equipped": true,
"grants_modifiers": [
{
"target": "armor_class",
"value": 1,
"type": "bonus",
"source": "Ring of Protection +1"
}
]
},
{
"id": "longbow",
"entity_ref": "entities/weapon.json",
"name": "Longbow",
"quantity": 1,
"weight": 2.0,
"equipped": true
},
{
"id": "shortsword",
"entity_ref": "entities/weapon.json",
"name": "Shortsword",
"quantity": 1,
"weight": 2.0,
"equipped": true
},
{
"id": "arrow",
"entity_ref": "entities/item.json",
"name": "Arrow",
"quantity": 20,
"weight": 1.0,
"equipped": false
},
{
"id": "potion_healing",
"entity_ref": "entities/item.json",
"name": "Potion of Healing",
"quantity": 2,
"weight": 1.0,
"equipped": false
}
]
}
}

Some files were not shown because too many files have changed in this diff Show More