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}