diff --git a/iOS/MonsterCards/Helpers/MonsterImportHelper.swift b/iOS/MonsterCards/Helpers/MonsterImportHelper.swift index 42be564..3d81240 100644 --- a/iOS/MonsterCards/Helpers/MonsterImportHelper.swift +++ b/iOS/MonsterCards/Helpers/MonsterImportHelper.swift @@ -109,3 +109,26 @@ class MonsterImportHelper { return monster } } + +// MARK: - Generic JSON import (auto-detects schema via EntityImporter) + +extension MonsterImportHelper { + + /// Attempts to parse raw JSON into a ``MonsterViewModel`` by trying all registered + /// ``EntityImporter`` implementations in turn. Returns `nil` if no importer recognises the input. + static func fromJSON(_ json: String) -> MonsterViewModel? { + // Try each EntityImporter until one says it can handle this format. + let importers: [EntityImporter.Type] = [TetraCubeMonsterImporter.self, BinderImporter.self] + + for importer in importers where importer.canImport(json) { + do { + return try importer.parse(json) + } catch { + print("⚠️ EntityImporter \(importer) threw while parsing: \(error.localizedDescription)") + continue + } + } + + return nil + } +} diff --git a/iOS/MonsterCards/ImportExport/BinderImportModels.swift b/iOS/MonsterCards/ImportExport/BinderImportModels.swift new file mode 100644 index 0000000..3670745 --- /dev/null +++ b/iOS/MonsterCards/ImportExport/BinderImportModels.swift @@ -0,0 +1,182 @@ +// +// BinderImportModels.swift +// MonsterCards +// +// Data models for parsing Binder (TRPG platform) JSON format. +// Companion to Android BinderExporter / Monster classes. +// Field names here must match the keys produced by Binder's Gson serializer, +// which serializes the Android `Monster` POJO. + +import Foundation + +/// Top-level Binder export file containing collections of monster cards. +struct BinderRoot: Decodable { + let schemaVersion: Int? + @Environment(\.codingVersion) var codingScheme + let collections: [CollectionModel] +} + +/// A named collection (folder) within a Binder export. +struct CollectionModel: Decodable { + let name: String + let cards: [MonsterCard] +} + +/// A single monster card exported from the Binder platform. +struct MonsterCard: Decodable { + + // MARK: - Basic info + + typealias CodingKeys = MonsterCardCodingKey + + enum MonsterCardCodingKey: String, CodingKey { + case name, displayName, displayType, alignment, sizeAbbrv + case shieldName, baseAcFormula, baseAcVal, shieldAcNum, extraArmorAcMod + case hitDice, customHpText, speedText + case primaryAttributes, proSavingThrows + case proficiencySkills, proPerception + case damageImmunities, damageVulnerabilities, conditionImmunities + case specialdamage, languageLine, sensesDescription + case crDisplay, proficiencyBonusValue + case cardTraits, legendaryActionsWithHeader + case attributes + } + + let name: String? + let displayName: String? + let displayType: String? + let alignment: String? + let sizeAbbrv: String? + + // MARK: - Armor / HP + + /// Shield name from Binder (e.g. "Ring of Protection"). + let shieldName: String? + + /// Base AC formula string (e.g. "dexterity + 12" or "Unarmored Defense (+10) + dexterity"). + let baseAcFormula: String? + + /// Numeric baseAC value. + let baseAcVal: Int? + + /// Shield bonus contribution to AC. + let shieldAcNum: Int? + + /// Additional armor modifier (e.g. "+4", "-1"). + let extraArmorAcMod: String? + + /// Hit dice (e.g. "38d10"). When nil, uses hitDice from attributes[0]. + let hitDice: String? + + /// Custom HP text when the monster has custom HP. + let customHpText: String? + + // MARK: - Speed + + /// Formatted speed string (e.g. "40 feet, climb 30 feet"). + let speedText: String? + + // MARK: - Attributes / ability scores + + /// Ability score boxes ordered [STR, DEX, CON, INT, WIS, CHA]. + let primaryAttributes: [CardAttributeBox]? + + // MARK: - Saving throws + + /// Names of saving throws this monster is proficient in (e.g. ["str", "dex"]). + let proSavingThrows: [String]? + + // MARK: - Skills + + /// Proficiency-based skills with expertise flag. + let proficiencySkills: [ProfSkillBox]? + + /// Whether the monster has expert Perception (from Binder). + let proPerception: Int? // 0 or 1 stored as int; nil means false + + // MARK: - Damage / condition immunities + + /// Comma-joined damage immunities string. + let damageImmunities: String? + + /// Comma-joined damage vulnerabilities string. + let damageVulnerabilities: String? + + /// Comma-joined condition immunities string. + let conditionImmunities: String? + + /// Special damage types from Binder (radiant, etc.) with source/target. + let specialdamage: [SpecialDamageBox]? + + // MARK: - Languages & senses + + /// Pre-formatted languages line (e.g. "Common, Deep Speech (150 ft.)"). + let languageLine: String? + + /// Pre-formatted senses line. + let sensesDescription: String? + + // MARK: - Challenge rating / AC display + + /// Display challenge rating (e.g. "3", "Half" for 1/2). + let crDisplay: String? + + /// Proficiency bonus numeric value. + let proficiencyBonusValue: Int? + + /// Display armor class line (e.g. "AC 16; no armor, +4 shield (+2 AC), base AC 10"). + let acLine: String? + + // MARK: - Traits from card + + /// Card trait entries (abilities/actions) with section headers and grouping flags. + let cardTraits: [CardTraitEntry]? + + /// Whether the card already prepended a "Legendary Actions" heading. + let legendaryActionsWithHeader: Int? // 0/1 +} + +/// An ability score box on a Binder monster card. +struct CardAttributeBox: Decodable { + let abbrv: String? // str, dex, con, int, wis, cha + let value: Int? // score (e.g. 18) + let modifier: String? // display modifier string ("+4") +} + +/// A proficiency-based skill entry (e.g. Perception). +struct ProfSkillBox: Decodable { + let name: String? + @Environment(\.codingVersion) var codingScheme + let isExpertise: Int? // 0 or 1 stored as int; nil means false + + enum CodingKeys: String, CodingKey { + case name, expertise + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) + + // "expertise" field may be 0 or 1 as Int. Also store it in isExpertise. + if let expertiseValue = (try? container.decode(Int.self, forKey: .expertise)), expertiseValue == 1 { + self.isExpertise = 1 + } else { + self.isExpertise = 0 + } + } +} + +/// Special damage type entry from Binder cards (e.g. radiant damage from a feature). +struct SpecialDamageBox: Decodable { + let source: String? // ability that causes the damage + let value: String? // damage dice/string + let target: String? // target description +} + +/// A trait entry parsed from cardTraits array in Binder output. +struct CardTraitEntry: Decodable { + let displayName: String? // action/ability name + let desc: String? // ability text + let isActionHeader: Int? // 0 or 1 flag for section headers (e.g. "Spellcasting", "Legendary Actions") + let actionSection: Int? // grouping key (0=bonus actions, 1=actions, 2=reactions, etc.) +} diff --git a/iOS/MonsterCards/ImportExport/BinderImporter.swift b/iOS/MonsterCards/ImportExport/BinderImporter.swift new file mode 100644 index 0000000..cfaeede --- /dev/null +++ b/iOS/MonsterCards/ImportExport/BinderImporter.swift @@ -0,0 +1,356 @@ +// +// BinderImporter.swift +// MonsterCards +// +// Detects and parses Binder (TRPG platform) JSON format into a MonsterViewModel. +// Companion to Android BinderImporter.java — shares the same detection logic (schema or collections key). +// + +import Foundation + +/// Imports a single monster from the Binder export format onto a new `MonsterViewModel`. +struct BinderImporter: EntityImporter { + + // MARK: - canImport + + /// Detects whether `raw` is a Binder import payload (single collection file or multi-collection file). + static func canImport(_ raw: String) -> Bool { + guard let data = raw.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + return false + } + + // Check for $schema reference to binder.schema.json (Android BinderImporter.java line ~32) + if let schema = root["$schema"] as? String, schema.contains("binder.schema.json") { + return true + } + + // Fall back: check known Binder fields from collections array (matches Android logic) + if root["collections"] is [Any] { + return !CollectionsCheck.isEmpty(try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]]) + } + + return false + } + + // MARK: - parse + + static func parse(_ raw: String) throws -> MonsterViewModel { + guard let data = raw.data(using: .utf8) else { + throw BinderParseError.unsupportedEncoding + } + + // Try full export with collections + if let binderRoot = try? JSONDecoder().decode(BinderRoot.self, from: data), + !binderRoot.collections.isEmpty, + !binderRoot.collections[0].cards.isEmpty { + return mapMonster(binderRoot.collections[0].cards[0]) + } + + // Fall back to single-card format + guard let card = try? JSONDecoder().decode(MonsterCard.self, from: data) else { + throw BinderParseError.invalidFormat("Failed to parse Binder JSON") + } + + return mapMonster(card) + } + + // MARK: - Mapping + + /// Maps a `MonsterCard` (Binder inner type or top-level export entry) to MonsterViewModel. + private static func mapMonster(_ card: MonsterCard) -> MonsterViewModel { + let monster = MonsterViewModel() + + // MARK: Basic info (Android: name, displayType, sizeAbbrv, alignment) + monster.name = card.displayName ?? card.name ?? "" + monster.type = card.displayType ?? "" + // sizeAbbrv maps like Android: "T" → tiny, "S" → small, etc. + monster.size = abrvToSize(card.sizeAbbrv) + monster.subType = "" // Binder does not have a separate subtype field + monster.alignment = card.alignment ?? "" + + // MARK: AC & armor (from Android Monster.java fields: shieldName, baseAcFormula, etc.) + monster.hasShield = (card.shieldAcNum ?? 0) != 0 || ((card.baseAcFormula?.contains("shield")) ?? false) + monster.shieldBonus = card.shieldAcNum ?? (monster.hasShield ? 2 : 0) + + if card.baseAcFormula != nil || (card.shieldName != nil && !card.shieldName!.isEmpty) { + monster.armorType = .other // unarmored-defense / custom approach + monster.otherArmorDescription = card.shieldName ?? "no shield" + } else { + monster.armorType = .none + } + + if let armorModStr = card.extraArmorAcMod, let modVal = Int(armorModStr) { + // For "no armor" style, extraArmorAcMod is added directly to the AC formula text. + let suffix = (card.otherArmorDescription.isEmpty ? "" : ", ") + "(+\(modVal))" + monster.otherArmorDescription += suffix + } + + // MARK: HP & hit dice (Android: hitDice → String like "38d10"; customHpText for override) + if card.customHpText != nil && !card.customHpText!.isEmpty { + monster.hasCustomHP = true + monster.customHP = card.customHpText! + } else { + // Parse hit dice string like "38d10" → hitDice = 38 + if let hdStr = card.hitDice, let parts = splitHitDice(hdStr) { + monster.hitDice = Int64(parts.diceCount ?? 0) + } + } + + // MARK: Speed (Android MonsterImportHelper.speedFromString) + if let stxt = card.speedText where !stxt.isEmpty && !stxt.contains("feet") { + monster.customSpeed = stxt + monster.hasCustomSpeed = true + } else if card.speedText != nil { + monster.walkSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "walk")) + monster.burrowSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "burrow")) + monster.climbSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "climb")) + monster.flySpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "fly")) + monster.swimSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "swim")) + } + + // MARK: Ability scores (Android Monster.primaryAttributes[0…5] in [STR, DEX, CON, INT, WIS, CHA]) + if let attrs = card.primaryAttributes, attrs.count >= 6 { + monster.strengthScore = Int64(attrs[0].value ?? 10) + monster.dexterityScore = Int64(attrs[1].value ?? 10) + monster.constitutionScore = Int64(attrs[2].value ?? 10) + monster.intelligenceScore = Int64(attrs[3].value ?? 10) + monster.wisdomScore = Int64(attrs[4].value ?? 10) + monster.charismaScore = Int64(attrs[5].value ?? 10) + } + + // MARK: Saving throws proficiency (Android Monster.proSavingThrows) + for throwName in (card.proSavingThrows ?? []) { + switch throwName.lowercased() { + case "str" : monster.strengthSavingThrowProficiency = .proficient + case "dex" : monster.dexteritySavingThrowProficiency = .proficient + case "con" : monster.constitutionSavingThrowProficiency = .proficient + case "int" : monster.intelligenceSavingThrowProficiency = .proficient + case "wis" : monster.wisdomSavingThrowProficiency = .proficient + case "cha" : monster.charismaSavingThrowProficiency = .proficient + default: break + } + } + + // MARK: Skills (Android Monster.proficiencySkills) + for skillEntry in (card.proficiencySkills ?? []) { + guard let sname = skillEntry.name else { continue } + let prof: ProficiencyType = skillEntry.isExpertise == 1 ? .expertise : .proficient + if let ability = SkillViewModel.knownSkillForName(sname) { + monster.skills.append(SkillViewModel(sname, ability, prof)) + } else { + // Fallback + monster.skills.append(SkillViewModel(sname, .dexterity, prof)) + } + } + + // MARK: Damage immunities / resistances / vulnerabilities + if let di = card.damageImmunities { + monster.damageImmunities = parseDelimitedString(di) + } + if let dv = card.damageVulnerabilities { + monster.damageVulnerabilities = parseDelimitedString(dv) + } + + // Condition immunities + if let ci = card.conditionImmunities { + monster.conditionImmunities = parseDelimitedString(ci) + } + + // MARK: Languages (Android MonsterImportHelper.parseLanguageLine) + if let langLine = card.languageLine { + monster.languages = parseLanguages(langLine) + } + + // MARK: Senses (Android sensesDescription → split into sense entries) + if let sensesStr = card.sensesDescription, !sensesStr.isEmpty { + // Each item is typically " ft." or just a name. + monster.senses = parseDelimitedString(sensesStr) + } + + // MARK: Challenge rating (Android Monster.crDisplay) + let crText = card.crDisplay ?? "0" + if let cr = challengeRating(for: crText) { + monster.challengeRating = cr + } else { + monster.challengeRating = .zero + } + + // Proficiency bonus + if let pbVal = card.proficiencyBonusValue { + monster.customProficiencyBonus = Int64(pbVal) + } + + // MARK: Traits / actions (from Android Monster.cardTraits grouped by actionSection) + processCardTraits(card, into: monster) + + return monster + } +} + +// MARK: - Private Helpers + +private extension BinderImporter { + + /// Convert a size abbreviation to the full size enum name. (Android uses the same mapping.) + static func abrvToSize(_ abbrv: String?) -> String { + guard let a = abbrv else { return "" } + switch a.uppercased() { + case "T": return "Tiny" + case "S": return "Small" + case "M": return "Medium" + case "L": return "Large" + case "H": return "Huge" + case "G": return "Gargantuan" + default: return a + } + } + + /// Parse "38d10" → (diceCount: 38, dieSize: 10). Android uses this format. + static func splitHitDice(_ hdStr: String) -> (diceCount: Int?, dieSize: Int)? { + let parts = hdStr.split(separator: "d") + guard parts.count == 2 else { return nil } + let count = Int(parts[0]) ?? 0 + let size = Int(parts[1]) ?? 6 + return (count, size) + } + + /// Extract a specific movement speed type from formatted string like "40 feet, climb 20 ft." + static func extractSpeedMeters(_ text: String?, forSpeed type: String) -> Int { + guard let t = text else { return 0 } + let pattern = "\\b\(type)\\b" + if let regex = try? NSRegularExpression(pattern: "\(pattern) (\\d+)", options: [.caseInsensitive]) { + let range = NSRange(t.startIndex..., in: t) + if let match = regex.firstMatch(in: t, options: [], range: range), + let numRange = Range(match.range(at: 1), in: t) { + var extractedText = "" + #if swift(>=5.0) + extractedText = String(t[numRange]) + #else + extractedText = (t as NSString).substring(with: numRange) + #endif + if let val = Int(extractedText) { return val } + } + } + return 0 + } + + /// Parse comma-separated string into [StringViewModel]. + static func parseDelimitedString(_ input: String) -> [StringViewModel] { + return input.components(separatedBy: ",").compactMap { + let trimmed = $0.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : StringViewModel(trimmed) + } + } + + /// Parse a language line like "Common, Deep Speech (150 ft.)" into [LanguageViewModel]. + static func parseLanguages(_ line: String) -> [LanguageViewModel] { + var result: [LanguageViewModel] = [] + let components = line.components(separatedBy: ",") + + for comp in components { + let trimmed = comp.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + var hasSpeaks = true + + // Check for understands-only patterns + if trimmed.lowercased().contains("understand") || trimmed.lowercased().contains("(silently)") { + hasSpeaks = false + } + + // Remove trailing range annotation like " (150 ft.)" → keep just the name + let parts = trimmed.split(separator: " (") + var nameOnly = trimmed + if parts.count >= 2 { + nameOnly = String(parts.first ?? "") + } + + result.append(LanguageViewModel(nameOnly, hasSpeaks)) + } + + return result + } + + /// Determine ChallengeRating from text (Android Monster.crDisplay → challengeRating conversion). + static func challengeRating(for text: String?) -> ChallengeRating? { + guard let crText = text else { return .zero } + + // Handle common text aliases + switch crText.lowercased() { + case "half": return .oneHalf + case "quarter", "one quarter": return .oneQuarter + case "eighth": return .oneEighth + case "three halves": return .two + default: break + } + + // Try numeric parse first (e.g. "0" → zero, "3" → three) + if let val = Int(crText), let cr = ChallengeRating(rawValue: String(val)) { + return cr + } + + // Handle fractions like "1/2", "5/2" + if let rangeIdx = crText.range(of: "/") { + let numStr = crText[..= 2) + XCTAssertTrue(m.senses.first(where: { $0.name.contains("Blindness") }) != nil) + + // Challenge rating from text: "Half" → oneHalf + XCTAssertEqual(m.challengeRating, .oneHalf, "Should parse 'Half' as one_half CR") + } + + func testParseCollectionFormat() { + let json = """ + { + "$schema": "https://majinnaibu.com/schemas/binder.schema.json", + "schemaVersion": 1, + "collections": [ + { + "name": "Villains of the Realm", + "cards": [ + { + "name": "Balrog", + "displayName": "Balrog of Moria", + "displayType": "Demon", + "alignment": "Neutral Evil", + "sizeAbbrv": "L", + "hitDice": "104d10", + "proSavingThrows": ["con"], + "primaryAttributes": [ + {"abbrv": "str", "value": 30}, + {"abbrv": "dex", "value": 18}, + {"abbrv": "con", "value": 25}, + {"abbrv": "int", "value": 18}, + {"abbrv": "wis", "value": 16}, + {"abbrv": "cha", "value": 20} + ] + } + ] + } + ] + } + """ + + let monster = try? BinderImporter.parse(json) + XCTAssertNotNil(monster, "Should successfully parse valid collection JSON") + + guard let m = monster else { return XCTFail("Expected non-nil parsed result") } + + XCTAssertEqual(m.name, "Balrog of Moria", "Name should use displayName from first card in first collection") + XCTAssertEqual(m.type, "Demon", "displayType preserved") + XCTAssertEqual(m.size, "Large", "L maps to Large") + } + + func testParseInvalidJsonThrows() { + XCTAssertThrowsError(try BinderImporter.parse("not valid json string")) { error in + XCTAssertTrue(error is BinderParseError) + if let e = error as? BinderParseError { + switch e { + case .invalidFormat: break // expected + default: XCTFail("Expected invalidFormat error") + } + } + } + } + + func testDefaultAbilityScoresFailsafe() { + let json = """ + {"collections": [{"name": "X", "cards": [{"name": "No Stats"}]}]} + """ + + guard let m = try? BinderImporter.parse(json) else { + return XCTFail("Should succeed even with minimal MonsterCard data") + } + + // Default ability score is 10 per MonsterViewModel init convention + XCTAssertEqual(m.strengthScore, 10) + } +}