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
33 changes: 33 additions & 0 deletions Sources/DHKit/EncounterSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ import Observation
import FoundationEssentials
#endif

// MARK: - SessionPhase

/// The lifecycle phase of a live encounter session.
public enum SessionPhase: String, Codable, Sendable {
case running
case paused
}

// MARK: - EncounterSession

/// The live state of a Daggerheart encounter being run at the table.
Expand Down Expand Up @@ -100,6 +108,11 @@ public final class EncounterSession: Identifiable, Hashable {
/// Running total of spotlight grants in this encounter.
public var spotlightCount: Int

// MARK: Lifecycle

/// The current lifecycle phase of this session.
public private(set) var phase: SessionPhase

// MARK: Notes

/// Freeform notes visible to the GM during the encounter.
Expand All @@ -122,6 +135,7 @@ public final class EncounterSession: Identifiable, Hashable {
spotlightedSlotID: UUID? = nil,
spotlightCount: Int = 0,
gmNotes: String = "",
phase: SessionPhase = .running,
definitionID: UUID? = nil,
definitionSnapshotDate: Date? = nil
) {
Expand All @@ -135,10 +149,23 @@ public final class EncounterSession: Identifiable, Hashable {
self.spotlightedSlotID = spotlightedSlotID
self.spotlightCount = spotlightCount
self.gmNotes = gmNotes
self.phase = phase
self.definitionID = definitionID
self.definitionSnapshotDate = definitionSnapshotDate
}

// MARK: - Lifecycle

/// Pause this session. The session persists and can be resumed later.
public func pause() {
phase = .paused
}

/// Resume a paused session.
public func resume() {
phase = .running
}

// MARK: - Roster Management

/// Add a new adversary slot populated from a catalog entry.
Expand Down Expand Up @@ -453,6 +480,7 @@ extension EncounterSession: @MainActor Codable {
case id, name
case adversarySlots, playerSlots, environmentSlots
case fearPool, hopePool, spotlightedSlotID, spotlightCount, gmNotes
case phase
case definitionID, definitionSnapshotDate
}

Expand All @@ -468,6 +496,7 @@ extension EncounterSession: @MainActor Codable {
try c.encodeIfPresent(spotlightedSlotID, forKey: .spotlightedSlotID)
try c.encode(spotlightCount, forKey: .spotlightCount)
try c.encode(gmNotes, forKey: .gmNotes)
try c.encode(phase, forKey: .phase)
try c.encodeIfPresent(definitionID, forKey: .definitionID)
try c.encodeIfPresent(definitionSnapshotDate, forKey: .definitionSnapshotDate)
}
Expand All @@ -484,6 +513,9 @@ extension EncounterSession: @MainActor Codable {
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 phase =
(try c.decodeIfPresent(String.self, forKey: .phase))
.flatMap(SessionPhase.init(rawValue:)) ?? .running
let definitionID = try c.decodeIfPresent(UUID.self, forKey: .definitionID)
let definitionSnapshotDate = try c.decodeIfPresent(Date.self, forKey: .definitionSnapshotDate)
self.init(
Expand All @@ -492,6 +524,7 @@ extension EncounterSession: @MainActor Codable {
environmentSlots: environmentSlots,
fearPool: fearPool, hopePool: hopePool,
spotlightedSlotID: spotlightedSlotID, spotlightCount: spotlightCount, gmNotes: gmNotes,
phase: phase,
definitionID: definitionID, definitionSnapshotDate: definitionSnapshotDate
)
}
Expand Down
76 changes: 76 additions & 0 deletions Tests/DHKitTests/EncounterSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,13 @@ import Testing
#expect(session.adversarySlots[1].customName == "Ironguard Soldier 1")
#expect(session.adversarySlots[2].customName == "Ironguard Soldier 2")
}

@Test func sessionMadeFromDefinitionStartsRunning() {
let compendium = makeCompendium()
let def = EncounterDefinition(name: "Phase Test")
let session = EncounterSession.make(from: def, using: compendium)
#expect(session.phase == .running)
}
}

// MARK: - EncounterSession Codable
Expand Down Expand Up @@ -606,6 +613,75 @@ import Testing
}
}

// MARK: - SessionPhase

@MainActor struct SessionPhaseTests {

@Test func newSessionPhaseIsRunning() {
let session = EncounterSession(name: "Test")
#expect(session.phase == .running)
}

@Test func pauseSetsPhase() {
let session = EncounterSession(name: "Test")
session.pause()
#expect(session.phase == .paused)
}

@Test func resumeSetsPhase() {
let session = EncounterSession(name: "Test", phase: .paused)
session.resume()
#expect(session.phase == .running)
}

@Test func pauseIsIdempotent() {
let session = EncounterSession(name: "Test")
session.pause()
session.pause()
#expect(session.phase == .paused)
}

@Test func resumeIsIdempotent() {
let session = EncounterSession(name: "Test")
session.resume()
#expect(session.phase == .running)
}

@Test func phaseRoundTripsViaCodable() throws {
let session = EncounterSession(name: "Test")
session.pause()

let data = try JSONEncoder().encode(session)
let decoded = try JSONDecoder().decode(EncounterSession.self, from: data)

#expect(decoded.phase == .paused)
}

@Test func missingPhaseKeyDecodesAsRunning() throws {
// JSON from before SessionPhase was added — must not break existing saved sessions.
let json = """
{"id":"\(UUID().uuidString)","name":"Legacy","adversarySlots":[],"playerSlots":[],\
"environmentSlots":[],"fearPool":0,"hopePool":0,"spotlightCount":0,"gmNotes":""}
"""
let data = json.data(using: .utf8)!
let decoded = try JSONDecoder().decode(EncounterSession.self, from: data)
#expect(decoded.phase == .running)
}

@Test func unknownFuturePhaseValueDecodesAsRunning() throws {
// A session saved by a newer app version with an unknown phase case must not
// crash an older client — it should fall back to .running.
let json = """
{"id":"\(UUID().uuidString)","name":"Future","adversarySlots":[],"playerSlots":[],\
"environmentSlots":[],"fearPool":0,"hopePool":0,"spotlightCount":0,\
"gmNotes":"","phase":"completed"}
"""
let data = json.data(using: .utf8)!
let decoded = try JSONDecoder().decode(EncounterSession.self, from: data)
#expect(decoded.phase == .running)
}
}

// MARK: - AdversaryState stat snapshot

@MainActor struct AdversaryStateSnapshotTests {
Expand Down
Loading