diff --git a/Sources/DHKit/AdversaryState.swift b/Sources/DHKit/AdversaryState.swift index a1362d7..726f200 100644 --- a/Sources/DHKit/AdversaryState.swift +++ b/Sources/DHKit/AdversaryState.swift @@ -24,7 +24,8 @@ import Foundation /// /// All properties are immutable. Mutations are performed by ``EncounterSession``, /// which replaces values wholesale (copy-with-update pattern). -nonisolated public struct AdversaryState: CombatParticipant, Sendable, Equatable, Hashable { +nonisolated public struct AdversaryState: CombatParticipant, Codable, Sendable, Equatable, Hashable +{ public let id: UUID /// The slug that identifies this adversary in the ``Compendium``. public let adversaryID: String diff --git a/Sources/DHKit/EncounterSession.swift b/Sources/DHKit/EncounterSession.swift index 3e81cb3..344a206 100644 --- a/Sources/DHKit/EncounterSession.swift +++ b/Sources/DHKit/EncounterSession.swift @@ -60,6 +60,14 @@ public final class EncounterSession: Identifiable, Hashable { public let id: UUID public var name: String + /// The ID of the ``EncounterDefinition`` this session was created from. + /// `nil` for sessions created directly (tests, blank sessions). + public let definitionID: UUID? + + /// The modification date of the source definition at the time this session was created. + /// Used by the registry to detect stale sessions. `nil` if not definition-backed. + public let definitionSnapshotDate: Date? + // MARK: Participants (private backing stores) private var _adversarySlots: [AdversaryState] private var _playerSlots: [PlayerState] @@ -102,8 +110,11 @@ public final class EncounterSession: Identifiable, Hashable { environmentSlots: [EnvironmentState] = [], fearPool: Int = 0, hopePool: Int = 0, + spotlightedSlotID: UUID? = nil, spotlightCount: Int = 0, - gmNotes: String = "" + gmNotes: String = "", + definitionID: UUID? = nil, + definitionSnapshotDate: Date? = nil ) { self.id = id self.name = name @@ -112,9 +123,11 @@ public final class EncounterSession: Identifiable, Hashable { self._environmentSlots = environmentSlots self.fearPool = fearPool self.hopePool = hopePool - self.spotlightedSlotID = nil + self.spotlightedSlotID = spotlightedSlotID self.spotlightCount = spotlightCount self.gmNotes = gmNotes + self.definitionID = definitionID + self.definitionSnapshotDate = definitionSnapshotDate } // MARK: - Roster Management @@ -347,9 +360,20 @@ public final class EncounterSession: Identifiable, Hashable { from definition: EncounterDefinition, using compendium: Compendium ) -> EncounterSession { + // Count occurrences of each adversary ID so duplicates can be named. + var counts: [String: Int] = [:] + for id in definition.adversaryIDs { counts[id, default: 0] += 1 } + + // Assign sequential custom names only when the same adversary appears more than once. + var counters: [String: Int] = [:] let adversarySlots: [AdversaryState] = definition.adversaryIDs.compactMap { id in guard let adversary = compendium.adversary(id: id) else { return nil } - return AdversaryState(from: adversary) + guard (counts[id] ?? 0) > 1 else { + return AdversaryState(from: adversary) + } + let n = (counters[id] ?? 0) + 1 + counters[id] = n + return AdversaryState(from: adversary, customName: "\(adversary.name) \(n)") } let environmentSlots: [EnvironmentState] = definition.environmentIDs.compactMap { id in @@ -374,7 +398,61 @@ public final class EncounterSession: Identifiable, Hashable { adversarySlots: adversarySlots, playerSlots: playerSlots, environmentSlots: environmentSlots, - gmNotes: definition.gmNotes + gmNotes: definition.gmNotes, + definitionID: definition.id, + definitionSnapshotDate: definition.modifiedAt + ) + } +} + +// MARK: - Codable + +extension EncounterSession: @MainActor Codable { + + enum CodingKeys: String, CodingKey { + case id, name + case adversarySlots, playerSlots, environmentSlots + case fearPool, hopePool, spotlightedSlotID, spotlightCount, gmNotes + case definitionID, definitionSnapshotDate + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(_adversarySlots, forKey: .adversarySlots) + try c.encode(_playerSlots, forKey: .playerSlots) + try c.encode(_environmentSlots, forKey: .environmentSlots) + try c.encode(fearPool, forKey: .fearPool) + try c.encode(hopePool, forKey: .hopePool) + try c.encodeIfPresent(spotlightedSlotID, forKey: .spotlightedSlotID) + try c.encode(spotlightCount, forKey: .spotlightCount) + try c.encode(gmNotes, forKey: .gmNotes) + try c.encodeIfPresent(definitionID, forKey: .definitionID) + try c.encodeIfPresent(definitionSnapshotDate, forKey: .definitionSnapshotDate) + } + + public convenience init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let id = try c.decode(UUID.self, forKey: .id) + let name = try c.decode(String.self, forKey: .name) + let adversarySlots = try c.decode([AdversaryState].self, forKey: .adversarySlots) + let playerSlots = try c.decode([PlayerState].self, forKey: .playerSlots) + let environmentSlots = try c.decode([EnvironmentState].self, forKey: .environmentSlots) + let fearPool = try c.decode(Int.self, forKey: .fearPool) + let hopePool = try c.decode(Int.self, forKey: .hopePool) + let spotlightedSlotID = try c.decodeIfPresent(UUID.self, forKey: .spotlightedSlotID) + let spotlightCount = try c.decode(Int.self, forKey: .spotlightCount) + let gmNotes = try c.decode(String.self, forKey: .gmNotes) + let definitionID = try c.decodeIfPresent(UUID.self, forKey: .definitionID) + let definitionSnapshotDate = try c.decodeIfPresent(Date.self, forKey: .definitionSnapshotDate) + self.init( + id: id, name: name, + adversarySlots: adversarySlots, playerSlots: playerSlots, + environmentSlots: environmentSlots, + fearPool: fearPool, hopePool: hopePool, + spotlightedSlotID: spotlightedSlotID, spotlightCount: spotlightCount, gmNotes: gmNotes, + definitionID: definitionID, definitionSnapshotDate: definitionSnapshotDate ) } } diff --git a/Sources/DHKit/EnvironmentState.swift b/Sources/DHKit/EnvironmentState.swift index d0ce086..04b4206 100644 --- a/Sources/DHKit/EnvironmentState.swift +++ b/Sources/DHKit/EnvironmentState.swift @@ -18,7 +18,9 @@ import Foundation /// /// All properties are immutable. Mutations are performed by ``EncounterSession``, /// which replaces values wholesale (copy-with-update pattern). -nonisolated public struct EnvironmentState: EncounterParticipant, Sendable, Equatable, Hashable { +nonisolated public struct EnvironmentState: EncounterParticipant, Codable, Sendable, Equatable, + Hashable +{ public let id: UUID /// The slug identifying this environment in the ``Compendium``. public let environmentID: String diff --git a/Sources/DHKit/PlayerState.swift b/Sources/DHKit/PlayerState.swift index 1b39805..7dcf55c 100644 --- a/Sources/DHKit/PlayerState.swift +++ b/Sources/DHKit/PlayerState.swift @@ -24,7 +24,7 @@ import Foundation /// /// Tracks combat-relevant PC stats the GM needs to resolve hits and /// track health during play. The full character sheet remains with the player. -nonisolated public struct PlayerState: CombatParticipant, Sendable, Equatable, Hashable { +nonisolated public struct PlayerState: CombatParticipant, Codable, Sendable, Equatable, Hashable { public let id: UUID public let name: String diff --git a/Tests/DHKitTests/EncounterSessionTests.swift b/Tests/DHKitTests/EncounterSessionTests.swift index d482f3e..a41a75b 100644 --- a/Tests/DHKitTests/EncounterSessionTests.swift +++ b/Tests/DHKitTests/EncounterSessionTests.swift @@ -470,6 +470,140 @@ import Testing #expect(session.spotlightedSlotID == nil) #expect(session.spotlightCount == 0) } + + // MARK: Definition provenance + + @Test func makeFromDefinitionSetsDefinitionProvenance() { + let compendium = makeCompendium() + let def = EncounterDefinition(name: "Provenance Test") + + let session = EncounterSession.make(from: def, using: compendium) + + #expect(session.definitionID == def.id) + #expect(session.definitionSnapshotDate == def.modifiedAt) + } + + // MARK: Adversary auto-numbering (#17) + + @Test func singleAdversaryHasNoCustomName() { + let compendium = makeCompendium() + var def = EncounterDefinition(name: "Test") + def.adversaryIDs = ["ironguard-soldier"] + + let session = EncounterSession.make(from: def, using: compendium) + + #expect(session.adversarySlots[0].customName == nil) + } + + @Test func duplicateAdversariesGetNumberedNames() { + let compendium = makeCompendium() + var def = EncounterDefinition(name: "Test") + def.adversaryIDs = ["ironguard-soldier", "ironguard-soldier"] + + let session = EncounterSession.make(from: def, using: compendium) + + #expect(session.adversarySlots[0].customName == "Ironguard Soldier 1") + #expect(session.adversarySlots[1].customName == "Ironguard Soldier 2") + } + + @Test func triplicateAdversariesGetNumberedNames() { + let comp = Compendium() + comp.addAdversary( + Adversary( + id: "goblin", name: "Goblin", + tier: 1, role: .minion, flavorText: "A small menace.", + difficulty: 8, thresholdMajor: 3, thresholdSevere: 6, + hp: 3, stress: 2, attackModifier: "+1", attackName: "Shiv", + attackRange: .veryClose, damage: "1d6 phy" + )) + var def = EncounterDefinition(name: "Test") + def.adversaryIDs = ["goblin", "goblin", "goblin"] + + let session = EncounterSession.make(from: def, using: comp) + + #expect(session.adversarySlots[0].customName == "Goblin 1") + #expect(session.adversarySlots[1].customName == "Goblin 2") + #expect(session.adversarySlots[2].customName == "Goblin 3") + } + + @Test func mixedAdversariesOnlyDuplicatesAreNumbered() { + let comp = Compendium() + comp.addAdversary( + Adversary( + id: "goblin", name: "Goblin", + tier: 1, role: .minion, flavorText: "A small menace.", + difficulty: 8, thresholdMajor: 3, thresholdSevere: 6, + hp: 3, stress: 2, attackModifier: "+1", attackName: "Shiv", + attackRange: .veryClose, damage: "1d6 phy" + )) + comp.addAdversary( + Adversary( + id: "ironguard-soldier", name: "Ironguard Soldier", + tier: 1, role: .bruiser, flavorText: "A disciplined mercenary.", + difficulty: 11, thresholdMajor: 5, thresholdSevere: 10, + hp: 6, stress: 3, attackModifier: "+3", attackName: "Longsword", + attackRange: .veryClose, damage: "1d10+3 phy" + )) + var def = EncounterDefinition(name: "Test") + def.adversaryIDs = ["goblin", "ironguard-soldier", "ironguard-soldier"] + + let session = EncounterSession.make(from: def, using: comp) + + // goblin appears once — no custom name + #expect(session.adversarySlots[0].customName == nil) + // soldiers appear twice — numbered + #expect(session.adversarySlots[1].customName == "Ironguard Soldier 1") + #expect(session.adversarySlots[2].customName == "Ironguard Soldier 2") + } +} + +// MARK: - EncounterSession Codable + +@MainActor struct EncounterSessionCodableTests { + + @Test func sessionCodableRoundTrip() throws { + let adversarySlot = AdversaryState(adversaryID: "ironguard-soldier", maxHP: 6, maxStress: 3) + let session = EncounterSession( + name: "Bandit Ambush", + adversarySlots: [adversarySlot], + playerSlots: [ + PlayerState( + name: "Aldric", maxHP: 6, maxStress: 6, + evasion: 12, thresholdMajor: 8, thresholdSevere: 15, armorSlots: 3 + ) + ], + fearPool: 2, + hopePool: 3, + spotlightedSlotID: adversarySlot.id, + spotlightCount: 1, + gmNotes: "Remember the secret door.", + definitionID: UUID(), + definitionSnapshotDate: Date(timeIntervalSince1970: 1_000_000) + ) + + let data = try JSONEncoder().encode(session) + let decoded = try JSONDecoder().decode(EncounterSession.self, from: data) + + #expect(decoded.id == session.id) + #expect(decoded.name == "Bandit Ambush") + #expect(decoded.adversarySlots.count == 1) + #expect(decoded.adversarySlots[0].adversaryID == "ironguard-soldier") + #expect(decoded.playerSlots.count == 1) + #expect(decoded.playerSlots[0].name == "Aldric") + #expect(decoded.fearPool == 2) + #expect(decoded.hopePool == 3) + #expect(decoded.spotlightedSlotID == adversarySlot.id) + #expect(decoded.spotlightCount == 1) + #expect(decoded.gmNotes == "Remember the secret door.") + #expect(decoded.definitionID == session.definitionID) + #expect(decoded.definitionSnapshotDate == session.definitionSnapshotDate) + } + + @Test func directInitHasNilDefinitionFields() { + let session = EncounterSession(name: "Ad-hoc Encounter") + #expect(session.definitionID == nil) + #expect(session.definitionSnapshotDate == nil) + } } // MARK: - AdversaryState stat snapshot diff --git a/Tests/DHKitTests/StateTests.swift b/Tests/DHKitTests/StateTests.swift index b624357..d2e2414 100644 --- a/Tests/DHKitTests/StateTests.swift +++ b/Tests/DHKitTests/StateTests.swift @@ -48,6 +48,25 @@ struct PlayerStateTests { ) #expect(slot1 == slot2) } + + @Test func playerStateCodableRoundTrip() throws { + let slot = PlayerState( + name: "Aldric", maxHP: 6, currentHP: 4, + maxStress: 6, currentStress: 2, + evasion: 12, thresholdMajor: 8, thresholdSevere: 15, + armorSlots: 3, currentArmorSlots: 1, + conditions: [.restrained] + ) + let data = try JSONEncoder().encode(slot) + let decoded = try JSONDecoder().decode(PlayerState.self, from: data) + + #expect(decoded.id == slot.id) + #expect(decoded.name == "Aldric") + #expect(decoded.currentHP == 4) + #expect(decoded.currentStress == 2) + #expect(decoded.currentArmorSlots == 1) + #expect(decoded.conditions == [.restrained]) + } } // MARK: - AdversaryState @@ -86,6 +105,27 @@ struct AdversaryStateTests { #expect(updated.adversaryID == "orc") #expect(updated.id == slot.id) } + + @Test func adversaryCodableRoundTrip() throws { + let slot = AdversaryState( + adversaryID: "goblin", + customName: "Grimfang", + maxHP: 4, maxStress: 2, + currentHP: 2, currentStress: 1, + isDefeated: false, + conditions: [.vulnerable] + ) + let data = try JSONEncoder().encode(slot) + let decoded = try JSONDecoder().decode(AdversaryState.self, from: data) + + #expect(decoded.id == slot.id) + #expect(decoded.adversaryID == "goblin") + #expect(decoded.customName == "Grimfang") + #expect(decoded.currentHP == 2) + #expect(decoded.currentStress == 1) + #expect(decoded.isDefeated == false) + #expect(decoded.conditions == [.vulnerable]) + } } // MARK: - EnvironmentState @@ -105,4 +145,14 @@ struct EnvironmentStateTests { #expect(deactivated.id == slot.id) #expect(deactivated.environmentID == slot.environmentID) } + + @Test func environmentStateCodableRoundTrip() throws { + let slot = EnvironmentState(environmentID: "arcane-storm", isActive: false) + let data = try JSONEncoder().encode(slot) + let decoded = try JSONDecoder().decode(EnvironmentState.self, from: data) + + #expect(decoded.id == slot.id) + #expect(decoded.environmentID == "arcane-storm") + #expect(decoded.isActive == false) + } }