From 3dffecae0bce97a691d18a89210de0bf95193319 Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 4 Apr 2026 12:40:40 -0700 Subject: [PATCH] add Player and Party value types to DHModels Closes #14, closes #15 --- Sources/DHModels/Party.swift | 36 ++++++++++++ Sources/DHModels/Player.swift | 68 ++++++++++++++++++++++ Tests/DHModelsTests/ModelTests.swift | 87 ++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 Sources/DHModels/Party.swift create mode 100644 Sources/DHModels/Player.swift diff --git a/Sources/DHModels/Party.swift b/Sources/DHModels/Party.swift new file mode 100644 index 0000000..1e1aaee --- /dev/null +++ b/Sources/DHModels/Party.swift @@ -0,0 +1,36 @@ +// +// Party.swift +// DHModels +// +// A named group of player characters. +// + +import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + +// MARK: - Party + +/// A named group of player characters. +/// +/// `Party` stores ordered player IDs only; resolving full ``Player`` objects +/// from those IDs is the store's responsibility. A player may belong to +/// multiple parties. +nonisolated public struct Party: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: UUID + public var name: String + /// Ordered list of player IDs; order determines display order. + public var playerIDs: [UUID] + + public init( + id: UUID = UUID(), + name: String, + playerIDs: [UUID] = [] + ) { + self.id = id + self.name = name + self.playerIDs = playerIDs + } +} diff --git a/Sources/DHModels/Player.swift b/Sources/DHModels/Player.swift new file mode 100644 index 0000000..ffb5850 --- /dev/null +++ b/Sources/DHModels/Player.swift @@ -0,0 +1,68 @@ +// +// Player.swift +// DHModels +// +// A persistent record of a player character tracked by the GM across encounters. +// + +import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + +// MARK: - Player + +/// A persistent record of a player character tracked by the GM across encounters. +/// +/// `Player` is the canonical global identity for a PC. It carries the same +/// stat fields as ``PlayerConfig`` — the encounter-snapshot counterpart — +/// but exists independently of any encounter or party. +/// +/// Use ``asConfig()`` to snapshot a `Player` into a ``PlayerConfig`` for +/// use in an ``EncounterDefinition``. +nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: UUID + public var name: String + public var maxHP: Int + public var maxStress: Int + public var evasion: Int + public var thresholdMajor: Int + public var thresholdSevere: Int + public var armorSlots: Int + + public init( + id: UUID = UUID(), + name: String, + maxHP: Int, + maxStress: Int, + evasion: Int, + thresholdMajor: Int, + thresholdSevere: Int, + armorSlots: Int + ) { + self.id = id + self.name = name + self.maxHP = maxHP + self.maxStress = maxStress + self.evasion = evasion + self.thresholdMajor = thresholdMajor + self.thresholdSevere = thresholdSevere + self.armorSlots = armorSlots + } + + /// Snapshots this player's current stats into a ``PlayerConfig`` for use + /// in an ``EncounterDefinition`` or session creation. + public func asConfig() -> PlayerConfig { + PlayerConfig( + id: id, + name: name, + maxHP: maxHP, + maxStress: maxStress, + evasion: evasion, + thresholdMajor: thresholdMajor, + thresholdSevere: thresholdSevere, + armorSlots: armorSlots + ) + } +} diff --git a/Tests/DHModelsTests/ModelTests.swift b/Tests/DHModelsTests/ModelTests.swift index 0f40519..3586366 100644 --- a/Tests/DHModelsTests/ModelTests.swift +++ b/Tests/DHModelsTests/ModelTests.swift @@ -442,6 +442,93 @@ struct DifficultyBudgetTests { } } +// MARK: - Player + +struct PlayerTests { + + @Test func playerIsValueType() { + var player1 = Player( + name: "Aldric", maxHP: 6, maxStress: 6, + evasion: 12, thresholdMajor: 8, thresholdSevere: 15, armorSlots: 3 + ) + let player2 = player1 + player1.name = "Modified" + #expect(player2.name == "Aldric") + } + + @Test func playerCodableRoundTrip() throws { + let player = Player( + name: "Sera", maxHP: 8, maxStress: 6, + evasion: 14, thresholdMajor: 10, thresholdSevere: 18, armorSlots: 4 + ) + let data = try JSONEncoder().encode(player) + let decoded = try JSONDecoder().decode(Player.self, from: data) + + #expect(decoded.id == player.id) + #expect(decoded.name == "Sera") + #expect(decoded.maxHP == 8) + #expect(decoded.maxStress == 6) + #expect(decoded.evasion == 14) + #expect(decoded.thresholdMajor == 10) + #expect(decoded.thresholdSevere == 18) + #expect(decoded.armorSlots == 4) + } + + @Test func asConfigPreservesAllFields() { + let player = Player( + name: "Torven", maxHP: 10, maxStress: 5, + evasion: 11, thresholdMajor: 7, thresholdSevere: 14, armorSlots: 2 + ) + let config = player.asConfig() + + #expect(config.id == player.id) + #expect(config.name == player.name) + #expect(config.maxHP == player.maxHP) + #expect(config.maxStress == player.maxStress) + #expect(config.evasion == player.evasion) + #expect(config.thresholdMajor == player.thresholdMajor) + #expect(config.thresholdSevere == player.thresholdSevere) + #expect(config.armorSlots == player.armorSlots) + } +} + +// MARK: - Party + +struct PartyTests { + + @Test func partyIsValueType() { + var party1 = Party(name: "The Wanderers") + let party2 = party1 + party1.name = "Modified" + #expect(party2.name == "The Wanderers") + } + + @Test func partyCodableRoundTrip() throws { + let ids = [UUID(), UUID(), UUID()] + let party = Party(name: "Ironclad Company", playerIDs: ids) + let data = try JSONEncoder().encode(party) + let decoded = try JSONDecoder().decode(Party.self, from: data) + + #expect(decoded.id == party.id) + #expect(decoded.name == "Ironclad Company") + #expect(decoded.playerIDs == ids) + } + + @Test func playerIDsOrderIsPreserved() throws { + let ids = (0..<5).map { _ in UUID() } + let party = Party(name: "Order Test", playerIDs: ids) + let data = try JSONEncoder().encode(party) + let decoded = try JSONDecoder().decode(Party.self, from: data) + + #expect(decoded.playerIDs == ids) + } + + @Test func defaultPartyHasNoPlayers() { + let party = Party(name: "Empty") + #expect(party.playerIDs.isEmpty) + } +} + // MARK: - DaggerheartEnvironment struct EnvironmentModelTests {