Parent directory

GameStore.swift

5996 bytes
  1import Foundation
  2import Observation
  3import kaiwariShared
  4
  5@MainActor
  6@Observable
  7final class GameStore {
  8    enum Phase {
  9        case idle
 10        case recording
 11        case processing
 12    }
 13
 14    private static let sessionIDKey = "sessionID"
 15    private static let recordCap: Duration = .seconds(15)
 16
 17    private let settings: SettingsStore
 18    private let api = APIClient()
 19    private let recorder = Recorder()
 20    private let player = Player()
 21    private var capTask: Task<Void, Never>?
 22
 23    private(set) var sessionID: String?
 24    private(set) var entries: [LogEntry] = []
 25    private(set) var phase: Phase = .idle
 26    private(set) var status: String?
 27    private(set) var lastAudio: Data?
 28
 29    init(settings: SettingsStore) {
 30        self.settings = settings
 31        sessionID = UserDefaults.standard.string(forKey: Self.sessionIDKey)
 32    }
 33
 34    var hasAudio: Bool { lastAudio != nil }
 35
 36    // Reconnect to a persisted session. Sessions are in-memory on the server,
 37    // so any failure means starting fresh.
 38    func connect() async {
 39        guard let configuration = settings.configuration else {
 40            return
 41        }
 42        entries = []
 43        guard let sessionID else {
 44            return
 45        }
 46        do {
 47            entries = try await api.fetchLog(sessionID: sessionID, using: configuration)
 48        } catch {
 49            setSession(nil)
 50        }
 51    }
 52
 53    func newGame() async {
 54        guard phase == .idle, let configuration = settings.configuration else {
 55            return
 56        }
 57        setPhase(.processing)
 58        defer { setPhase(.idle) }
 59        do {
 60            let (id, opening) = try await api.createSession(using: configuration)
 61            entries = []
 62            lastAudio = nil
 63            status = nil
 64            setSession(id)
 65            apply(opening)
 66        } catch {
 67            status = "turn failed: \(error.localizedDescription)"
 68        }
 69    }
 70
 71    func send(_ rawAction: String) async {
 72        let action = rawAction.trimmingCharacters(in: .whitespacesAndNewlines)
 73        guard phase == .idle, Self.isSendable(action), let configuration = settings.configuration, let sessionID else {
 74            return
 75        }
 76        setPhase(.processing)
 77        defer { setPhase(.idle) }
 78        do {
 79            if action.hasPrefix("!") {
 80                let added = try await api.addFlashcards(
 81                    sessionID: sessionID,
 82                    instructions: String(action.dropFirst()),
 83                    using: configuration
 84                )
 85                status = flashcardStatus(added)
 86            } else {
 87                apply(try await api.performAction(sessionID: sessionID, action: action, using: configuration))
 88            }
 89        } catch {
 90            status = "turn failed: \(error.localizedDescription)"
 91        }
 92    }
 93
 94    func toggleTalk() {
 95        switch phase {
 96        case .idle:
 97            beginRecording()
 98        case .recording:
 99            finishRecording()
100        case .processing:
101            break
102        }
103    }
104
105    func replay() {
106        guard let lastAudio else {
107            status = "nothing to replay yet"
108            return
109        }
110        do {
111            try player.play(lastAudio)
112        } catch {
113            status = "replay failed: \(error.localizedDescription)"
114        }
115    }
116
117    static func isSendable(_ action: String) -> Bool {
118        guard !action.isEmpty else {
119            return false
120        }
121        guard let prefix = action.first, prefix == "?" || prefix == "!" else {
122            return true
123        }
124        return !action.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
125    }
126
127    private func beginRecording() {
128        do {
129            try recorder.start()
130            setPhase(.recording)
131            capTask = Task {
132                try? await Task.sleep(for: Self.recordCap)
133                finishRecording()
134            }
135        } catch {
136            status = "recording failed: \(error.localizedDescription)"
137        }
138    }
139
140    private func finishRecording() {
141        guard phase == .recording else {
142            return
143        }
144        setPhase(.processing)
145        capTask?.cancel()
146        capTask = nil
147        Task { await uploadRecording() }
148    }
149
150    private func uploadRecording() async {
151        defer { setPhase(.idle) }
152        do {
153            let wav = try recorder.stop()
154            guard let configuration = settings.configuration, let sessionID else {
155                return
156            }
157            apply(try await api.speak(sessionID: sessionID, wav: wav, using: configuration))
158        } catch {
159            status = "turn failed: \(error.localizedDescription)"
160        }
161    }
162
163    private func apply(_ turn: TurnResponse) {
164        entries.append(turn.entry)
165        status = turnStatus(turn)
166        if let audio = turn.audio, let data = Data(base64Encoded: audio) {
167            lastAudio = data
168            do {
169                try player.play(data)
170            } catch {
171                status = "audio failed: \(error.localizedDescription)"
172            }
173        }
174    }
175
176    private func turnStatus(_ turn: TurnResponse) -> String? {
177        switch (turn.speakError, turn.judgeError) {
178        case let (speakError?, _):
179            "audio failed: \(speakError)"
180        case let (_, judgeError?):
181            "judge unavailable: \(judgeError)"
182        default:
183            nil
184        }
185    }
186
187    private func flashcardStatus(_ added: Int) -> String? {
188        switch added {
189        case 0:
190            "no cards added"
191        case 1:
192            "1 card added"
193        default:
194            "\(added) cards added"
195        }
196    }
197
198    private func setPhase(_ phase: Phase) {
199        self.phase = phase
200    }
201
202    private func setSession(_ id: String?) {
203        sessionID = id
204        if let id {
205            UserDefaults.standard.set(id, forKey: Self.sessionIDKey)
206        } else {
207            UserDefaults.standard.removeObject(forKey: Self.sessionIDKey)
208        }
209    }
210}