Files
MonsterCards/MonsterCards/Models/MonsterViewModel.swift
Tom Hicks d3a1878656 Model cleanup.
Separates core data transport stuff to extensions so we can use the view models without a core data dependency.
2021-04-08 19:18:27 -07:00

709 lines
26 KiB
Swift

//
// MonsterViewModel.swift
// MonsterCards
//
// Created by Tom Hicks on 1/18/21.
//
import Foundation
import CoreData
class MonsterViewModel: ObservableObject {
// TODO: Determine whether to prefer Int or Int64 for these fields and switch as many as possible to the winner.
@Published var name: String
@Published var size: String
@Published var type: String
@Published var subType: String
@Published var alignment: String
@Published var hitDice: Int64
@Published var hasCustomHP: Bool
@Published var customHP: String
@Published var armorType: ArmorType
@Published var hasShield: Bool {
didSet { shieldBonus = hasShield ? 2 : 0 }
}
@Published var naturalArmorBonus: Int64
@Published var customArmor: String
@Published var walkSpeed: Int64
@Published var burrowSpeed: Int64
@Published var climbSpeed: Int64
@Published var flySpeed: Int64
@Published var canHover: Bool
@Published var swimSpeed: Int64
@Published var hasCustomSpeed: Bool
@Published var customSpeed: String
@Published var strengthScore: Int64
@Published var dexterityScore: Int64
@Published var constitutionScore: Int64
@Published var intelligenceScore: Int64
@Published var wisdomScore: Int64
@Published var charismaScore: Int64
@Published var strengthSavingThrowProficiency: ProficiencyType
@Published var strengthSavingThrowAdvantage: AdvantageType
@Published var dexteritySavingThrowProficiency: ProficiencyType
@Published var dexteritySavingThrowAdvantage: AdvantageType
@Published var constitutionSavingThrowProficiency: ProficiencyType
@Published var constitutionSavingThrowAdvantage: AdvantageType
@Published var intelligenceSavingThrowProficiency: ProficiencyType
@Published var intelligenceSavingThrowAdvantage: AdvantageType
@Published var wisdomSavingThrowProficiency: ProficiencyType
@Published var wisdomSavingThrowAdvantage: AdvantageType
@Published var charismaSavingThrowProficiency: ProficiencyType
@Published var charismaSavingThrowAdvantage: AdvantageType
@Published var skills: [SkillViewModel]
@Published var damageImmunities: [StringViewModel]
@Published var damageResistances: [StringViewModel]
@Published var damageVulnerabilities: [StringViewModel]
@Published var conditionImmunities: [StringViewModel]
@Published var senses: [StringViewModel]
@Published var languages: [LanguageViewModel]
@Published var telepathy: Int64
@Published var understandsBut: String
@Published var challengeRating: ChallengeRating
@Published var customChallengeRating: String
@Published var customProficiencyBonus: Int64
@Published var abilities: [AbilityViewModel]
@Published var actions: [AbilityViewModel]
@Published var legendaryActions: [AbilityViewModel]
@Published var lairActions: [AbilityViewModel]
@Published var regionalActions: [AbilityViewModel]
@Published var reactions: [AbilityViewModel]
@Published var isBlind: Bool
@Published var shieldBonus: Int
@Published var otherArmorDescription: String
let kBaseArmorClassUnarmored = 10;
let kBaseArmorClassMageArmor = 13;
let kBaseArmorClassPadded = 11;
let kBaseArmorClassLeather = 11;
let kBaseArmorClassStudded = 12;
let kBaseArmorClassHide = 12;
let kBaseArmorClassChainShirt = 13;
let kBaseArmorClassScaleMail = 14;
let kBaseArmorClassBreastplate = 14;
let kBaseArmorClassHalfPlate = 15;
let kBaseArmorClassRingMail = 14;
let kBaseArmorClassChainMail = 16;
let kBaseArmorClassSplintMail = 17;
let kBaseArmorClassPlate = 18;
init() {
self.name = ""
self.size = ""
self.type = ""
self.subType = ""
self.alignment = ""
self.hitDice = 0
self.hasCustomHP = false
self.customHP = ""
self.armorType = .none
self.hasShield = false
self.naturalArmorBonus = 0
self.customArmor = ""
self.walkSpeed = 0
self.burrowSpeed = 0
self.climbSpeed = 0
self.flySpeed = 0
self.canHover = false
self.swimSpeed = 0
self.hasCustomSpeed = false
self.customSpeed = ""
self.strengthScore = 10
self.strengthSavingThrowAdvantage = .none
self.strengthSavingThrowProficiency = .none
self.dexterityScore = 10
self.dexteritySavingThrowAdvantage = .none
self.dexteritySavingThrowProficiency = .none
self.constitutionScore = 10
self.constitutionSavingThrowAdvantage = .none
self.constitutionSavingThrowProficiency = .none
self.intelligenceScore = 10
self.intelligenceSavingThrowAdvantage = .none
self.intelligenceSavingThrowProficiency = .none
self.wisdomScore = 10
self.wisdomSavingThrowAdvantage = .none
self.wisdomSavingThrowProficiency = .none
self.charismaScore = 10
self.charismaSavingThrowAdvantage = .none
self.charismaSavingThrowProficiency = .none
self.skills = []
self.damageImmunities = []
self.damageResistances = []
self.damageVulnerabilities = []
self.conditionImmunities = []
self.senses = []
self.languages = []
self.telepathy = 0
self.understandsBut = ""
self.challengeRating = .one
self.customChallengeRating = ""
self.customProficiencyBonus = 0
self.abilities = []
self.actions = []
self.legendaryActions = []
self.lairActions = []
self.regionalActions = []
self.reactions = []
self.isBlind = false
// Private properties
self.shieldBonus = 0
self.otherArmorDescription = ""
}
// MARK: Basic Info
var meta: String {
get {
// size type (subtype) alignment
var parts: [String] = []
if (!(self.size.isEmpty)) {
parts.append(self.size)
}
if (!(self.type.isEmpty)) {
parts.append(self.type)
}
if (!(self.subType.isEmpty)) {
parts.append(String.init(format: "(%@)", arguments: [self.subType]))
}
if (!(self.alignment.isEmpty)) {
parts.append(self.alignment)
}
return parts.joined(separator: " ")
}
}
var sizeEnum: SizeType {
get {
return SizeType.init(rawValue: size) ?? .medium
}
set {
size = newValue.rawValue
}
}
var hitPoints: String {
get {
if (hasCustomHP) {
return customHP;
} else {
let dieSize = Double(MonsterViewModel.hitDieForSize(sizeEnum))
let conMod = Double(constitutionModifier)
// let level1HP = Double(dieSize + conMod)
let level1HP = Double(dieSize/2.0 + conMod)
let extraLevels = Double(hitDice - 1)
let levelNHP = (dieSize + 1.0) / 2.0 + conMod
let extraLevelsHP = extraLevels * levelNHP
let hpTotal = Int(ceil(level1HP + extraLevelsHP))
let conBonus = Int(conMod) * Int(hitDice)
return String(format: "%d (%dd%d%+d)", hpTotal, hitDice, Int(dieSize), conBonus)
}
}
}
var speed: String {
get {
if (hasCustomSpeed) {
return customSpeed
} else {
var parts: [String] = []
if (walkSpeed > 0) {
parts.append("\(walkSpeed) ft.")
}
if (burrowSpeed > 0) {
parts.append("burrow \(burrowSpeed) ft.")
}
if (climbSpeed > 0) {
parts.append("climb \(climbSpeed) ft.")
}
if (flySpeed > 0) {
parts.append("fly \(flySpeed) ft.\(canHover ? " (hover)": "")")
}
if (swimSpeed > 0) {
parts.append("swim \(swimSpeed) ft.")
}
return parts.joined(separator: ", ")
}
}
}
class func hitDieForSize(_ size: SizeType) -> Int {
switch size {
case .tiny: return 4
case .small: return 6
case .medium: return 8
case .large: return 10
case .huge: return 12
case .gargantuan: return 20
}
}
// MARK: Ability Scores
class func abilityModifierForScore(_ score: Int) -> Int {
return Int(floor(Double((score - 10)) / 2.0))
}
func abilityModifierForAbilityScore(_ abilityScore: AbilityScore) -> Int {
switch abilityScore {
case .strength:
return strengthModifier;
case .dexterity:
return dexterityModifier
case .constitution:
return constitutionModifier
case .intelligence:
return intelligenceModifier
case .wisdom:
return wisdomModifier
case .charisma:
return charismaModifier
}
}
var strengthModifier: Int {
get {
return MonsterViewModel.abilityModifierForScore(Int(strengthScore))
}
}
var dexterityModifier: Int {
get {
return MonsterViewModel.abilityModifierForScore(Int(dexterityScore))
}
}
var constitutionModifier: Int {
get {
return MonsterViewModel.abilityModifierForScore(Int(constitutionScore))
}
}
var intelligenceModifier: Int {
get {
return MonsterViewModel.abilityModifierForScore(Int(intelligenceScore))
}
}
var wisdomModifier: Int {
get {
return MonsterViewModel.abilityModifierForScore(Int(wisdomScore))
}
}
var charismaModifier: Int {
get {
return MonsterViewModel.abilityModifierForScore(Int(charismaScore))
}
}
// MARK: Armor
var armorClassDescription: String {
get {
var armorClassTotal = 0
if (armorType == ArmorType.none) {
// 10 + dexMod + 2 for shieldBonus "15" or "17 (shield)"
armorClassTotal = kBaseArmorClassUnarmored + dexterityModifier + Int(shieldBonus)
return "\(armorClassTotal)\(hasShield ? " (shield)" : "")"
} else if (armorType == .naturalArmor) {
// 10 + dexMod + naturalArmorBonus + 2 for shieldBonus "16 (natural armor)" or "18 (natural armor, shield)"
armorClassTotal = kBaseArmorClassUnarmored + dexterityModifier + Int(naturalArmorBonus) + Int(shieldBonus)
return "\(armorClassTotal) (natural armor\(hasShield ? " (shield)" : ""))"
} else if (armorType == .mageArmor) {
// 10 + dexMod + 2 for shield + 3 for mage armor "15 (18 with mage armor)" or 17 (shield, 20 with mage armor)
armorClassTotal = kBaseArmorClassUnarmored + dexterityModifier + Int(shieldBonus)
let acWithMageArmor = kBaseArmorClassMageArmor + dexterityModifier + Int(shieldBonus)
return String(format: "%d (%@%d with mage armor)", armorClassTotal, (hasShield ? "shield, " : ""), acWithMageArmor)
} else if (armorType == .padded) {
// 11 + dexMod + 2 for shield "18 (padded armor, shield)"
armorClassTotal = kBaseArmorClassPadded + dexterityModifier + Int(shieldBonus)
return String(format: "%d (padded%@)", armorClassTotal, (hasShield ? "shield, " : ""))
} else if (armorType == .leather) {
// 11 + dexMod + 2 for shield "18 (leather, shield)"
armorClassTotal = kBaseArmorClassLeather + dexterityModifier + Int(shieldBonus)
return String(format:"%d (leather%@)", armorClassTotal, (hasShield ? "shield, " : ""))
} else if (armorType == .studdedLeather) {
// 12 + dexMod +2 for shield "17 (studded leather)"
armorClassTotal = kBaseArmorClassStudded + dexterityModifier + Int(shieldBonus)
return String(format: "%d (studded leather%@)", armorClassTotal, (hasShield ? "shield, " : ""))
} else if (armorType == .hide) {
// 12 + Min(2, dexMod) + 2 for shield "12 (hide armor)"
armorClassTotal = kBaseArmorClassHide + min(2, dexterityModifier) + Int(shieldBonus)
return String(format: "%d (hide%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .chainShirt) {
// 13 + Min(2, dexMod) + 2 for shield "12 (chain shirt)"
armorClassTotal = kBaseArmorClassChainShirt + min(2, dexterityModifier) + Int(shieldBonus)
return String(format: "%d (chain shirt%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .scaleMail) {
// 14 + Min(2, dexMod) + 2 for shield "14 (scale mail)"
armorClassTotal = kBaseArmorClassScaleMail + min(2, dexterityModifier) + Int(shieldBonus)
return String(format: "%d (scale mail%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .breastplate) {
// 14 + Min(2, dexMod) + 2 for shield "16 (breastplate)"
armorClassTotal = kBaseArmorClassBreastplate + min(2, dexterityModifier) + Int(shieldBonus)
return String(format: "%d (breastplate%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .halfPlate) {
// 15 + Min(2, dexMod) + 2 for shield "17 (half plate)"
armorClassTotal = kBaseArmorClassHalfPlate + min(2, dexterityModifier) + Int(shieldBonus)
return String(format: "%d (half plate%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .ringMail) {
// 14 + 2 for shield "14 (ring mail)
armorClassTotal = kBaseArmorClassRingMail + Int(shieldBonus)
return String(format: "%d (ring mail%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .chainMail) {
// 16 + 2 for shield "16 (chain mail)"
armorClassTotal = kBaseArmorClassChainMail + Int(shieldBonus)
return String(format: "%d (chain mail%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .splintMail) {
// 17 + 2 for shield "17 (splint)"
armorClassTotal = kBaseArmorClassSplintMail + Int(shieldBonus)
return String(format: "%d (splint%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .plateMail) {
// 18 + 2 for shield "18 (plate)"
armorClassTotal = kBaseArmorClassPlate + Int(shieldBonus)
return String(format: "%d (plate%@)", armorClassTotal, (hasShield ? ", shield" : ""))
} else if (armorType == .other) {
// pure string value shield check does nothing just copies the string from otherArmorDesc
return otherArmorDescription;
} else {
return ""
}
}
}
// MARK: Challenge Rating / Proficiency Bonus
var proficiencyBonus: Int {
switch challengeRating {
case .custom:
return Int(customProficiencyBonus)
case .zero:
fallthrough
case .oneEighth:
fallthrough
case .oneQuarter:
fallthrough
case .oneHalf:
fallthrough
case .one:
fallthrough
case .two:
fallthrough
case .three:
fallthrough
case .four:
return 2
case .five:
fallthrough
case .six:
fallthrough
case .seven:
fallthrough
case .eight:
return 3
case .nine:
fallthrough
case .ten:
fallthrough
case .eleven:
fallthrough
case .twelve:
return 4
case .thirteen:
fallthrough
case .fourteen:
fallthrough
case .fifteen:
fallthrough
case .sixteen:
return 5
case .seventeen:
fallthrough
case .eighteen:
fallthrough
case .nineteen:
fallthrough
case .twenty:
return 6
case .twentyOne:
fallthrough
case .twentyTwo:
fallthrough
case .twentyThree:
fallthrough
case .twentyFour:
return 7
case .twentyFive:
fallthrough
case .twentySix:
fallthrough
case .twentySeven:
fallthrough
case .twentyEight:
return 8
case .twentyNine:
fallthrough
case .thirty:
return 9
}
}
func proficiencyBonusForType(_ profType: ProficiencyType) -> Int {
switch profType {
case .none:
return 0
case .proficient:
return proficiencyBonus
case .expertise:
return proficiencyBonus * 2
}
}
// MARK: Saving Throws
var savingThrowsDescription: String {
get {
// TODO: port from objective-c
var parts: [String] = []
var name: String
var advantage: String
var bonus: Int
if (strengthSavingThrowAdvantage != .none || strengthSavingThrowProficiency != .none) {
name = "Strength"
bonus = strengthModifier + proficiencyBonusForType(strengthSavingThrowProficiency)
advantage = MonsterViewModel.advantageLabelStringForType(strengthSavingThrowAdvantage)
if (!advantage.isEmpty) {
advantage = " " + advantage
}
parts.append(String(format: "%@ %+d%@", name, bonus, advantage))
}
if (dexteritySavingThrowAdvantage != .none || dexteritySavingThrowProficiency != .none) {
name = "Dexterity"
bonus = dexterityModifier + proficiencyBonusForType(dexteritySavingThrowProficiency)
advantage = MonsterViewModel.advantageLabelStringForType(dexteritySavingThrowAdvantage)
if (!advantage.isEmpty) {
advantage = " " + advantage
}
parts.append(String(format: "%@ %+d%@", name, bonus, advantage))
}
if (constitutionSavingThrowAdvantage != .none || constitutionSavingThrowProficiency != .none) {
name = "Constitution"
bonus = constitutionModifier + proficiencyBonusForType(constitutionSavingThrowProficiency)
advantage = MonsterViewModel.advantageLabelStringForType(constitutionSavingThrowAdvantage)
if (!advantage.isEmpty) {
advantage = " " + advantage
}
parts.append(String(format: "%@ %+d%@", name, bonus, advantage))
}
if (intelligenceSavingThrowAdvantage != .none || intelligenceSavingThrowProficiency != .none) {
name = "Intelligence"
bonus = intelligenceModifier + proficiencyBonusForType(intelligenceSavingThrowProficiency)
advantage = MonsterViewModel.advantageLabelStringForType(intelligenceSavingThrowAdvantage)
if (!advantage.isEmpty) {
advantage = " " + advantage
}
parts.append(String(format: "%@ %+d%@", name, bonus, advantage))
}
if (wisdomSavingThrowAdvantage != .none || wisdomSavingThrowProficiency != .none) {
name = "Wisdom"
bonus = wisdomModifier + proficiencyBonusForType(wisdomSavingThrowProficiency)
advantage = MonsterViewModel.advantageLabelStringForType(wisdomSavingThrowAdvantage)
if (!advantage.isEmpty) {
advantage = " " + advantage
}
parts.append(String(format: "%@ %+d%@", name, bonus, advantage))
}
if (charismaSavingThrowAdvantage != .none || charismaSavingThrowProficiency != .none) {
name = "Charisma"
bonus = charismaModifier + proficiencyBonusForType(charismaSavingThrowProficiency)
advantage = MonsterViewModel.advantageLabelStringForType(charismaSavingThrowAdvantage)
if (!advantage.isEmpty) {
advantage = " " + advantage
}
parts.append(String(format: "%@ %+d%@", name, bonus, advantage))
}
return parts.joined(separator: ", ")
}
}
// MARK: Misc Helpers
class func advantageLabelStringForType(_ advType: AdvantageType) -> String {
switch advType {
case .none:
return ""
case .advantage:
return "(A)"
case .disadvantage:
return "(D)"
}
}
// MARK: Skills
var skillsDescription: String {
get {
let sortedSkills = self.skills.sorted {$0.name < $1.name}
return sortedSkills.reduce("") {
if $0 == "" {
return $1.skillDescription(forMonster: self)
} else {
return $0 + ", " + $1.skillDescription(forMonster: self)
}
}
}
}
// MARK: Immunities, Resistances, and Vulnerabilities
var damageVulnerabilitiesDescription: String {
get {
// TODO: sort "bludgeoning, piercing, and slashing from nonmagical attacks" to the end and use ; as a separator before it.
let sortedVulnerabilities = self.damageVulnerabilities.sorted().map({$0.name})
return StringHelper.oxfordJoin(sortedVulnerabilities)
}
}
var damageResistancesDescription: String {
get {
// TODO: sort "bludgeoning, piercing, and slashing from nonmagical attacks" to the end and use ; as a separator before it.
let sortedResistances = self.damageResistances.sorted().map({$0.name})
return StringHelper.oxfordJoin(sortedResistances)
}
}
var damageImmunitiesDescription: String {
get {
// TODO: sort "bludgeoning, piercing, and slashing from nonmagical attacks" to the end and use ; as a separator before it.
let sortedImmunities = self.damageImmunities.sorted().map({$0.name})
return StringHelper.oxfordJoin(sortedImmunities)
}
}
var conditionImmunitiesDescription: String {
get {
let sortedImmunities = self.conditionImmunities.sorted().map({$0.name})
return StringHelper.oxfordJoin(sortedImmunities)
}
}
// MARK: OTHER
var passivePerception: Int {
get {
let perceptionSkill = skills.first(where: {
StringHelper.safeEqualsIgnoreCase($0.name, "Perception")
})
if (perceptionSkill == nil) {
return 10 + wisdomModifier
} else if (perceptionSkill!.proficiency == ProficiencyType.expertise) {
return 10 + wisdomModifier + proficiencyBonus + proficiencyBonus
} else if (perceptionSkill!.proficiency == ProficiencyType.proficient) {
return 10 + wisdomModifier + proficiencyBonus
} else {
return 10 + wisdomModifier
}
}
}
var sensesDescription: String {
get {
var modifiedSenses = self.senses.sorted().map({$0.name})
let hasPassivePerceptionSense = modifiedSenses.contains(where: {
$0.starts(with: "passive Perception")
})
if (!hasPassivePerceptionSense) {
let calculatedPassivePerception = String(format: "passive Perception %d", passivePerception)
modifiedSenses.append(calculatedPassivePerception)
}
return modifiedSenses.sorted().joined(separator: ", ")
}
}
var languagesDescription: String {
get {
let spokenLanguages =
languages
.filter({ $0.speaks })
.map({$0.name})
.sorted()
let understoodLanguages =
languages
.filter({ !$0.speaks })
.map({$0.name})
.sorted()
let understandsButText = understandsBut.isEmpty
? ""
: String(format: " but %@", understandsBut)
let telepathyText = telepathy > 0
? String(format: ", telepathy %d ft.", telepathy)
: ""
if (spokenLanguages.count > 0) {
if (understoodLanguages.count > 0) {
return String(
format:"%@ and understands %@%@%@",
StringHelper.oxfordJoin(spokenLanguages),
StringHelper.oxfordJoin(understoodLanguages),
understandsButText,
telepathyText)
} else {
return String(
format: "%@%@%@",
StringHelper.oxfordJoin(spokenLanguages),
understandsButText,
telepathyText)
}
} else {
if (understoodLanguages.count > 0) {
return String(
format: "understands %@%@%@",
StringHelper.oxfordJoin(understoodLanguages),
understandsButText,
telepathyText)
} else if (telepathy > 0){
return String(format: "telepathy %d ft.", telepathy)
} else {
return ""
}
}
}
}
var challengeRatingDescription: String {
get {
if (challengeRating != .custom) {
return challengeRating.displayName
} else {
return customChallengeRating
}
}
}
// MARK: End
}