Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/DHKit/AdversaryState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 82 additions & 4 deletions Sources/DHKit/EncounterSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
)
}
}
4 changes: 3 additions & 1 deletion Sources/DHKit/EnvironmentState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/DHKit/PlayerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
134 changes: 134 additions & 0 deletions Tests/DHKitTests/EncounterSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions Tests/DHKitTests/StateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
Loading