Skip to content

Commit c616e73

Browse files
authored
Add level to Player/PlayerConfig; tier utilities and lowerTierAdversary auto-detection (#21) (#22)
* Add level to Player/PlayerConfig; add tier utilities and auto-detect lowerTierAdversary - Add `level: Int` (default 1) to `Player` and `PlayerConfig`, with custom `init(from:)` so existing JSON missing the field decodes to 1. - Forward `level` through `Player.asConfig()`. - Add `DifficultyBudget.tier(forLevel:)` per SRD tier mapping (L1→T1, L2–4→T2, L5–7→T3, L8–10→T4). - Add `DifficultyBudget.partyTier(levels:)` using median level, rounded up at tier boundaries; empty input returns T1. - Extend `suggestedAdjustments` with `adversaryTiers:[Int]` and `partyTier:Int?` params; auto-inserts `.lowerTierAdversary` when at least one adversary has a known tier strictly below the party tier. - All existing call sites remain source-compatible (new params are defaulted to `[]` / `nil`). Resolves #21. * Fix review issues: zip parallel arrays, #require over force-unwrap, parameterized tier tests
1 parent 0a19d5d commit c616e73

4 files changed

Lines changed: 249 additions & 8 deletions

File tree

Sources/DHModels/DifficultyBudget.swift

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,53 @@ nonisolated public enum DifficultyBudget {
9797
return Rating(budget: budget, cost: cost, remaining: budget - cost)
9898
}
9999

100+
// MARK: - Tier Utilities
101+
102+
/// Returns the Daggerheart tier (1–4) for a given character level (1–10).
103+
///
104+
/// | Level | Tier |
105+
/// |-------|------|
106+
/// | 1 | 1 |
107+
/// | 2–4 | 2 |
108+
/// | 5–7 | 3 |
109+
/// | 8–10 | 4 |
110+
///
111+
/// Per SRD "Building Balanced Encounters".
112+
public static func tier(forLevel level: Int) -> Int {
113+
switch level {
114+
case ...1: return 1
115+
case 2...4: return 2
116+
case 5...7: return 3
117+
default: return 4
118+
}
119+
}
120+
121+
/// Returns the party tier derived from the median character level.
122+
///
123+
/// The median level is computed, then rounded up (ceiling) before mapping
124+
/// to tier. When the median straddles a tier boundary the party is treated
125+
/// as the higher tier — giving slightly more adversary budget latitude.
126+
/// Empty input returns Tier 1 as a safe default.
127+
///
128+
/// Examples:
129+
/// - `[1]` → median 1 → T1
130+
/// - `[2, 3, 4, 5]` → median 3.5 → ceil 4 → T2
131+
/// - `[4, 5]` → median 4.5 → ceil 5 → T3
132+
/// - `[7, 8, 9]` → median 8 → T4
133+
public static func partyTier(levels: [Int]) -> Int {
134+
guard !levels.isEmpty else { return 1 }
135+
let sorted = levels.sorted()
136+
let count = sorted.count
137+
let medianLevel: Double
138+
if count % 2 == 1 {
139+
medianLevel = Double(sorted[count / 2])
140+
} else {
141+
medianLevel = (Double(sorted[count / 2 - 1]) + Double(sorted[count / 2])) / 2.0
142+
}
143+
let ceiledLevel = Int(ceil(medianLevel))
144+
return tier(forLevel: ceiledLevel)
145+
}
146+
100147
// MARK: - Adjustment Suggestions
101148

102149
/// Predefined budget adjustments from the SRD.
@@ -141,14 +188,23 @@ nonisolated public enum DifficultyBudget {
141188

142189
/// Determine which SRD adjustments apply automatically based on the roster.
143190
///
144-
/// Only returns adjustments that can be mechanically detected:
145-
/// - `.multipleSolos` if 2+ Solo types
146-
/// - `.noBigThreats` if no Bruiser, Horde, Leader, or Solo types
191+
/// Mechanically detected adjustments:
192+
/// - `.multipleSolos` — 2 or more Solo types in the roster.
193+
/// - `.noBigThreats` — no Bruiser, Horde, Leader, or Solo types, and the roster is non-empty.
194+
/// - `.lowerTierAdversary` — `partyTier` is non-nil and at least one adversary has a
195+
/// known tier (non-zero entry in `adversaryTiers`) strictly less than `partyTier`.
196+
///
197+
/// GM-discretionary adjustments (easier/harder fight, boosted damage) are never
198+
/// auto-detected and must be toggled manually.
147199
///
148-
/// GM-discretionary adjustments (easier/harder fight, boosted damage,
149-
/// lower tier) must be toggled manually in the UI.
200+
/// - Parameters:
201+
/// - adversaryTypes: The types of all adversaries in the encounter.
202+
/// - adversaryTiers: Tiers parallel to `adversaryTypes`; use `0` for unknown.
203+
/// - partyTier: The party's derived tier; pass `nil` to skip lower-tier detection.
150204
public static func suggestedAdjustments(
151-
adversaryTypes: [AdversaryType]
205+
adversaryTypes: [AdversaryType],
206+
adversaryTiers: [Int] = [],
207+
partyTier: Int? = nil
152208
) -> Set<Adjustment> {
153209
var result: Set<Adjustment> = []
154210

@@ -163,6 +219,15 @@ nonisolated public enum DifficultyBudget {
163219
result.insert(.noBigThreats)
164220
}
165221

222+
if let pt = partyTier {
223+
let hasLowerTier = zip(adversaryTypes, adversaryTiers).contains { _, tier in
224+
tier > 0 && tier < pt
225+
}
226+
if hasLowerTier {
227+
result.insert(.lowerTierAdversary)
228+
}
229+
}
230+
166231
return result
167232
}
168233
}

Sources/DHModels/EncounterDefinition.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import Foundation
3131
nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable, Identifiable {
3232
public let id: UUID
3333
public let name: String
34+
/// The player character's level (1–10). Defaults to `1` when absent in JSON.
35+
public let level: Int
3436
public let maxHP: Int
3537
public let maxStress: Int
3638
public let evasion: Int
@@ -41,6 +43,7 @@ nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable,
4143
public init(
4244
id: UUID = UUID(),
4345
name: String,
46+
level: Int = 1,
4447
maxHP: Int,
4548
maxStress: Int,
4649
evasion: Int,
@@ -50,13 +53,27 @@ nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable,
5053
) {
5154
self.id = id
5255
self.name = name
56+
self.level = level
5357
self.maxHP = maxHP
5458
self.maxStress = maxStress
5559
self.evasion = evasion
5660
self.thresholdMajor = thresholdMajor
5761
self.thresholdSevere = thresholdSevere
5862
self.armorSlots = armorSlots
5963
}
64+
65+
public init(from decoder: any Decoder) throws {
66+
let container = try decoder.container(keyedBy: CodingKeys.self)
67+
id = try container.decode(UUID.self, forKey: .id)
68+
name = try container.decode(String.self, forKey: .name)
69+
level = try container.decodeIfPresent(Int.self, forKey: .level) ?? 1
70+
maxHP = try container.decode(Int.self, forKey: .maxHP)
71+
maxStress = try container.decode(Int.self, forKey: .maxStress)
72+
evasion = try container.decode(Int.self, forKey: .evasion)
73+
thresholdMajor = try container.decode(Int.self, forKey: .thresholdMajor)
74+
thresholdSevere = try container.decode(Int.self, forKey: .thresholdSevere)
75+
armorSlots = try container.decode(Int.self, forKey: .armorSlots)
76+
}
6077
}
6178

6279
// MARK: - EncounterDefinition

Sources/DHModels/Player.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
2626
public let id: UUID
2727
/// The player character's name.
2828
public var name: String
29+
/// The player character's level (1–10).
30+
public var level: Int
2931
/// Maximum hit points for this character.
3032
public var maxHP: Int
3133
/// Maximum stress for this character.
@@ -44,6 +46,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
4446
/// - Parameters:
4547
/// - id: Stable identifier; defaults to a new UUID.
4648
/// - name: The player character's name.
49+
/// - level: The character's level (1–10); defaults to `1`.
4750
/// - maxHP: Maximum hit points.
4851
/// - maxStress: Maximum stress.
4952
/// - evasion: The DC for rolls made against this PC.
@@ -53,6 +56,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
5356
public init(
5457
id: UUID = UUID(),
5558
name: String,
59+
level: Int = 1,
5660
maxHP: Int,
5761
maxStress: Int,
5862
evasion: Int,
@@ -62,6 +66,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
6266
) {
6367
self.id = id
6468
self.name = name
69+
self.level = level
6570
self.maxHP = maxHP
6671
self.maxStress = maxStress
6772
self.evasion = evasion
@@ -70,12 +75,26 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
7075
self.armorSlots = armorSlots
7176
}
7277

78+
public init(from decoder: any Decoder) throws {
79+
let container = try decoder.container(keyedBy: CodingKeys.self)
80+
id = try container.decode(UUID.self, forKey: .id)
81+
name = try container.decode(String.self, forKey: .name)
82+
level = try container.decodeIfPresent(Int.self, forKey: .level) ?? 1
83+
maxHP = try container.decode(Int.self, forKey: .maxHP)
84+
maxStress = try container.decode(Int.self, forKey: .maxStress)
85+
evasion = try container.decode(Int.self, forKey: .evasion)
86+
thresholdMajor = try container.decode(Int.self, forKey: .thresholdMajor)
87+
thresholdSevere = try container.decode(Int.self, forKey: .thresholdSevere)
88+
armorSlots = try container.decode(Int.self, forKey: .armorSlots)
89+
}
90+
7391
/// Snapshots this player's current stats into a ``PlayerConfig`` for use
7492
/// in an ``EncounterDefinition`` or session creation.
7593
public func asConfig() -> PlayerConfig {
7694
PlayerConfig(
7795
id: id,
7896
name: name,
97+
level: level,
7998
maxHP: maxHP,
8099
maxStress: maxStress,
81100
evasion: evasion,

Tests/DHModelsTests/ModelTests.swift

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,17 +294,36 @@ struct EncounterDefinitionTests {
294294

295295
@Test func playerConfigCodableRoundTrip() throws {
296296
let config = PlayerConfig(
297-
name: "Sera", maxHP: 8, maxStress: 6,
297+
name: "Sera", level: 3, maxHP: 8, maxStress: 6,
298298
evasion: 14, thresholdMajor: 10, thresholdSevere: 18, armorSlots: 4
299299
)
300300
let data = try JSONEncoder().encode(config)
301301
let decoded = try JSONDecoder().decode(PlayerConfig.self, from: data)
302302

303303
#expect(decoded.name == "Sera")
304+
#expect(decoded.level == 3)
304305
#expect(decoded.maxHP == 8)
305306
#expect(decoded.evasion == 14)
306307
#expect(decoded.armorSlots == 4)
307308
}
309+
310+
@Test func playerConfigLevelDefaultsToOneWhenAbsentInJSON() throws {
311+
let json = try #require(
312+
"""
313+
{
314+
"id": "00000000-0000-0000-0000-000000000002",
315+
"name": "Legacy",
316+
"maxHP": 6,
317+
"maxStress": 6,
318+
"evasion": 12,
319+
"thresholdMajor": 8,
320+
"thresholdSevere": 15,
321+
"armorSlots": 3
322+
}
323+
""".data(using: .utf8))
324+
let decoded = try JSONDecoder().decode(PlayerConfig.self, from: json)
325+
#expect(decoded.level == 1)
326+
}
308327
}
309328

310329
// MARK: - DifficultyBudget
@@ -440,6 +459,90 @@ struct DifficultyBudgetTests {
440459
#expect(DifficultyBudget.Adjustment.noBigThreats.pointValue == 1)
441460
#expect(DifficultyBudget.Adjustment.harderFight.pointValue == 2)
442461
}
462+
463+
// MARK: Tier Utilities
464+
465+
@Test(arguments: zip(1...10, [1, 2, 2, 2, 3, 3, 3, 4, 4, 4]))
466+
func tierForLevel(level: Int, expectedTier: Int) {
467+
#expect(DifficultyBudget.tier(forLevel: level) == expectedTier)
468+
}
469+
470+
@Test func partyTierSinglePlayer() {
471+
#expect(DifficultyBudget.partyTier(levels: [1]) == 1)
472+
#expect(DifficultyBudget.partyTier(levels: [5]) == 3)
473+
#expect(DifficultyBudget.partyTier(levels: [9]) == 4)
474+
}
475+
476+
@Test func partyTierEmptyIsT1() {
477+
#expect(DifficultyBudget.partyTier(levels: []) == 1)
478+
}
479+
480+
@Test func partyTierMedianBoundaryRoundsUp() {
481+
// [2,3,4,5] → median 3.5 → ceil 4 → T2
482+
#expect(DifficultyBudget.partyTier(levels: [2, 3, 4, 5]) == 2)
483+
// [4,5] → median 4.5 → ceil 5 → T3
484+
#expect(DifficultyBudget.partyTier(levels: [4, 5]) == 3)
485+
}
486+
487+
@Test func partyTierOddCount() {
488+
// [7,8,9] → median 8 → T4
489+
#expect(DifficultyBudget.partyTier(levels: [7, 8, 9]) == 4)
490+
}
491+
492+
// MARK: lowerTierAdversary auto-detection
493+
494+
@Test func lowerTierAdversaryDetectedWhenPresent() {
495+
let adjustments = DifficultyBudget.suggestedAdjustments(
496+
adversaryTypes: [.standard, .minion],
497+
adversaryTiers: [3, 1],
498+
partyTier: 3
499+
)
500+
#expect(adjustments.contains(.lowerTierAdversary))
501+
}
502+
503+
@Test func lowerTierAdversaryNotDetectedWhenAllTiersMatch() {
504+
let adjustments = DifficultyBudget.suggestedAdjustments(
505+
adversaryTypes: [.standard, .minion],
506+
adversaryTiers: [3, 3],
507+
partyTier: 3
508+
)
509+
#expect(!adjustments.contains(.lowerTierAdversary))
510+
}
511+
512+
@Test func lowerTierAdversaryNotDetectedWithoutPartyTier() {
513+
let adjustments = DifficultyBudget.suggestedAdjustments(
514+
adversaryTypes: [.standard],
515+
adversaryTiers: [1],
516+
partyTier: nil
517+
)
518+
#expect(!adjustments.contains(.lowerTierAdversary))
519+
}
520+
521+
@Test func lowerTierAdversaryIgnoresUnknownTierZero() {
522+
let adjustments = DifficultyBudget.suggestedAdjustments(
523+
adversaryTypes: [.standard],
524+
adversaryTiers: [0],
525+
partyTier: 3
526+
)
527+
#expect(!adjustments.contains(.lowerTierAdversary))
528+
}
529+
530+
@Test func lowerTierAdversaryNotDetectedWithEmptyRoster() {
531+
// adversaryTiers has a lower-tier entry but adversaryTypes is empty —
532+
// zip silences the dangling tier, so no adjustment should fire.
533+
let adjustments = DifficultyBudget.suggestedAdjustments(
534+
adversaryTypes: [],
535+
adversaryTiers: [1],
536+
partyTier: 3
537+
)
538+
#expect(!adjustments.contains(.lowerTierAdversary))
539+
}
540+
541+
@Test func suggestedAdjustmentsBackwardCompatible() {
542+
// Calling with no tier params must still detect multipleSolos / noBigThreats.
543+
let adjustments = DifficultyBudget.suggestedAdjustments(adversaryTypes: [.solo, .solo])
544+
#expect(adjustments.contains(.multipleSolos))
545+
}
443546
}
444547

445548
// MARK: - Player
@@ -474,15 +577,52 @@ struct PlayerTests {
474577
#expect(decoded.armorSlots == 4)
475578
}
476579

580+
@Test func playerDefaultLevelIsOne() {
581+
let player = Player(
582+
name: "Aldric", maxHP: 6, maxStress: 6,
583+
evasion: 12, thresholdMajor: 8, thresholdSevere: 15, armorSlots: 3
584+
)
585+
#expect(player.level == 1)
586+
}
587+
588+
@Test func playerLevelRoundTrip() throws {
589+
let player = Player(
590+
name: "Sera", level: 5, maxHP: 8, maxStress: 6,
591+
evasion: 14, thresholdMajor: 10, thresholdSevere: 18, armorSlots: 4
592+
)
593+
let data = try JSONEncoder().encode(player)
594+
let decoded = try JSONDecoder().decode(Player.self, from: data)
595+
#expect(decoded.level == 5)
596+
}
597+
598+
@Test func playerLevelDefaultsToOneWhenAbsentInJSON() throws {
599+
let json = try #require(
600+
"""
601+
{
602+
"id": "00000000-0000-0000-0000-000000000001",
603+
"name": "Legacy",
604+
"maxHP": 6,
605+
"maxStress": 6,
606+
"evasion": 12,
607+
"thresholdMajor": 8,
608+
"thresholdSevere": 15,
609+
"armorSlots": 3
610+
}
611+
""".data(using: .utf8))
612+
let decoded = try JSONDecoder().decode(Player.self, from: json)
613+
#expect(decoded.level == 1)
614+
}
615+
477616
@Test func asConfigPreservesAllFields() {
478617
let player = Player(
479-
name: "Torven", maxHP: 10, maxStress: 5,
618+
name: "Torven", level: 4, maxHP: 10, maxStress: 5,
480619
evasion: 11, thresholdMajor: 7, thresholdSevere: 14, armorSlots: 2
481620
)
482621
let config = player.asConfig()
483622

484623
#expect(config.id == player.id)
485624
#expect(config.name == player.name)
625+
#expect(config.level == player.level)
486626
#expect(config.maxHP == player.maxHP)
487627
#expect(config.maxStress == player.maxStress)
488628
#expect(config.evasion == player.evasion)

0 commit comments

Comments
 (0)