From c62a8802dc575458088a53a50dd4355a0c7310ef Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 10:58:43 +0900 Subject: [PATCH 01/13] =?UTF-8?q?*=20=E2=9C=A8[feat]:=20=EB=8F=84=EC=A0=84?= =?UTF-8?q?=20=EA=B3=BC=EC=A0=9C=203=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/BaseBall/BaseBallGame.swift | 79 ++++++++++++++----- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift index c01172d..d3c11b0 100644 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift @@ -10,13 +10,52 @@ import LogMacro actor BaseBallGame { private let digits = 3 + private var records: [Int] = [] - // MARK: - 게임 시작 + // MARK: - 게임시작 관련 func start() async { + while true { + showMenu() + + guard let line = await readLineAsync()?.trimmingCharacters(in: .whitespacesAndNewlines), + let choice = Int(line) else { + #logDebug("올바르지 않은 입력입니다. 1~3 중 하나를 입력해주세요.") + continue + } + + switch choice { + case 1: + #logDebug("\(choice) // 1번 게임 시작하기 입력") + let attempts = await startGame() + + case 2: + #logDebug("\(choice) // 2번 게임 기록 보기 입력") + + case 3: + #logDebug("\(choice) // 3번 종료하기 입력") + #logDebug("프로그램을 종료합니다.") + return + default: + #logDebug("올바르지 않은 입력입니다. 1~3 중 하나를 입력해주세요.") + } + } + } + + private func showMenu() { + #logDebug(""" + 환영합니다! 원하시는 번호를 입력해주세요 + 1. 게임 시작하기 2. 게임 기록 보기 3. 종료하기 + """) + } + + // MARK: - 게임 시작 + func startGame() async -> Int { #logDebug("< 게임을 시작합니다 >") let answer = makeAnswer() #logDebug("정답 생성: \(answer)") + var attempts = 0 + while true { #logDebug("숫자를 입력하세요") guard let line = await readLineAsync(), @@ -25,21 +64,22 @@ actor BaseBallGame { continue } + attempts += 1 let result = judge(answer: answer, guess: guess) switch result { - case .correct: - #logDebug("정답입니다!") - return - - case .nothing: - #logDebug("Nothing") - - case .strikeAndBall(let strike, let ball): - var parts: [String] = [] - if strike > 0 { parts.append("\(strike)스트라이크") } - if ball > 0 { parts.append("\(ball)볼") } - #logDebug(parts.joined(separator: " ")) + case .correct: + #logDebug("정답입니다!") + return attempts + + case .nothing: + #logDebug("Nothing") + + case .strikeAndBall(let strike, let ball): + var parts: [String] = [] + if strike > .zero { parts.append("\(strike)스트라이크") } + if ball > .zero { parts.append("\(ball)볼") } + #logDebug(parts.joined(separator: " ")) } } } @@ -49,7 +89,8 @@ actor BaseBallGame { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.count == digits, trimmed.allSatisfy({ $0.isNumber }), - !trimmed.contains("0") else { return nil } + trimmed.first != "0" else { return nil } + let numbers = trimmed.compactMap { Int(String($0)) } guard Set(numbers).count == digits else { return nil } return numbers @@ -60,15 +101,16 @@ actor BaseBallGame { let strikeCount = zip(answer, guess).filter { $0 == $1 }.count let ballCount = guess.filter { answer.contains($0) }.count - strikeCount if strikeCount == answer.count { return .correct } - if strikeCount == 0 && ballCount == 0 { return .nothing } + if strikeCount == .zero && ballCount == .zero { return .nothing } return .strikeAndBall(strike: strikeCount, ball: ballCount) } - // MARK: - 정답 생성 + // MARK: - 정답 생성 (첫 자리 1~9, 이후 0 허용, 중복 금지) private func makeAnswer() -> [Int] { - var pool = Array(1...9) + let firstNumber = Int.random(in: 1...9) + var pool = Array(0...9).filter { $0 != firstNumber } pool.shuffle() - return Array(pool.prefix(digits)) + return [firstNumber] + pool.prefix(digits - 1) } // MARK: - readLine 비동기 래핑 @@ -80,4 +122,3 @@ actor BaseBallGame { } } } - From e70f0daa9a42d31712435ff2a0e705cdfa18490f Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 11:44:57 +0900 Subject: [PATCH 02/13] =?UTF-8?q?*=20=E2=9C=A8[feat]:=20=20=EB=9E=9C?= =?UTF-8?q?=EB=8D=A4=20=EB=B2=88=ED=98=B8=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=EC=97=90=EC=84=9C=20=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Answer/AnswerGeneratorProtocol.swift | 10 ++++ .../Answer/RandomAnswerGenerator.swift | 18 +++++++ .../Sources/BaseBall/BaseBallGame.swift | 48 ++++++++++++------- 3 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/AnswerGeneratorProtocol.swift create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/RandomAnswerGenerator.swift diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/AnswerGeneratorProtocol.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/AnswerGeneratorProtocol.swift new file mode 100644 index 0000000..882808c --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/AnswerGeneratorProtocol.swift @@ -0,0 +1,10 @@ +// +// AnswerGeneratorProtocol.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +protocol AnswerGeneratorProtocol { + func make(digits: Int) -> [Int] +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/RandomAnswerGenerator.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/RandomAnswerGenerator.swift new file mode 100644 index 0000000..f2b00c4 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Answer/RandomAnswerGenerator.swift @@ -0,0 +1,18 @@ +// +// RandomAnswerGenerator.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + + +struct RandomAnswerGenerator: AnswerGeneratorProtocol, Sendable { + func make(digits: Int) -> [Int] { + let first = Int.random(in: 1...9) + var pool = Array(0...9).filter { $0 != first } + pool.shuffle() + return [first] + Array(pool.prefix(digits - 1)) + } +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift index d3c11b0..28508c3 100644 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift @@ -11,6 +11,13 @@ import LogMacro actor BaseBallGame { private let digits = 3 private var records: [Int] = [] + private let generator: AnswerGeneratorProtocol + + init(records: [Int] = [], generator: AnswerGeneratorProtocol = RandomAnswerGenerator()) { + self.records = records + self.generator = generator + } + // MARK: - 게임시작 관련 func start() async { @@ -27,10 +34,10 @@ actor BaseBallGame { case 1: #logDebug("\(choice) // 1번 게임 시작하기 입력") let attempts = await startGame() - + await saveRecord(attempts) case 2: #logDebug("\(choice) // 2번 게임 기록 보기 입력") - + await showRecords() case 3: #logDebug("\(choice) // 3번 종료하기 입력") #logDebug("프로그램을 종료합니다.") @@ -51,7 +58,7 @@ actor BaseBallGame { // MARK: - 게임 시작 func startGame() async -> Int { #logDebug("< 게임을 시작합니다 >") - let answer = makeAnswer() + let answer = generator.make(digits: digits) #logDebug("정답 생성: \(answer)") var attempts = 0 @@ -84,6 +91,23 @@ actor BaseBallGame { } } + // MARK: - 기록 저장/출력 + private func saveRecord(_ attempts: Int) async { + records.append(attempts) + } + + private func showRecords() async { + #logDebug("< 게임 기록 보기 >") + if records.isEmpty { + #logDebug("완료된 게임 기록이 없습니다.") + return + } + + for (index, value) in records.enumerated() { + #logDebug("\(index + 1)번째 게임 : 시도 횟수 - \(value)") + } + } + // MARK: - 입력 파싱 private func parseGuess(from input: String) -> [Int]? { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) @@ -98,19 +122,11 @@ actor BaseBallGame { // MARK: - 판정 private func judge(answer: [Int], guess: [Int]) -> JudgeResult { - let strikeCount = zip(answer, guess).filter { $0 == $1 }.count - let ballCount = guess.filter { answer.contains($0) }.count - strikeCount - if strikeCount == answer.count { return .correct } - if strikeCount == .zero && ballCount == .zero { return .nothing } - return .strikeAndBall(strike: strikeCount, ball: ballCount) - } - - // MARK: - 정답 생성 (첫 자리 1~9, 이후 0 허용, 중복 금지) - private func makeAnswer() -> [Int] { - let firstNumber = Int.random(in: 1...9) - var pool = Array(0...9).filter { $0 != firstNumber } - pool.shuffle() - return [firstNumber] + pool.prefix(digits - 1) + let strike = zip(answer, guess).filter { $0 == $1 }.count + let ball = Set(answer).intersection(guess).count - strike + if strike == answer.count { return .correct } + if strike == 0 && ball == 0 { return .nothing } + return .strikeAndBall(strike: strike, ball: ball) } // MARK: - readLine 비동기 래핑 From 5a4114c773ce71e7e1ad2ce06ee6fec71b50d70a Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 12:29:45 +0900 Subject: [PATCH 03/13] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=ED=8C=8C=EC=8B=A4=20=20=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/GuessParser/GuessParser.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift new file mode 100644 index 0000000..8bc2664 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift @@ -0,0 +1,31 @@ +// +// GuessParser.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +/// 1) Parser: 입력 파싱 +struct GuessParser: Sendable { + let digits: Int + let forbidLeadingZero: Bool + + init(digits: Int, forbidLeadingZero: Bool = true) { + precondition((1...10).contains(digits)) + self.digits = digits + self.forbidLeadingZero = forbidLeadingZero + } + + // MARK: - 입력 파싱 + func parse(_ input: String) -> [Int]? { + let s = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard s.count == digits, s.allSatisfy(\.isNumber) else { return nil } + if forbidLeadingZero && s.first == "0" { return nil } + let nums = s.compactMap { Int(String($0)) } + guard Set(nums).count == digits else { return nil } // 중복 금지 + return nums + } +} + From b470816cd2fa9dc44b8df38ca8d03f697fd54411 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 12:30:08 +0900 Subject: [PATCH 04/13] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=93=B0=EB=8A=94=20=EA=B1=B0=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Interface/ConsoleIO.swift | 22 +++++++++++++++++++ .../Sources/Interface/IOProvider.swift | 13 +++++++++++ 2 files changed, 35 insertions(+) create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/ConsoleIO.swift create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/IOProvider.swift diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/ConsoleIO.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/ConsoleIO.swift new file mode 100644 index 0000000..1ea2311 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/ConsoleIO.swift @@ -0,0 +1,22 @@ +// +// ConsoleIO.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation +import LogMacro + +struct ConsoleIO: IOProvider { + func show(_ message: String) { + #logDebug(message) + } + + // MARK: - readLine 비동기 래핑 + func readLine() async -> String? { + await withCheckedContinuation { continuation in + Task.detached { continuation.resume(returning: Swift.readLine()) } + } + } +} diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/IOProvider.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/IOProvider.swift new file mode 100644 index 0000000..d39e0d4 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Interface/IOProvider.swift @@ -0,0 +1,13 @@ +// +// IOProvider.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +protocol IOProvider { + func show(_ message: String) + func readLine() async -> String? +} From ff89f723866a0e7caeba23c597f3ec33dcf0ea47 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 12:30:28 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EB=A5=BC=20enum=20=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=B4=EC=84=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Menu/MenuAction.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift new file mode 100644 index 0000000..b6c7fe1 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift @@ -0,0 +1,31 @@ +// +// MenuAction.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +enum MenuType { + case startGame, showRecords, quit, invalid +} + +struct MenuAction: Sendable { + func ask(using io: IOProvider) async -> MenuType { + io.show(""" + 환영합니다! 원하시는 번호를 입력해주세요 + 1. 게임 시작하기 2. 게임 기록 보기 3. 종료하기 + """, ) + + guard let line = await io.readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + let n = Int(line) else { return .invalid } + + switch n { + case 1: return .startGame + case 2: return .showRecords + case 3: return .quit + default: return .invalid + } + } +} From eec973fd3bbae42cf5c09a169e9c4cf610bf29f5 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 12:30:51 +0900 Subject: [PATCH 06/13] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=EA=B2=8C=EC=9E=84?= =?UTF-8?q?=20=20=EA=B8=B0=EB=A1=9D=20=EC=9D=84=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/GameRecord.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/Record/GameRecord.swift diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Record/GameRecord.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Record/GameRecord.swift new file mode 100644 index 0000000..512840f --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Record/GameRecord.swift @@ -0,0 +1,32 @@ +// +// GameRecord.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +// MARK: - 기록 저장/출력 +actor GameRecord: Sendable { + private(set) var items: [Int] = [] + + func saveRecord(_ attempts: Int) async { + items.append(attempts) + } + + func isEmpty() -> Bool { + items.isEmpty + } + + func showRecords(using console: IOProvider) async { + console.show("< 게임 기록 보기 >") + if items.isEmpty { + console.show("완료된 게임 기록이 없습니다.") + return + } + for (idx, tries) in items.enumerated() { + console.show("\(idx + 1)번째 게임 : 시도 횟수 - \(tries)") + } + } +} From f1f72105fc68b3e8f8bec5f176084a031c8601e1 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 12:31:13 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=ED=8C=90=EB=B3=84?= =?UTF-8?q?=20=20=EA=B4=80=EB=A0=A8=20=ED=95=B4=EC=84=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Judge/JudgeEngine.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift new file mode 100644 index 0000000..e4b488c --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift @@ -0,0 +1,21 @@ +// +// JudgeEngine.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +/// 2) Judge: 판정 로직 +struct JudgeEngine: Sendable { + func judge(answer: [Int], guess: [Int]) -> JudgeResult { + precondition(answer.count == guess.count) + let strike = zip(answer, guess).filter { $0 == $1 }.count + let ball = Set(answer).intersection(guess).count - strike + if strike == answer.count { return .correct } + if strike == .zero && ball == .zero { return .nothing } + return .strikeAndBall(strike: strike, ball: ball) + } +} + From 1983dc5fc0c9a6190c58c1bf3dc96e0c2c076388 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 12:31:41 +0900 Subject: [PATCH 08/13] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=ED=95=B4=EB=8B=B9?= =?UTF-8?q?=20=20=EB=9D=BC=EC=9A=B4=EB=93=9C=EC=97=90=20=EC=96=B4=EB=96=A4?= =?UTF-8?q?=EA=B2=8C=20=ED=8C=90=EB=B3=84=ED=95=98=EA=B3=A0=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=EC=A7=80=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Round/RoundRunner.swift | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift new file mode 100644 index 0000000..e452e05 --- /dev/null +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift @@ -0,0 +1,46 @@ +// +// RoundRunner.swift +// SpartBaseBallGame +// +// Created by Wonji Suh on 8/28/25. +// + +import Foundation + +struct RoundRunner { + let digits: Int + let generator: AnswerGeneratorProtocol + let parser: GuessParser + let judge: JudgeEngine + let console: IOProvider + + func run() async -> Int { + console.show("< 게임을 시작합니다 >") + let answer = generator.make(digits: digits) +#if DEBUG + console.show("정답 생성: \(answer)") +#endif + + var attempts: Int = .zero + while true { + console.show("숫자를 입력하세요") + guard let line = await console.readLine(), + let guess = parser.parse(line) else { + console.show("올바르지 않은 입력값입니다") + continue + } + attempts += 1 + + switch judge.judge(answer: answer, guess: guess) { + case .correct: + console.show("정답입니다!") + return attempts + case .nothing: + console.show("Nothing") + case let .strikeAndBall(s, b): + console.show([s > .zero ? "\(s)스트라이크" : nil, + b > .zero ? "\(b)볼" : nil].compactMap { $0 }.joined(separator: " ")) + } + } + } +} From a1c80efe1c898da3155779f36e8769cda1571869 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 12:32:11 +0900 Subject: [PATCH 09/13] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=ED=95=84=EC=9A=94=ED=95=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=A7=8C=20=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/BaseBall/BaseBallGame.swift | 150 ++++-------------- 1 file changed, 33 insertions(+), 117 deletions(-) diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift index 28508c3..3256512 100644 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/BaseBall/BaseBallGame.swift @@ -9,131 +9,47 @@ import Foundation import LogMacro actor BaseBallGame { - private let digits = 3 - private var records: [Int] = [] + private let digits: Int private let generator: AnswerGeneratorProtocol - - init(records: [Int] = [], generator: AnswerGeneratorProtocol = RandomAnswerGenerator()) { + private let parser: GuessParser + private let judgeEngine: JudgeEngine + private let console: IOProvider + private var records: GameRecord + private let menu = MenuAction() + private let runner: RoundRunner + + init( + digits: Int = 3, + generator: AnswerGeneratorProtocol = RandomAnswerGenerator(), + judgeEngine: JudgeEngine = .init(), + console: IOProvider = ConsoleIO(), + records: GameRecord = .init() + ) { + self.digits = digits self.records = records self.generator = generator + self.parser = GuessParser(digits: digits, forbidLeadingZero: true) + self.judgeEngine = judgeEngine + self.console = console + self.records = records + self.runner = RoundRunner(digits: digits, generator: generator, parser: parser , judge: judgeEngine, console: console) } - // MARK: - 게임시작 관련 + // MARK: - 게임시작 func start() async { while true { - showMenu() - - guard let line = await readLineAsync()?.trimmingCharacters(in: .whitespacesAndNewlines), - let choice = Int(line) else { - #logDebug("올바르지 않은 입력입니다. 1~3 중 하나를 입력해주세요.") - continue - } - - switch choice { - case 1: - #logDebug("\(choice) // 1번 게임 시작하기 입력") - let attempts = await startGame() - await saveRecord(attempts) - case 2: - #logDebug("\(choice) // 2번 게임 기록 보기 입력") - await showRecords() - case 3: - #logDebug("\(choice) // 3번 종료하기 입력") - #logDebug("프로그램을 종료합니다.") - return - default: - #logDebug("올바르지 않은 입력입니다. 1~3 중 하나를 입력해주세요.") - } - } - } - - private func showMenu() { - #logDebug(""" - 환영합니다! 원하시는 번호를 입력해주세요 - 1. 게임 시작하기 2. 게임 기록 보기 3. 종료하기 - """) - } - - // MARK: - 게임 시작 - func startGame() async -> Int { - #logDebug("< 게임을 시작합니다 >") - let answer = generator.make(digits: digits) - #logDebug("정답 생성: \(answer)") - - var attempts = 0 - - while true { - #logDebug("숫자를 입력하세요") - guard let line = await readLineAsync(), - let guess = parseGuess(from: line) else { - #logDebug("올바르지 않은 입력값입니다") - continue - } - - attempts += 1 - let result = judge(answer: answer, guess: guess) - - switch result { - case .correct: - #logDebug("정답입니다!") - return attempts - - case .nothing: - #logDebug("Nothing") - - case .strikeAndBall(let strike, let ball): - var parts: [String] = [] - if strike > .zero { parts.append("\(strike)스트라이크") } - if ball > .zero { parts.append("\(ball)볼") } - #logDebug(parts.joined(separator: " ")) - } - } - } - - // MARK: - 기록 저장/출력 - private func saveRecord(_ attempts: Int) async { - records.append(attempts) - } - - private func showRecords() async { - #logDebug("< 게임 기록 보기 >") - if records.isEmpty { - #logDebug("완료된 게임 기록이 없습니다.") - return - } - - for (index, value) in records.enumerated() { - #logDebug("\(index + 1)번째 게임 : 시도 횟수 - \(value)") - } - } - - // MARK: - 입력 파싱 - private func parseGuess(from input: String) -> [Int]? { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count == digits, - trimmed.allSatisfy({ $0.isNumber }), - trimmed.first != "0" else { return nil } - - let numbers = trimmed.compactMap { Int(String($0)) } - guard Set(numbers).count == digits else { return nil } - return numbers - } - - // MARK: - 판정 - private func judge(answer: [Int], guess: [Int]) -> JudgeResult { - let strike = zip(answer, guess).filter { $0 == $1 }.count - let ball = Set(answer).intersection(guess).count - strike - if strike == answer.count { return .correct } - if strike == 0 && ball == 0 { return .nothing } - return .strikeAndBall(strike: strike, ball: ball) - } - - // MARK: - readLine 비동기 래핑 - func readLineAsync() async -> String? { - await withCheckedContinuation { continuation in - Task.detached(priority: .userInitiated) { - continuation.resume(returning: readLine()) + switch await menu.ask(using: console) { + case .startGame: + let attempts = await runner.run() + await records.saveRecord(attempts) + case .showRecords: + await records.showRecords(using: console) + case .invalid: + console.show("올바르지 않은 입력입니다. 1~3 중 하나를 입력해주세요.") + case .quit: + console.show("프로그램을 종료합니다.") + return } } } From 6f08d8334153a21ebdaa142c0e8f5f2b63d4f94d Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 12:32:37 +0900 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=94=A5[del]:=20=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EB=90=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/GameStart/GameManger.swift | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 SpartBaseBallGame/SpartBaseBallGame/Sources/GameStart/GameManger.swift diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/GameStart/GameManger.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/GameStart/GameManger.swift deleted file mode 100644 index 25aee43..0000000 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/GameStart/GameManger.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// GameManger.swift -// SpartBaseBallGame -// -// Created by Wonji Suh on 8/27/25. -// - -import Foundation -import Combine - -actor GameManger { - let baseBall = BaseBallGame() - // MARK: - 야구 게임 시작 - func baseBallGameStart() async { - await baseBall.start() - } -} From c0bc96f2a1bfff4a0d4509a6140a14b35fe0fd68 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 28 Aug 2025 17:46:59 +0900 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=AA=9B[chore]:=20=EC=95=88=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpartBaseBallGame/Sources/Judge/JudgeEngine.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift index e4b488c..7217e88 100644 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Judge/JudgeEngine.swift @@ -10,7 +10,6 @@ import Foundation /// 2) Judge: 판정 로직 struct JudgeEngine: Sendable { func judge(answer: [Int], guess: [Int]) -> JudgeResult { - precondition(answer.count == guess.count) let strike = zip(answer, guess).filter { $0 == $1 }.count let ball = Set(answer).intersection(guess).count - strike if strike == answer.count { return .correct } From d0c6a0737aa7b4f796512919f11c83a4853c24bd Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 29 Aug 2025 09:58:03 +0900 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=AA=9B[chore]:=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81=20=20=EB=B0=8F?= =?UTF-8?q?=20=20=EB=A6=AC=EB=93=9C=EB=AF=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +++++++++++++++++++ .../Sources/Menu/MenuAction.swift | 4 +- .../Sources/Round/RoundRunner.swift | 7 ++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 454cf64..5486d01 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,42 @@ 일관된 커밋 메시지를 작성하기 위한 가이드입니다. --- + + +## 📘 과제 소개 +### 🎯 과제 주제 +- Swift 기본 문법을 활용하여 **숫자 야구(Baseball) 게임**을 구현합니다. +- 콘솔 기반 프로그램으로, 사용자가 숫자를 입력해 정답을 맞히는 구조입니다. + +### 📌 요구 사항 +1. 랜덤한 정답 숫자 생성 (중복 없는 n자리 수) +2. 사용자 입력 처리 (`readLine()` 사용 → 비동기 처리 개선) +3. 정답과 입력값을 비교하여 **스트라이크 / 볼** 판정 +4. 정답을 맞히면 게임 종료 후 다시 시작 가능 +5. 잘못된 입력(중복 숫자, 범위 오류 등)에 대한 예외 처리 + +### 🛠️ 학습 목표 +- **Swift 기본 문법** (변수, 함수, 조건문, 반복문) +- **컬렉션 활용** (Array, Set) +- **함수 분리와 모듈화** +- **비동기 처리**(`Task`, `withCheckedContinuation`) +- **파일 분리 리팩토링**을 통한 코드 구조화 + +# 📌 트러블슈팅 +이 프로젝트를 진행하면서 중점적으로 개선한 부분은 다음과 같습니다. + +### 1) `readLine()` 비동기 처리 +- 기존의 `readLine()`은 입력 대기 동안 프로그램 전체를 멈추게 하는 **동기 방식**이었음. +- `Task`와 `withCheckedContinuation`을 활용하여 **비동기 입력 처리**로 개선. +- 입력 대기 중에도 안내 문구 출력 등 다른 동작과 병렬 실행 가능. + +### 2) 파일 분리 리팩토링 +- 초기 코드가 `main.swift`에 모두 섞여 있어 가독성과 유지보수성이 떨어짐. +- `AnswerGenerator.swift`, `InputParser.swift`, `Judge.swift`, `Game.swift` 등으로 **역할별 파일 분리**. +- 각 기능의 책임이 명확해져 수정과 확장이 용이해짐. + +--- + +## 🚀 실행 방법 +```bash +swift run diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift index b6c7fe1..048364d 100644 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Menu/MenuAction.swift @@ -19,9 +19,9 @@ struct MenuAction: Sendable { """, ) guard let line = await io.readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), - let n = Int(line) else { return .invalid } + let menuNumber = Int(line) else { return .invalid } - switch n { + switch menuNumber { case 1: return .startGame case 2: return .showRecords case 3: return .quit diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift index e452e05..ec0eaa1 100644 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/Round/RoundRunner.swift @@ -37,9 +37,10 @@ struct RoundRunner { return attempts case .nothing: console.show("Nothing") - case let .strikeAndBall(s, b): - console.show([s > .zero ? "\(s)스트라이크" : nil, - b > .zero ? "\(b)볼" : nil].compactMap { $0 }.joined(separator: " ")) + case let .strikeAndBall(strike, ball): + console.show( + [strike > .zero ? "\(strike)스트라이크" : nil, + ball > .zero ? "\(ball)볼" : nil].compactMap { $0 }.joined(separator: " ")) } } } From 241ffe28753297130496006691dbd748ab1f2321 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 29 Aug 2025 16:48:27 +0900 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=AA=9B[chore]:=20PR=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/GuessParser/GuessParser.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift b/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift index 8bc2664..68687f6 100644 --- a/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift +++ b/SpartBaseBallGame/SpartBaseBallGame/Sources/GuessParser/GuessParser.swift @@ -20,10 +20,10 @@ struct GuessParser: Sendable { // MARK: - 입력 파싱 func parse(_ input: String) -> [Int]? { - let s = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard s.count == digits, s.allSatisfy(\.isNumber) else { return nil } - if forbidLeadingZero && s.first == "0" { return nil } - let nums = s.compactMap { Int(String($0)) } + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count == digits, trimmed.allSatisfy(\.isNumber) else { return nil } + if forbidLeadingZero && trimmed.first == "0" { return nil } + let nums = trimmed.compactMap { Int(String($0)) } guard Set(nums).count == digits else { return nil } // 중복 금지 return nums }