Skip to content

Commit 5be6afb

Browse files
authored
feat: add SessionPhase, pause(), and resume() to EncounterSession (#26)
Adds a two-case SessionPhase enum (running/paused) as a public property on EncounterSession. Phase is enforced via pause()/resume() methods (private(set) prevents direct mutation). Codable round-trips cleanly and tolerates both missing phase keys (old sessions default to .running) and unknown future case strings (forward-compat fallback via raw-value decoding).
1 parent 478fdd8 commit 5be6afb

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

Sources/DHKit/EncounterSession.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ import Observation
2525
import FoundationEssentials
2626
#endif
2727

28+
// MARK: - SessionPhase
29+
30+
/// The lifecycle phase of a live encounter session.
31+
public enum SessionPhase: String, Codable, Sendable {
32+
case running
33+
case paused
34+
}
35+
2836
// MARK: - EncounterSession
2937

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

111+
// MARK: Lifecycle
112+
113+
/// The current lifecycle phase of this session.
114+
public private(set) var phase: SessionPhase
115+
103116
// MARK: Notes
104117

105118
/// Freeform notes visible to the GM during the encounter.
@@ -122,6 +135,7 @@ public final class EncounterSession: Identifiable, Hashable {
122135
spotlightedSlotID: UUID? = nil,
123136
spotlightCount: Int = 0,
124137
gmNotes: String = "",
138+
phase: SessionPhase = .running,
125139
definitionID: UUID? = nil,
126140
definitionSnapshotDate: Date? = nil
127141
) {
@@ -135,10 +149,23 @@ public final class EncounterSession: Identifiable, Hashable {
135149
self.spotlightedSlotID = spotlightedSlotID
136150
self.spotlightCount = spotlightCount
137151
self.gmNotes = gmNotes
152+
self.phase = phase
138153
self.definitionID = definitionID
139154
self.definitionSnapshotDate = definitionSnapshotDate
140155
}
141156

157+
// MARK: - Lifecycle
158+
159+
/// Pause this session. The session persists and can be resumed later.
160+
public func pause() {
161+
phase = .paused
162+
}
163+
164+
/// Resume a paused session.
165+
public func resume() {
166+
phase = .running
167+
}
168+
142169
// MARK: - Roster Management
143170

144171
/// Add a new adversary slot populated from a catalog entry.
@@ -453,6 +480,7 @@ extension EncounterSession: @MainActor Codable {
453480
case id, name
454481
case adversarySlots, playerSlots, environmentSlots
455482
case fearPool, hopePool, spotlightedSlotID, spotlightCount, gmNotes
483+
case phase
456484
case definitionID, definitionSnapshotDate
457485
}
458486

@@ -468,6 +496,7 @@ extension EncounterSession: @MainActor Codable {
468496
try c.encodeIfPresent(spotlightedSlotID, forKey: .spotlightedSlotID)
469497
try c.encode(spotlightCount, forKey: .spotlightCount)
470498
try c.encode(gmNotes, forKey: .gmNotes)
499+
try c.encode(phase, forKey: .phase)
471500
try c.encodeIfPresent(definitionID, forKey: .definitionID)
472501
try c.encodeIfPresent(definitionSnapshotDate, forKey: .definitionSnapshotDate)
473502
}
@@ -484,6 +513,9 @@ extension EncounterSession: @MainActor Codable {
484513
let spotlightedSlotID = try c.decodeIfPresent(UUID.self, forKey: .spotlightedSlotID)
485514
let spotlightCount = try c.decode(Int.self, forKey: .spotlightCount)
486515
let gmNotes = try c.decode(String.self, forKey: .gmNotes)
516+
let phase =
517+
(try c.decodeIfPresent(String.self, forKey: .phase))
518+
.flatMap(SessionPhase.init(rawValue:)) ?? .running
487519
let definitionID = try c.decodeIfPresent(UUID.self, forKey: .definitionID)
488520
let definitionSnapshotDate = try c.decodeIfPresent(Date.self, forKey: .definitionSnapshotDate)
489521
self.init(
@@ -492,6 +524,7 @@ extension EncounterSession: @MainActor Codable {
492524
environmentSlots: environmentSlots,
493525
fearPool: fearPool, hopePool: hopePool,
494526
spotlightedSlotID: spotlightedSlotID, spotlightCount: spotlightCount, gmNotes: gmNotes,
527+
phase: phase,
495528
definitionID: definitionID, definitionSnapshotDate: definitionSnapshotDate
496529
)
497530
}

Tests/DHKitTests/EncounterSessionTests.swift

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,13 @@ import Testing
555555
#expect(session.adversarySlots[1].customName == "Ironguard Soldier 1")
556556
#expect(session.adversarySlots[2].customName == "Ironguard Soldier 2")
557557
}
558+
559+
@Test func sessionMadeFromDefinitionStartsRunning() {
560+
let compendium = makeCompendium()
561+
let def = EncounterDefinition(name: "Phase Test")
562+
let session = EncounterSession.make(from: def, using: compendium)
563+
#expect(session.phase == .running)
564+
}
558565
}
559566

560567
// MARK: - EncounterSession Codable
@@ -606,6 +613,75 @@ import Testing
606613
}
607614
}
608615

