diff --git a/Sources/DHModels/DifficultyBudget.swift b/Sources/DHModels/DifficultyBudget.swift index 35f2884..26fda18 100644 --- a/Sources/DHModels/DifficultyBudget.swift +++ b/Sources/DHModels/DifficultyBudget.swift @@ -97,6 +97,53 @@ nonisolated public enum DifficultyBudget { return Rating(budget: budget, cost: cost, remaining: budget - cost) } + // MARK: - Tier Utilities + + /// Returns the Daggerheart tier (1–4) for a given character level (1–10). + /// + /// | Level | Tier | + /// |-------|------| + /// | 1 | 1 | + /// | 2–4 | 2 | + /// | 5–7 | 3 | + /// | 8–10 | 4 | + /// + /// Per SRD "Building Balanced Encounters". + public static func tier(forLevel level: Int) -> Int { + switch level { + case ...1: return 1 + case 2...4: return 2 + case 5...7: return 3 + default: return 4 + } + } + + /// Returns the party tier derived from the median character level. + /// + /// The median level is computed, then rounded up (ceiling) before mapping + /// to tier. When the median straddles a tier boundary the party is treated + /// as the higher tier — giving slightly more adversary budget latitude. + /// Empty input returns Tier 1 as a safe default. + /// + /// Examples: + /// - `[1]` → median 1 → T1 + /// - `[2, 3, 4, 5]` → median 3.5 → ceil 4 → T2 + /// - `[4, 5]` → median 4.5 → ceil 5 → T3 + /// - `[7, 8, 9]` → median 8 → T4 + public static func partyTier(levels: [Int]) -> Int { + guard !levels.isEmpty else { return 1 } + let sorted = levels.sorted() + let count = sorted.count + let medianLevel: Double + if count % 2 == 1 { + medianLevel = Double(sorted[count / 2]) + } else { + medianLevel = (Double(sorted[count / 2 - 1]) + Double(sorted[count / 2])) / 2.0 + } + let ceiledLevel = Int(ceil(medianLevel)) + return tier(forLevel: ceiledLevel) + } + // MARK: - Adjustment Suggestions /// Predefined budget adjustments from the SRD. @@ -141,14 +188,23 @@ nonisolated public enum DifficultyBudget { /// Determine which SRD adjustments apply automatically based on the roster. /// - /// Only returns adjustments that can be mechanically detected: - /// - `.multipleSolos` if 2+ Solo types - /// - `.noBigThreats` if no Bruiser, Horde, Leader, or Solo types + /// Mechanically detected adjustments: + /// - `.multipleSolos` — 2 or more Solo types in the roster. + /// - `.noBigThreats` — no Bruiser, Horde, Leader, or Solo types, and the roster is non-empty. + /// - `.lowerTierAdversary` — `partyTier` is non-nil and at least one adversary has a + /// known tier (non-zero entry in `adversaryTiers`) strictly less than `partyTier`. + /// + /// GM-discretionary adjustments (easier/harder fight, boosted damage) are never + /// auto-detected and must be toggled manually. /// - /// GM-discretionary adjustments (easier/harder fight, boosted damage, - /// lower tier) must be toggled manually in the UI. + /// - Parameters: + /// - adversaryTypes: The types of all adversaries in the encounter. + /// - adversaryTiers: Tiers parallel to `adversaryTypes`; use `0` for unknown. + /// - partyTier: The party's derived tier; pass `nil` to skip lower-tier detection. public static func suggestedAdjustments( - adversaryTypes: [AdversaryType] + adversaryTypes: [AdversaryType], + adversaryTiers: [Int] = [], + partyTier: Int? = nil ) -> Set { var result: Set = [] @@ -163,6 +219,15 @@ nonisolated public enum DifficultyBudget { result.insert(.noBigThreats) } + if let pt = partyTier { + let hasLowerTier = zip(adversaryTypes, adversaryTiers).contains { _, tier in + tier > 0 && tier < pt + } + if hasLowerTier { + result.insert(.lowerTierAdversary) + } + } + return result } } diff --git a/Sources/DHModels/EncounterDefinition.swift b/Sources/DHModels/EncounterDefinition.swift index 373139d..0194594 100644 --- a/Sources/DHModels/EncounterDefinition.swift +++ b/Sources/DHModels/EncounterDefinition.swift @@ -31,6 +31,8 @@ import Foundation nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable, Identifiable { public let id: UUID public let name: String + /// The player character's level (1–10). Defaults to `1` when absent in JSON. + public let level: Int public let maxHP: Int public let maxStress: Int public let evasion: Int @@ -41,6 +43,7 @@ nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable, public init( id: UUID = UUID(), name: String, + level: Int = 1, maxHP: Int, maxStress: Int, evasion: Int, @@ -50,6 +53,7 @@ nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable, ) { self.id = id self.name = name + self.level = level self.maxHP = maxHP self.maxStress = maxStress self.evasion = evasion @@ -57,6 +61,19 @@ nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable, self.thresholdSevere = thresholdSevere self.armorSlots = armorSlots } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + level = try container.decodeIfPresent(Int.self, forKey: .level) ?? 1 + maxHP = try container.decode(Int.self, forKey: .maxHP) + maxStress = try container.decode(Int.self, forKey: .maxStress) + evasion = try container.decode(Int.self, forKey: .evasion) + thresholdMajor = try container.decode(Int.self, forKey: .thresholdMajor) + thresholdSevere = try container.decode(Int.self, forKey: .thresholdSevere) + armorSlots = try container.decode(Int.self, forKey: .armorSlots) + } } // MARK: - EncounterDefinition diff --git a/Sources/DHModels/Player.swift b/Sources/DHModels/Player.swift index cba4172..e221149 100644 --- a/Sources/DHModels/Player.swift +++ b/Sources/DHModels/Player.swift @@ -26,6 +26,8 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi public let id: UUID /// The player character's name. public var name: String + /// The player character's level (1–10). + public var level: Int /// Maximum hit points for this character. public var maxHP: Int /// Maximum stress for this character. @@ -44,6 +46,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi /// - Parameters: /// - id: Stable identifier; defaults to a new UUID. /// - name: The player character's name. + /// - level: The character's level (1–10); defaults to `1`. /// - maxHP: Maximum hit points. /// - maxStress: Maximum stress. /// - evasion: The DC for rolls made against this PC. @@ -53,6 +56,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi public init( id: UUID = UUID(), name: String, + level: Int = 1, maxHP: Int, maxStress: Int, evasion: Int, @@ -62,6 +66,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi ) { self.id = id self.name = name + self.level = level self.maxHP = maxHP self.maxStress = maxStress self.evasion = evasion @@ -70,12 +75,26 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi self.armorSlots = armorSlots } + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + level = try container.decodeIfPresent(Int.self, forKey: .level) ?? 1 + maxHP = try container.decode(Int.self, forKey: .maxHP) + maxStress = try container.decode(Int.self, forKey: .maxStress) + evasion = try container.decode(Int.self, forKey: .evasion) + thresholdMajor = try container.decode(Int.self, forKey: .thresholdMajor) + thresholdSevere = try container.decode(Int.self, forKey: .thresholdSevere) + armorSlots = try container.decode(Int.self, forKey: .armorSlots) + } + /// Snapshots this player's current stats into a ``PlayerConfig`` for use /// in an ``EncounterDefinition`` or session creation. public func asConfig() -> PlayerConfig { PlayerConfig( id: id, name: name, + level: level, maxHP: maxHP, maxStress: maxStress, evasion: evasion, diff --git a/Tests/DHModelsTests/ModelTests.swift b/Tests/DHModelsTests/ModelTests.swift index 3586366..730bad1 100644 --- a/Tests/DHModelsTests/ModelTests.swift +++ b/Tests/DHModelsTests/ModelTests.swift @@ -294,17 +294,36 @@ struct EncounterDefinitionTests { @Test func playerConfigCodableRoundTrip() throws { let config = PlayerConfig( - name: "Sera", maxHP: 8, maxStress: 6, + name: "Sera", level: 3, maxHP: 8, maxStress: 6, evasion: 14, thresholdMajor: 10, thresholdSevere: 18, armorSlots: 4 ) let data = try JSONEncoder().encode(config) let decoded = try JSONDecoder().decode(PlayerConfig.self, from: data) #expect(decoded.name == "Sera") + #expect(decoded.level == 3) #expect(decoded.maxHP == 8) #expect(decoded.evasion == 14) #expect(decoded.armorSlots == 4) } + + @Test func playerConfigLevelDefaultsToOneWhenAbsentInJSON() throws { + let json = try #require( + """ + { + "id": "00000000-0000-0000-0000-000000000002", + "name": "Legacy", + "maxHP": 6, + "maxStress": 6, + "evasion": 12, + "thresholdMajor": 8, + "thresholdSevere": 15, + "armorSlots": 3 + } + """.data(using: .utf8)) + let decoded = try JSONDecoder().decode(PlayerConfig.self, from: json) + #expect(decoded.level == 1) + } } // MARK: - DifficultyBudget @@ -440,6 +459,90 @@ struct DifficultyBudgetTests { #expect(DifficultyBudget.Adjustment.noBigThreats.pointValue == 1) #expect(DifficultyBudget.Adjustment.harderFight.pointValue == 2) } + + // MARK: Tier Utilities + + @Test(arguments: zip(1...10, [1, 2, 2, 2, 3, 3, 3, 4, 4, 4])) + func tierForLevel(level: Int, expectedTier: Int) { + #expect(DifficultyBudget.tier(forLevel: level) == expectedTier) + } + + @Test func partyTierSinglePlayer() { + #expect(DifficultyBudget.partyTier(levels: [1]) == 1) + #expect(DifficultyBudget.partyTier(levels: [5]) == 3) + #expect(DifficultyBudget.partyTier(levels: [9]) == 4) + } + + @Test func partyTierEmptyIsT1() { + #expect(DifficultyBudget.partyTier(levels: []) == 1) + } + + @Test func partyTierMedianBoundaryRoundsUp() { + // [2,3,4,5] → median 3.5 → ceil 4 → T2 + #expect(DifficultyBudget.partyTier(levels: [2, 3, 4, 5]) == 2) + // [4,5] → median 4.5 → ceil 5 → T3 + #expect(DifficultyBudget.partyTier(levels: [4, 5]) == 3) + } + + @Test func partyTierOddCount() { + // [7,8,9] → median 8 → T4 + #expect(DifficultyBudget.partyTier(levels: [7, 8, 9]) == 4) + } + + // MARK: lowerTierAdversary auto-detection + + @Test func lowerTierAdversaryDetectedWhenPresent() { + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [.standard, .minion], + adversaryTiers: [3, 1], + partyTier: 3 + ) + #expect(adjustments.contains(.lowerTierAdversary)) + } + + @Test func lowerTierAdversaryNotDetectedWhenAllTiersMatch() { + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [.standard, .minion], + adversaryTiers: [3, 3], + partyTier: 3 + ) + #expect(!adjustments.contains(.lowerTierAdversary)) + } + + @Test func lowerTierAdversaryNotDetectedWithoutPartyTier() { + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [.standard], + adversaryTiers: [1], + partyTier: nil + ) + #expect(!adjustments.contains(.lowerTierAdversary)) + } + + @Test func lowerTierAdversaryIgnoresUnknownTierZero() { + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [.standard], + adversaryTiers: [0], + partyTier: 3 + ) + #expect(!adjustments.contains(.lowerTierAdversary)) + } + + @Test func lowerTierAdversaryNotDetectedWithEmptyRoster() { + // adversaryTiers has a lower-tier entry but adversaryTypes is empty — + // zip silences the dangling tier, so no adjustment should fire. + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [], + adversaryTiers: [1], + partyTier: 3 + ) + #expect(!adjustments.contains(.lowerTierAdversary)) + } + + @Test func suggestedAdjustmentsBackwardCompatible() { + // Calling with no tier params must still detect multipleSolos / noBigThreats. + let adjustments = DifficultyBudget.suggestedAdjustments(adversaryTypes: [.solo, .solo]) + #expect(adjustments.contains(.multipleSolos)) + } } // MARK: - Player @@ -474,15 +577,52 @@ struct PlayerTests { #expect(decoded.armorSlots == 4) } + @Test func playerDefaultLevelIsOne() { + let player = Player( + name: "Aldric", maxHP: 6, maxStress: 6, + evasion: 12, thresholdMajor: 8, thresholdSevere: 15, armorSlots: 3 + ) + #expect(player.level == 1) + } + + @Test func playerLevelRoundTrip() throws { + let player = Player( + name: "Sera", level: 5, maxHP: 8, maxStress: 6, + evasion: 14, thresholdMajor: 10, thresholdSevere: 18, armorSlots: 4 + ) + let data = try JSONEncoder().encode(player) + let decoded = try JSONDecoder().decode(Player.self, from: data) + #expect(decoded.level == 5) + } + + @Test func playerLevelDefaultsToOneWhenAbsentInJSON() throws { + let json = try #require( + """ + { + "id": "00000000-0000-0000-0000-000000000001", + "name": "Legacy", + "maxHP": 6, + "maxStress": 6, + "evasion": 12, + "thresholdMajor": 8, + "thresholdSevere": 15, + "armorSlots": 3 + } + """.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Player.self, from: json) + #expect(decoded.level == 1) + } + @Test func asConfigPreservesAllFields() { let player = Player( - name: "Torven", maxHP: 10, maxStress: 5, + name: "Torven", level: 4, maxHP: 10, maxStress: 5, evasion: 11, thresholdMajor: 7, thresholdSevere: 14, armorSlots: 2 ) let config = player.asConfig() #expect(config.id == player.id) #expect(config.name == player.name) + #expect(config.level == player.level) #expect(config.maxHP == player.maxHP) #expect(config.maxStress == player.maxStress) #expect(config.evasion == player.evasion)