From c58e8998c3b138ce99b5dfe1efce6a2bdc4e8c4b Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 9 May 2026 09:15:05 -0700 Subject: [PATCH] feat: add SessionPhase, pause(), and resume() to EncounterSession 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). --- Sources/DHKit/EncounterSession.swift | 33 +++++++++ Tests/DHKitTests/EncounterSessionTests.swift | 76 ++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/Sources/DHKit/EncounterSession.swift b/Sources/DHKit/EncounterSession.swift index 885ee07..e25b5bd 100644 --- a/Sources/DHKit/EncounterSession.swift +++ b/Sources/DHKit/EncounterSession.swift @@ -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. @@ -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. @@ -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 ) { @@ -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. @@ -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 } @@ -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) } @@ -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( @@ -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 ) } diff --git a/Tests/DHKitTests/EncounterSessionTests.swift b/Tests/DHKitTests/EncounterSessionTests.swift index a41a75b..86a2fd9 100644 --- a/Tests/DHKitTests/EncounterSessionTests.swift +++ b/Tests/DHKitTests/EncounterSessionTests.swift @@ -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 @@ -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 {