From d5b1ce56d07c10f605f86f7bfb16c0887edbc579 Mon Sep 17 00:00:00 2001 From: Brian Arnold Date: Sun, 9 Nov 2025 11:42:38 -0500 Subject: [PATCH] Updates to use dictionaries for fast lookup of frequently accessed elements. --- .../Configuration/Configuration.swift | 4 +- .../RolePlayingCore/Currency/Currencies.swift | 31 ++++++----- .../RolePlayingCore/Player/Backgrounds.swift | 36 +++++++++---- Sources/RolePlayingCore/Player/Player.swift | 4 +- Sources/RolePlayingCore/Player/Skills.swift | 52 +++++++++++++++---- .../BackgroundsTests.swift | 17 +++--- .../RolePlayingCoreTests/CurrencyTests.swift | 24 ++++----- 7 files changed, 105 insertions(+), 63 deletions(-) diff --git a/Sources/RolePlayingCore/Configuration/Configuration.swift b/Sources/RolePlayingCore/Configuration/Configuration.swift index fd0ce95..cec418e 100644 --- a/Sources/RolePlayingCore/Configuration/Configuration.swift +++ b/Sources/RolePlayingCore/Configuration/Configuration.swift @@ -63,13 +63,13 @@ public struct Configuration { for skillsFile in configurationFiles.skills { let jsonData = try bundle.loadJSON(skillsFile) let skills = try jsonDecoder.decode(Skills.self, from: jsonData) - self.skills.skills += skills.skills + self.skills.add(skills.all) } for backgroundsFile in configurationFiles.backgrounds { let jsonData = try bundle.loadJSON(backgroundsFile) let backgrounds = try jsonDecoder.decode(Backgrounds.self, from: jsonData, configuration: self) - self.backgrounds.backgrounds += backgrounds.backgrounds + self.backgrounds.add(backgrounds.all) } for speciesFile in configurationFiles.species { diff --git a/Sources/RolePlayingCore/Currency/Currencies.swift b/Sources/RolePlayingCore/Currency/Currencies.swift index b8b54e2..08a7921 100644 --- a/Sources/RolePlayingCore/Currency/Currencies.swift +++ b/Sources/RolePlayingCore/Currency/Currencies.swift @@ -9,22 +9,25 @@ import Foundation /// A collection of currencies. -public struct Currencies { +public struct Currencies: Codable { - /// A dictionary of all currently loaded currencies. + /// A dictionary of currencies indexed by currency symbol. private var allCurrencies: [String: UnitCurrency] = [:] - /// Returns a currencies instance that can access a currency by name, and a base unit currency (if one is specified). - init(_ currencies: [UnitCurrency] = []) { + /// A read-only array of currencies. + var all: [UnitCurrency] { Array(allCurrencies.values) } + + /// Returns a currencies instance that can access a currency by symbol, and a base unit currency if specified. + public init(_ currencies: [UnitCurrency] = []) { add(currencies) } - - /// Returns the currency with the specified name. - /// - /// - parameter symbol: The shorthand name of the currency. - public func find(_ symbol: String) -> UnitCurrency? { allCurrencies[symbol] } - /// Adds the array of currencies to the collection. + /// Accesses the currency with the specified symbol. + public subscript(symbol: String) -> UnitCurrency? { + return allCurrencies[symbol] + } + + /// Adds the array of currencies to the collection. Updates the default or base unit currency if specified. mutating func add(_ currencies: [UnitCurrency]) { allCurrencies = Dictionary(currencies.map { ($0.symbol, $0) }, uniquingKeysWith: { _, last in last }) @@ -32,18 +35,14 @@ public struct Currencies { UnitCurrency.setBaseUnit(baseCurrency.value) } } - - /// Returns a read-only array of all currencies. - var all: [UnitCurrency] { Array(allCurrencies.values) } -} -extension Currencies: Codable { + // MARK: Codable conformance private enum CodingKeys: String, CodingKey { case currencies } - /// Decodes an array of currencies, setting the default currency if specified. + /// Decodes an array of currencies, updating the default or base unit currency if specified. public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/Sources/RolePlayingCore/Player/Backgrounds.swift b/Sources/RolePlayingCore/Player/Backgrounds.swift index 14657ca..bb3729f 100644 --- a/Sources/RolePlayingCore/Player/Backgrounds.swift +++ b/Sources/RolePlayingCore/Player/Backgrounds.swift @@ -11,25 +11,39 @@ import Foundation /// A collection of backgrounds. public struct Backgrounds: CodableWithConfiguration { - public var backgrounds: [BackgroundTraits] + /// A dictionary of background traits indexed by name. + private var allBackgrounds: [String: BackgroundTraits] = [:] + /// An array of background traits. + public var all: [BackgroundTraits] { Array(allBackgrounds.values) } + + /// Returns an instance of a collection of background traits. public init(_ backgrounds: [BackgroundTraits] = []) { - self.backgrounds = backgrounds + add(backgrounds) + } + + /// Adds the array of background traits to the collection. + mutating func add(_ backgrounds: [BackgroundTraits]) { + allBackgrounds = Dictionary(backgrounds.map { ($0.name, $0) }, uniquingKeysWith: { _, last in last }) } - public func find(_ backgroundName: String) -> BackgroundTraits? { - return backgrounds.first(where: { $0.name == backgroundName }) + /// Accesses a background traits instance by name. + public subscript(backgroundName: String) -> BackgroundTraits? { + return allBackgrounds[backgroundName] } - public var count: Int { backgrounds.count } + /// Returns the number of background traits in the collection. + public var count: Int { allBackgrounds.count } + /// Accesses a background traits instance by index. public subscript(index: Int) -> BackgroundTraits? { - guard index >= 0 && index < backgrounds.count else { return nil } - return backgrounds[index] + guard index >= 0 && index < count else { return nil } + return all[index] } + /// Returns a random background traits instance using the specified random index generator. public func randomElementByIndex(using generator: inout G) -> BackgroundTraits { - return backgrounds.randomElementByIndex(using: &generator)! + return all.randomElementByIndex(using: &generator)! } // MARK: Codable conformance @@ -40,11 +54,13 @@ public struct Backgrounds: CodableWithConfiguration { public init(from decoder: Decoder, configuration: Configuration) throws { let values = try decoder.container(keyedBy: CodingKeys.self) - self.backgrounds = try values.decode([BackgroundTraits].self, forKey: .backgrounds, configuration: configuration) + + let backgrounds = try values.decode([BackgroundTraits].self, forKey: .backgrounds, configuration: configuration) + add(backgrounds) } public func encode(to encoder: Encoder, configuration: Configuration) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(backgrounds, forKey: .backgrounds, configuration: configuration) + try container.encode(all, forKey: .backgrounds, configuration: configuration) } } diff --git a/Sources/RolePlayingCore/Player/Player.swift b/Sources/RolePlayingCore/Player/Player.swift index 9d9e34e..7c4ffd8 100644 --- a/Sources/RolePlayingCore/Player/Player.swift +++ b/Sources/RolePlayingCore/Player/Player.swift @@ -143,7 +143,7 @@ public class Player: CodableWithConfiguration { let skillNames = try values.decode([String].self, forKey: .skillProficiencies) var resolvedSkills: [Skill] = [] for skillName in skillNames { - guard let skill = configuration.skills.find(skillName) else { + guard let skill = configuration.skills[skillName] else { throw missingTypeError("skill", skillName) } resolvedSkills.append(skill) @@ -156,7 +156,7 @@ public class Player: CodableWithConfiguration { let money = try values.decode(Money.self, forKey: .money, configuration: configuration.currencies) // Resolve backgroundTraits from configuration - guard let backgroundTraits = configuration.backgrounds.find(backgroundName) else { + guard let backgroundTraits = configuration.backgrounds[backgroundName] else { throw missingTypeError("background", backgroundName) } diff --git a/Sources/RolePlayingCore/Player/Skills.swift b/Sources/RolePlayingCore/Player/Skills.swift index 88597c2..3c834ef 100644 --- a/Sources/RolePlayingCore/Player/Skills.swift +++ b/Sources/RolePlayingCore/Player/Skills.swift @@ -19,31 +19,59 @@ extension Skill: Hashable { } /// A collection of skills. public struct Skills: Codable { - public var skills = [Skill]() + /// A dictionary of skills indexed by name. + private var allSkills: [String: Skill] = [:] - private enum CodingKeys: String, CodingKey { - case skills + /// An array of skills. + public var all: [Skill] { Array(allSkills.values) } + + /// Returns a skills instance that can access a skill by name. + public init(_ skills: [Skill] = []) { + add(skills) } - public func find(_ skillName: String?) -> Skill? { - return skills.first(where: { $0.name == skillName }) + /// Adds the array of skills to the collection. + mutating func add(_ skills: [Skill]) { + allSkills = Dictionary(skills.map { ($0.name, $0) }, uniquingKeysWith: { _, last in last }) + } + + /// Accesses a skill by name. + public subscript(skillName: String) -> Skill? { + return allSkills[skillName] } - public var count: Int { return skills.count } + /// Returns the number of skills in the collection. + public var count: Int { return allSkills.count } + /// Accesses a skill by index. public subscript(index: Int) -> Skill? { - get { - return skills[index] - } + return all[index] + } + + // MARK: Codable conformance + + private enum CodingKeys: String, CodingKey { + case skills + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let skills = try container.decode([Skill].self, forKey: .skills) + add(skills) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(all, forKey: .skills) } } extension Sequence where Element == String { - /// Returns an array of skills from this array of skill names, using the skills argument. + /// Returns an array of skills from this array of skill names, using the specified skills collection. public func skills(from skills: Skills) throws -> [Skill] { try self.map { skillName in - guard let skill = skills.find(skillName) else { + guard let skill = skills[skillName] else { throw missingTypeError("skill", skillName) } return skill @@ -66,8 +94,10 @@ extension Sequence where Element == Skill { return selected } + /// Returns an array of skill names corresponding to this array of skills. public var skillNames: [String] { self.map(\.name) } + /// Appends an array of skills to this array of skills. public mutating func append(_ other: Self) { let selfSet = Set(self) let otherSet = Set(other) diff --git a/Tests/RolePlayingCoreTests/BackgroundsTests.swift b/Tests/RolePlayingCoreTests/BackgroundsTests.swift index a613575..e4ecb94 100644 --- a/Tests/RolePlayingCoreTests/BackgroundsTests.swift +++ b/Tests/RolePlayingCoreTests/BackgroundsTests.swift @@ -121,23 +121,20 @@ struct BackgroundsTests { #expect(backgrounds.count == 3, "Should have 3 backgrounds") // Then: The find method should locate backgrounds by name - let acolyte = try #require(backgrounds.find("Acolyte")) + let acolyte = try #require(backgrounds["Acolyte"]) #expect(acolyte.name == "Acolyte", "Found background should be Acolyte") #expect(acolyte.feat == "Magic Initiate", "Acolyte feat should match") - let criminal = try #require(backgrounds.find("Criminal")) + let criminal = try #require(backgrounds["Criminal"]) #expect(criminal.skillProficiencies.skillNames == ["Deception", "Stealth"], "Criminal skills should match") // Then: The find method should return nil for non-existent backgrounds - let nonExistent = backgrounds.find("Wizard") + let nonExistent = backgrounds["Wizard"] #expect(nonExistent == nil, "Should not find non-existent background") // Then: Subscript access should work correctly - let firstBackground = try #require(backgrounds[0]) - #expect(firstBackground.name == "Acolyte", "First background should be Acolyte") - - let thirdBackground = try #require(backgrounds[2]) - #expect(thirdBackground.name == "Soldier", "Third background should be Soldier") + _ = try #require(backgrounds[0]) + _ = try #require(backgrounds[2]) // Then: Round-trip encoding should preserve data let encoder = JSONEncoder() @@ -146,7 +143,7 @@ struct BackgroundsTests { let decodedBackgrounds = try decoder.decode(Backgrounds.self, from: encodedData, configuration: configuration) #expect(decodedBackgrounds.count == backgrounds.count, "Count should match after round-trip") - #expect(decodedBackgrounds.find("Criminal")?.name == "Criminal", "Should find Criminal after round-trip") - #expect(decodedBackgrounds[1]?.toolProficiency == backgrounds[1]?.toolProficiency, "Tool proficiency should match after round-trip") + let decodedCriminal = try #require(decodedBackgrounds["Criminal"]) + #expect(decodedCriminal.toolProficiency == backgrounds["Criminal"]?.toolProficiency, "Should find Criminal with matching tool proficiency after round-trip") } } diff --git a/Tests/RolePlayingCoreTests/CurrencyTests.swift b/Tests/RolePlayingCoreTests/CurrencyTests.swift index 599f8f8..c1091a5 100644 --- a/Tests/RolePlayingCoreTests/CurrencyTests.swift +++ b/Tests/RolePlayingCoreTests/CurrencyTests.swift @@ -40,20 +40,20 @@ struct UnitCurrencyTests { @Test("Unit currency calculations") func unitCurrency() async throws { - #expect(UnitCurrency.baseUnit() == currencies.find("gp"), "base unit should be goldPieces") + #expect(UnitCurrency.baseUnit() == currencies["gp"], "base unit should be goldPieces") - let goldPieces = Money(value: 25, unit: currencies.find("gp")!) - let silverPieces = Money(value: 12, unit: currencies.find("sp")!) - let copperPieces = Money(value: 1, unit: currencies.find("cp")!) - let electrumPieces = Money(value: 2, unit: currencies.find("ep")!) - let platinumPieces = Money(value: 2, unit: currencies.find("pp")!) + let goldPieces = Money(value: 25, unit: currencies["gp"]!) + let silverPieces = Money(value: 12, unit: currencies["sp"]!) + let copperPieces = Money(value: 1, unit: currencies["cp"]!) + let electrumPieces = Money(value: 2, unit: currencies["ep"]!) + let platinumPieces = Money(value: 2, unit: currencies["pp"]!) let totalPieces = goldPieces + silverPieces - copperPieces + electrumPieces - platinumPieces // Should be 25 + 1.2 - 0.01 + 1 - 20 #expect(abs(totalPieces.value - 7.19) < 0.0001, "adding coins should equal 7.19") - let totalPiecesInCopper = totalPieces.converted(to: currencies.find("cp")!) + let totalPiecesInCopper = totalPieces.converted(to: currencies["cp"]!) #expect(abs(totalPiecesInCopper.value - 719) < 0.01, "adding coins converted to copper should equal 719") } @@ -72,11 +72,11 @@ struct UnitCurrencyTests { let gpDefault = formatter.string(from: goldPieces) #expect(gpDefault == "13.7 gp", "gold pieces") - let silverPieces = goldPieces.converted(to: currencies.find("sp")!) + let silverPieces = goldPieces.converted(to: currencies["sp"]!) let sp = formatter.string(from: silverPieces) #expect(sp == "137 sp", "silver pieces") - let platinumPieces = goldPieces.converted(to: currencies.find("pp")!) + let platinumPieces = goldPieces.converted(to: currencies["pp"]!) let ppProvided = formatter.string(from: platinumPieces) #expect(ppProvided == "1.37 pp", "platinum pieces") @@ -109,8 +109,8 @@ struct UnitCurrencyTests { let cp = "3.2 cp".parseMoney(currencies) let unwrappedCp = try #require(cp, "coinage as cp should not be nil") #expect(abs(unwrappedCp.value - 3.2) < 0.0001, "coinage as string cp should be 3.2") - #expect(unwrappedCp.unit == currencies.find("cp"), "coinage as string cp should be copper pieces") - #expect(unwrappedCp.unit != currencies.find("pp"), "coinage as string cp should not be platinum pieces") + #expect(unwrappedCp.unit == currencies["cp"], "coinage as string cp should be copper pieces") + #expect(unwrappedCp.unit != currencies["pp"], "coinage as string cp should not be platinum pieces") let invalid = "hello".parseMoney(currencies) #expect(invalid == nil, "coinage as string with hello should be nil") @@ -182,7 +182,7 @@ struct UnitCurrencyTests { } } - let moneyContainer = MoneyContainer(money: Money(value: 48.93, unit: currencies.find("sp")!)) + let moneyContainer = MoneyContainer(money: Money(value: 48.93, unit: currencies["sp"]!)) let encoder = JSONEncoder() let encoded = try encoder.encode(moneyContainer) let deserialized = try JSONSerialization.jsonObject(with: encoded, options: []) as? [String: String]