diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Games/LanguageGame.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Games/LanguageGame.swift index 01d1d6fa..7e4481f4 100644 --- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Games/LanguageGame.swift +++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Games/LanguageGame.swift @@ -133,17 +133,21 @@ final class LanguageGame: Game { ) if isSuccess { user.wallet.addGold(gainGold) + /// 정답 횟수 기록 user.record.record(.languageCorrect) /// 누적 재산 업데이트 user.record.record(.earnMoney(gainGold)) + // 재화 획득 시 캐릭터 웃게 만들기 animationSystem?.playSmile() return gainGold } user.wallet.spendGold(Int(Double(gainGold) * Policy.Game.Language.incorrectGoldLossMultiplier)) + /// 오답 횟수 기록 user.record.record(.languageIncorrect) + return Int(Double(gainGold) * Policy.Game.Language.incorrectGoldLossMultiplier) * -1 } diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/MissionSystem.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/MissionSystem.swift index 41e1749d..1ba0c3c3 100644 --- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/MissionSystem.swift +++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/MissionSystem.swift @@ -29,13 +29,13 @@ final class MissionSystem { } /// 기록을 통하여 현재 미션의 상태들을 업데이트 합니다. + /// - Note: 비동기로 처리되어 즉시 반환 + /// - Parameter record: 업데이트할 Record func updateCompletedMissions(record: Record) { - missions - .filter { $0.state == .inProgress } - .forEach { $0.update(record: record) } - - sortMissions() - checkHasCompletedMission() + // 메인 큐에 비동기로 추가 (즉시 반환, 순서 보장, 누락 없음) + DispatchQueue.main.async { [weak self] in + self?.performMissionUpdate(record: record) + } } func claimMissionReward(mission: Mission, wallet: Wallet) { @@ -46,12 +46,25 @@ final class MissionSystem { sortMissions() checkHasCompletedMission() } +} + +// MARK: - Helper +private extension MissionSystem { + /// 실제 미션 업데이트 수행 (메인 스레드, 비동기) + func performMissionUpdate(record: Record) { + missions + .filter { $0.state == .inProgress } + .forEach { $0.update(record: record) } + + sortMissions() + checkHasCompletedMission() + } - private func checkHasCompletedMission() { + func checkHasCompletedMission() { hasCompletedMission = missions.contains { $0.state == .claimable } } - private func sortMissions() { + func sortMissions() { missions.sort { $0.state.rawValue < $1.state.rawValue } } } diff --git a/SoloDeveloperTraining/SoloDeveloperTrainingTests/MissionSystemTests.swift b/SoloDeveloperTraining/SoloDeveloperTrainingTests/MissionSystemTests.swift new file mode 100644 index 00000000..4e99a352 --- /dev/null +++ b/SoloDeveloperTraining/SoloDeveloperTrainingTests/MissionSystemTests.swift @@ -0,0 +1,130 @@ +// +// MissionSystemTests.swift +// SoloDeveloperTraining +// +// Created by 김성훈 on 2/4/26. +// + +import Testing +import Foundation +@testable import SoloDeveloperTraining + +@MainActor +struct MissionSystemTests { + + @Test("기본 미션 업데이트 동작 검증") + func testBasicMissionUpdate() async throws { + // Given: 목표 10인 언어 미션 생성 + let mission = Mission( + id: 1, + type: .languageMatch(.bronze), + title: "언어 10회 성공", + description: "언어 게임 10회 성공하기", + targetValue: 10, + updateCondition: { $0.languageCorrectCount }, + reward: Cost(gold: 100) + ) + let missionSystem = MissionSystem(missions: [mission]) + let record = Record(missionSystem: missionSystem) + + // When: 10번 정답 기록 + for _ in 1...10 { + record.record(.languageCorrect) + } + + // 비동기 처리 완료 대기 + try await Task.sleep(nanoseconds: 100_000_000) // 0.1초 + + // Then: 미션 완료되어야 함 + #expect(mission.currentValue == 10, "미션 값이 10이어야 함") + #expect(mission.state == .claimable, "미션이 완료 상태여야 함") + #expect(missionSystem.hasCompletedMission == true, "완료된 미션이 있어야 함") + } + + @Test("연속 업데이트 시 누락 방지 - 100회 연속 기록") + func testConsecutiveUpdateGuarantee() async throws { + // Given: 목표 100인 언어 미션 + let mission = Mission( + id: 2, + type: .languageMatch(.silver), + title: "언어 100회 성공", + description: "언어 게임 100회 성공하기", + targetValue: 100, + updateCondition: { $0.languageCorrectCount }, + reward: Cost(gold: 1000) + ) + let missionSystem = MissionSystem(missions: [mission]) + let record = Record(missionSystem: missionSystem) + + // When: 100번 빠르게 연속 기록 + for _ in 1...100 { + record.record(.languageCorrect) + } + + // 비동기 처리 완료 대기 + try await Task.sleep(nanoseconds: 100_000_000) // 0.1초 + + // Then: 정확히 100이어야 함 (누락 없음) + #expect(record.languageCorrectCount == 100, "레코드가 정확히 100이어야 함") + #expect(mission.currentValue == 100, "미션 값이 정확히 100이어야 함") + #expect(mission.state == .claimable, "미션이 완료되어야 함") + } + + @Test("여러 미션 동시 업데이트 시 누락 방지 - 30개 미션") + func testMultipleMissionsUpdateWithoutLoss() async throws { + // Given: 30개의 미션 생성 + let missions = (1...30).map { idx in + Mission( + id: idx, + type: .languageMatch(.bronze), + title: "미션 \(idx)", + description: "테스트 미션", + targetValue: 1, + updateCondition: { $0.languageCorrectCount }, + reward: Cost(gold: 100) + ) + } + let missionSystem = MissionSystem(missions: missions) + let record = Record(missionSystem: missionSystem) + + // When: 1번만 기록 + record.record(.languageCorrect) + + // 비동기 처리 완료 대기 + try await Task.sleep(nanoseconds: 100_000_000) // 0.1초 + + // Then: 모든 미션이 완료되어야 함 (누락 없음) + let completedCount = missions.filter { $0.state == .claimable }.count + #expect(completedCount == 30, "30개 미션 모두 완료되어야 함 - 누락 없음") + #expect(missionSystem.hasCompletedMission == true, "완료 플래그가 true여야 함") + } + + @Test("earnMoney 이벤트로 누적 골드 미션 업데이트") + func testEarnMoneyEventMissionUpdate() async throws { + // Given: 누적 골드 미션 + let mission = Mission( + id: 100, + type: .tap(.gold), + title: "1000골드 획득", + description: "누적 1000골드 벌기", + targetValue: 1000, + updateCondition: { $0.totalEarnedMoney }, + reward: Cost(gold: 500, diamond: 10) + ) + let missionSystem = MissionSystem(missions: [mission]) + let record = Record(missionSystem: missionSystem) + + // When: 여러 번 골드 획득 + record.record(.earnMoney(300)) + record.record(.earnMoney(400)) + record.record(.earnMoney(300)) + + // 비동기 처리 완료 대기 + try await Task.sleep(nanoseconds: 100_000_000) // 0.1초 + + // Then: 정확히 1000이어야 함 + #expect(record.totalEarnedMoney == 1000, "총 획득 골드가 1000이어야 함") + #expect(mission.currentValue == 1000, "미션 값이 1000이어야 함") + #expect(mission.state == .claimable, "미션이 완료되어야 함") + } +}