358 lines
12 KiB
Swift
358 lines
12 KiB
Swift
import AVFoundation
|
|
import Combine
|
|
import Foundation
|
|
|
|
@MainActor
|
|
final class HermesRepository: ObservableObject {
|
|
@Published private(set) var currentSession: HermesSessionResponse?
|
|
@Published private(set) var currentRound: HermesRound?
|
|
@Published private(set) var isLoading = true
|
|
@Published private(set) var errorCause: Error?
|
|
@Published private(set) var serverClockOffset: TimeInterval?
|
|
|
|
private let apiClient: HermesAPIClient
|
|
private var mockRoundIndex = 0
|
|
|
|
init(apiClient: HermesAPIClient) {
|
|
self.apiClient = apiClient
|
|
}
|
|
|
|
func reset() {
|
|
currentSession = nil
|
|
currentRound = nil
|
|
isLoading = false
|
|
errorCause = nil
|
|
serverClockOffset = nil
|
|
mockRoundIndex = 0
|
|
}
|
|
|
|
func bootstrapMock(localeCode: String, appVersion: String) async {
|
|
isLoading = true
|
|
errorCause = nil
|
|
mockRoundIndex = 0
|
|
currentSession = MockHermesData.session(localeCode: localeCode, appVersion: appVersion)
|
|
currentRound = MockHermesData.round(index: mockRoundIndex, now: Date())
|
|
isLoading = false
|
|
}
|
|
|
|
func refreshMockRound() async throws -> HermesRound {
|
|
isLoading = true
|
|
errorCause = nil
|
|
mockRoundIndex = (mockRoundIndex + 1) % MockHermesData.roundCount
|
|
let round = MockHermesData.round(index: mockRoundIndex, now: Date())
|
|
currentRound = round
|
|
isLoading = false
|
|
return round
|
|
}
|
|
|
|
func bootstrap(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
|
|
isLoading = true
|
|
errorCause = nil
|
|
|
|
do {
|
|
await syncClock()
|
|
|
|
let session: HermesSessionResponse
|
|
if let existingSession = currentSession {
|
|
session = existingSession
|
|
} else {
|
|
session = try await startSession(request)
|
|
}
|
|
|
|
if currentRound == nil {
|
|
currentRound = try await loadRoundFromNetwork()
|
|
}
|
|
|
|
isLoading = false
|
|
return session
|
|
} catch {
|
|
errorCause = error
|
|
isLoading = false
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func refreshRoundFromNetwork() async throws -> HermesRound {
|
|
isLoading = true
|
|
errorCause = nil
|
|
|
|
do {
|
|
await syncClock()
|
|
|
|
let round = try await loadRoundFromNetwork()
|
|
currentRound = round
|
|
isLoading = false
|
|
return round
|
|
} catch {
|
|
errorCause = error
|
|
isLoading = false
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func startSession(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
|
|
let session = try await apiClient.startSession(request)
|
|
currentSession = session
|
|
return session
|
|
}
|
|
|
|
func endSession() async throws -> HermesSessionResponse {
|
|
let session = try await apiClient.endSession()
|
|
currentSession = session
|
|
return session
|
|
}
|
|
|
|
func submitBetIntent(_ request: HermesBetIntentRequest) async throws -> HermesBetIntentResponse {
|
|
try await apiClient.submitBetIntent(request)
|
|
}
|
|
|
|
func currentOdds(marketID: UUID) async throws -> HermesOddsVersion {
|
|
try await apiClient.currentOdds(marketID: marketID)
|
|
}
|
|
|
|
func settlement(eventID: UUID) async throws -> HermesSettlement {
|
|
try await apiClient.settlement(eventID: eventID)
|
|
}
|
|
|
|
func experimentConfig() async throws -> HermesExperimentConfig {
|
|
try await apiClient.experimentConfig()
|
|
}
|
|
|
|
func localization(localeCode: String) async throws -> HermesLocalizationBundle {
|
|
try await apiClient.localization(localeCode: localeCode)
|
|
}
|
|
|
|
func submitAnalyticsBatch(_ payload: HermesAnalyticsBatchRequest) async throws {
|
|
try await apiClient.submitAnalyticsBatch(payload)
|
|
}
|
|
|
|
func serverNow() -> Date {
|
|
guard let serverClockOffset else {
|
|
return Date()
|
|
}
|
|
|
|
return Date().addingTimeInterval(serverClockOffset)
|
|
}
|
|
|
|
private func syncClock() async {
|
|
do {
|
|
let health = try await apiClient.health()
|
|
serverClockOffset = health.serverTime.timeIntervalSince(Date())
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
|
|
private func loadRoundFromNetwork() async throws -> HermesRound {
|
|
let event = try await apiClient.nextEvent()
|
|
let manifest = try await apiClient.eventManifest(eventID: event.id)
|
|
guard let media = manifest.media.first(where: { $0.mediaType == "hls_main" }) ?? manifest.media.first else {
|
|
throw HermesAPIError.invalidResponse
|
|
}
|
|
|
|
let market: HermesMarket
|
|
if let manifestMarket = manifest.markets.first {
|
|
market = manifestMarket
|
|
} else {
|
|
let markets = try await apiClient.markets(eventID: event.id)
|
|
guard let fallbackMarket = markets.first else {
|
|
throw HermesAPIError.invalidResponse
|
|
}
|
|
market = fallbackMarket
|
|
}
|
|
|
|
let oddsVersion = try await apiClient.currentOdds(marketID: market.id)
|
|
let settlement = try await apiClient.settlement(eventID: event.id)
|
|
|
|
return HermesRound(
|
|
event: event,
|
|
media: media,
|
|
market: market,
|
|
oddsVersion: oddsVersion,
|
|
settlement: settlement
|
|
)
|
|
}
|
|
}
|
|
|
|
enum MockHermesData {
|
|
static let roundCount = 3
|
|
private static let fallbackMediaURL = URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8")!
|
|
|
|
static func session(localeCode: String, appVersion: String) -> HermesSessionResponse {
|
|
HermesSessionResponse(
|
|
sessionId: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(),
|
|
userId: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(),
|
|
startedAt: Date(),
|
|
endedAt: nil,
|
|
experimentVariant: "modern",
|
|
appVersion: appVersion,
|
|
deviceModel: "Demo Device",
|
|
osVersion: "Demo OS",
|
|
localeCode: localeCode,
|
|
devicePlatform: "ios"
|
|
)
|
|
}
|
|
|
|
static func round(index: Int, now: Date) -> HermesRound {
|
|
let scenarios = [
|
|
scenario(
|
|
eventID: "10000000-0000-0000-0000-000000000001",
|
|
marketID: "20000000-0000-0000-0000-000000000001",
|
|
yesOutcomeID: "30000000-0000-0000-0000-000000000001",
|
|
noOutcomeID: "30000000-0000-0000-0000-000000000002",
|
|
promptKey: "event.question.score",
|
|
sourceRef: "mock-score",
|
|
winningOutcomeID: "30000000-0000-0000-0000-000000000001",
|
|
now: now
|
|
),
|
|
scenario(
|
|
eventID: "10000000-0000-0000-0000-000000000011",
|
|
marketID: "20000000-0000-0000-0000-000000000011",
|
|
yesOutcomeID: "30000000-0000-0000-0000-000000000011",
|
|
noOutcomeID: "30000000-0000-0000-0000-000000000012",
|
|
promptKey: "event.question.save",
|
|
sourceRef: "mock-save",
|
|
winningOutcomeID: "30000000-0000-0000-0000-000000000012",
|
|
now: now
|
|
),
|
|
scenario(
|
|
eventID: "10000000-0000-0000-0000-000000000021",
|
|
marketID: "20000000-0000-0000-0000-000000000021",
|
|
yesOutcomeID: "30000000-0000-0000-0000-000000000021",
|
|
noOutcomeID: "30000000-0000-0000-0000-000000000022",
|
|
promptKey: "event.question.convert",
|
|
sourceRef: "mock-convert",
|
|
winningOutcomeID: "30000000-0000-0000-0000-000000000021",
|
|
now: now
|
|
),
|
|
]
|
|
|
|
return scenarios[index % scenarios.count]
|
|
}
|
|
|
|
private static func scenario(
|
|
eventID: String,
|
|
marketID: String,
|
|
yesOutcomeID: String,
|
|
noOutcomeID: String,
|
|
promptKey: String,
|
|
sourceRef: String,
|
|
winningOutcomeID: String,
|
|
now: Date
|
|
) -> HermesRound {
|
|
let mediaURL = localMediaURL(named: "example-round.mov") ?? fallbackMediaURL
|
|
let previewDurationMs = assetDurationMs(url: mediaURL) ?? 15_000
|
|
let revealURL = revealMediaURL()
|
|
let revealDurationMs = assetDurationMs(url: revealURL) ?? 6_000
|
|
let lockAt = now.addingTimeInterval(Double(previewDurationMs) / 1_000.0)
|
|
let settleAt = lockAt.addingTimeInterval(6)
|
|
|
|
let event = HermesEvent(
|
|
id: UUID(uuidString: eventID) ?? UUID(),
|
|
sportType: "football",
|
|
sourceRef: sourceRef,
|
|
titleEn: promptKey,
|
|
titleSv: promptKey,
|
|
status: "active",
|
|
previewStartMs: 0,
|
|
previewEndMs: previewDurationMs,
|
|
revealStartMs: 0,
|
|
revealEndMs: revealDurationMs,
|
|
lockAt: lockAt,
|
|
settleAt: settleAt
|
|
)
|
|
|
|
let media = HermesEventMedia(
|
|
id: UUID(),
|
|
eventId: event.id,
|
|
mediaType: "hls_main",
|
|
hlsMasterUrl: mediaURL,
|
|
posterUrl: nil,
|
|
durationMs: previewDurationMs,
|
|
previewStartMs: 0,
|
|
previewEndMs: previewDurationMs,
|
|
revealStartMs: 0,
|
|
revealEndMs: revealDurationMs
|
|
)
|
|
|
|
let marketUUID = UUID(uuidString: marketID) ?? UUID()
|
|
let market = HermesMarket(
|
|
id: marketUUID,
|
|
eventId: event.id,
|
|
questionKey: promptKey,
|
|
marketType: promptKey,
|
|
status: "open",
|
|
lockAt: lockAt,
|
|
settlementRuleKey: "demo",
|
|
outcomes: [
|
|
HermesOutcome(
|
|
id: UUID(uuidString: yesOutcomeID) ?? UUID(),
|
|
marketId: marketUUID,
|
|
outcomeCode: "yes",
|
|
labelKey: "round.yes",
|
|
sortOrder: 0
|
|
),
|
|
HermesOutcome(
|
|
id: UUID(uuidString: noOutcomeID) ?? UUID(),
|
|
marketId: marketUUID,
|
|
outcomeCode: "no",
|
|
labelKey: "round.no",
|
|
sortOrder: 1
|
|
),
|
|
]
|
|
)
|
|
|
|
let oddsVersionID = UUID()
|
|
let oddsVersion = HermesOddsVersion(
|
|
id: oddsVersionID,
|
|
marketId: market.id,
|
|
versionNo: 1,
|
|
createdAt: now,
|
|
isCurrent: true,
|
|
odds: [
|
|
HermesOutcomeOdds(id: UUID(), oddsVersionId: oddsVersionID, outcomeId: market.outcomes[0].id, decimalOdds: 1.82, fractionalNum: 91, fractionalDen: 50),
|
|
HermesOutcomeOdds(id: UUID(), oddsVersionId: oddsVersionID, outcomeId: market.outcomes[1].id, decimalOdds: 2.05, fractionalNum: 41, fractionalDen: 20),
|
|
]
|
|
)
|
|
|
|
let settlement = HermesSettlement(
|
|
id: UUID(),
|
|
marketId: market.id,
|
|
settledAt: settleAt,
|
|
winningOutcomeId: UUID(uuidString: winningOutcomeID) ?? market.outcomes[0].id
|
|
)
|
|
|
|
return HermesRound(event: event, media: media, market: market, oddsVersion: oddsVersion, settlement: settlement)
|
|
}
|
|
|
|
static func revealMediaURL() -> URL {
|
|
localMediaURL(named: "example-reveal.mov") ?? fallbackMediaURL
|
|
}
|
|
|
|
private static func localMediaURL(named fileName: String) -> URL? {
|
|
let fileURL = URL(fileURLWithPath: fileName)
|
|
if let bundledURL = Bundle.main.url(forResource: fileURL.deletingPathExtension().lastPathComponent, withExtension: fileURL.pathExtension) {
|
|
return bundledURL
|
|
}
|
|
|
|
let sourceFileURL = URL(fileURLWithPath: #filePath)
|
|
let repoRoot = sourceFileURL
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
|
|
let candidate = repoRoot.appendingPathComponent(fileName)
|
|
return FileManager.default.fileExists(atPath: candidate.path) ? candidate : nil
|
|
}
|
|
|
|
private static func assetDurationMs(url: URL) -> Int? {
|
|
let duration = AVAsset(url: url).duration.seconds
|
|
guard duration.isFinite, duration > 0 else {
|
|
return nil
|
|
}
|
|
|
|
return Int((duration * 1_000.0).rounded())
|
|
}
|
|
}
|