616+
// MARK: - SessionPhase
617+
618+
@MainActor struct SessionPhaseTests {
619+
620+
@Test func newSessionPhaseIsRunning() {
621+
let session = EncounterSession(name: "Test")
622+
#expect(session.phase == .running)
623+
}
624+
625+
@Test func pauseSetsPhase() {
626+
let session = EncounterSession(name: "Test")
627+
session.pause()
628+
#expect(session.phase == .paused)
629+
}
630+
631+
@Test func resumeSetsPhase() {
632+
let session = EncounterSession(name: "Test", phase: .paused)
633+
session.resume()
634+
#expect(session.phase == .running)
635+
}
636+
637+
@Test func pauseIsIdempotent() {
638+
let session = EncounterSession(name: "Test")
639+
session.pause()
640+
session.pause()
641+
#expect(session.phase == .paused)
642+
}
643+
644+
@Test func resumeIsIdempotent() {
645+
let session = EncounterSession(name: "Test")
646+
session.resume()
647+
#expect(session.phase == .running)
648+
}
649+
650+
@Test func phaseRoundTripsViaCodable() throws {
651+
let session = EncounterSession(name: "Test")
652+
session.pause()
653+
654+
let data = try JSONEncoder().encode(session)
655+
let decoded = try JSONDecoder().decode(EncounterSession.self, from: data)
656+
657+
#expect(decoded.phase == .paused)
658+
}
659+
660+
@Test func missingPhaseKeyDecodesAsRunning() throws {
661+
// JSON from before SessionPhase was added — must not break existing saved sessions.
662+
let json = """
663+
{"id":"\(UUID().uuidString)","name":"Legacy","adversarySlots":[],"playerSlots":[],\
664+
"environmentSlots":[],"fearPool":0,"hopePool":0,"spotlightCount":0,"gmNotes":""}
665+
"""
666+
let data = json.data(using: .utf8)!
667+
let decoded = try JSONDecoder().decode(EncounterSession.self, from: data)
668+
#expect(decoded.phase == .running)
669+
}
670+
671+
@Test func unknownFuturePhaseValueDecodesAsRunning() throws {
672+
// A session saved by a newer app version with an unknown phase case must not
673+
// crash an older client — it should fall back to .running.
674+
let json = """
675+
{"id":"\(UUID().uuidString)","name":"Future","adversarySlots":[],"playerSlots":[],\
676+
"environmentSlots":[],"fearPool":0,"hopePool":0,"spotlightCount":0,\
677+
"gmNotes":"","phase":"completed"}
678+
"""
679+
let data = json.data(using: .utf8)!
680+
let decoded = try JSONDecoder().decode(EncounterSession.self, from: data)
681+
#expect(decoded.phase == .running)
682+
}
683+
}
684+
609685
// MARK: - AdversaryState stat snapshot
610686

611687
@MainActor struct AdversaryStateSnapshotTests {

0 commit comments

Comments
 (0)