Compare commits
No commits in common. "fd6e3355a5df3b8faaee3d10f242c9fa72e95809" and "9cc7352c60a97d5ba3d1bef6ed6da21446cd4a60" have entirely different histories.
fd6e3355a5
...
9cc7352c60
@ -105,29 +105,11 @@ final class GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var playerHandValue: Int {
|
var playerHandValue: Int {
|
||||||
// Only calculate value from visible face-up cards
|
engine.playerHand.value
|
||||||
guard visiblePlayerCards.count == playerCardsFaceUp.count else { return 0 }
|
|
||||||
guard playerCardsFaceUp.allSatisfy({ $0 }) else {
|
|
||||||
// If not all cards are face up, calculate value from only the face-up cards
|
|
||||||
let faceUpCards = zip(visiblePlayerCards, playerCardsFaceUp)
|
|
||||||
.filter { $1 }
|
|
||||||
.map { $0.0 }
|
|
||||||
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
|
||||||
}
|
|
||||||
return engine.playerHand.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var bankerHandValue: Int {
|
var bankerHandValue: Int {
|
||||||
// Only calculate value from visible face-up cards
|
engine.bankerHand.value
|
||||||
guard visibleBankerCards.count == bankerCardsFaceUp.count else { return 0 }
|
|
||||||
guard bankerCardsFaceUp.allSatisfy({ $0 }) else {
|
|
||||||
// If not all cards are face up, calculate value from only the face-up cards
|
|
||||||
let faceUpCards = zip(visibleBankerCards, bankerCardsFaceUp)
|
|
||||||
.filter { $1 }
|
|
||||||
.map { $0.0 }
|
|
||||||
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
|
||||||
}
|
|
||||||
return engine.bankerHand.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent results for the road display (last 20)
|
// Recent results for the road display (last 20)
|
||||||
@ -702,11 +684,9 @@ final class GameState {
|
|||||||
sound.playCardDeal()
|
sound.playCardDeal()
|
||||||
visiblePlayerCards.append(playerThird)
|
visiblePlayerCards.append(playerThird)
|
||||||
playerCardsFaceUp.append(false)
|
playerCardsFaceUp.append(false)
|
||||||
CasinoDesign.debugLog("🃏 Player 3rd card dealt face-down: cards=\(visiblePlayerCards.count), faceUp=\(playerCardsFaceUp)")
|
|
||||||
try? await Task.sleep(for: shortDelay)
|
try? await Task.sleep(for: shortDelay)
|
||||||
sound.playCardFlip()
|
sound.playCardFlip()
|
||||||
playerCardsFaceUp[2] = true
|
playerCardsFaceUp[2] = true
|
||||||
CasinoDesign.debugLog("🃏 Player 3rd card flipped: cards=\(visiblePlayerCards.count), faceUp=\(playerCardsFaceUp)")
|
|
||||||
try? await Task.sleep(for: flipDelay)
|
try? await Task.sleep(for: flipDelay)
|
||||||
} else {
|
} else {
|
||||||
visiblePlayerCards.append(playerThird)
|
visiblePlayerCards.append(playerThird)
|
||||||
@ -722,11 +702,9 @@ final class GameState {
|
|||||||
sound.playCardDeal()
|
sound.playCardDeal()
|
||||||
visibleBankerCards.append(bankerThird)
|
visibleBankerCards.append(bankerThird)
|
||||||
bankerCardsFaceUp.append(false)
|
bankerCardsFaceUp.append(false)
|
||||||
CasinoDesign.debugLog("🃏 Banker 3rd card dealt face-down: cards=\(visibleBankerCards.count), faceUp=\(bankerCardsFaceUp)")
|
|
||||||
try? await Task.sleep(for: shortDelay)
|
try? await Task.sleep(for: shortDelay)
|
||||||
sound.playCardFlip()
|
sound.playCardFlip()
|
||||||
bankerCardsFaceUp[2] = true
|
bankerCardsFaceUp[2] = true
|
||||||
CasinoDesign.debugLog("🃏 Banker 3rd card flipped: cards=\(visibleBankerCards.count), faceUp=\(bankerCardsFaceUp)")
|
|
||||||
try? await Task.sleep(for: dealDelay)
|
try? await Task.sleep(for: dealDelay)
|
||||||
} else {
|
} else {
|
||||||
visibleBankerCards.append(bankerThird)
|
visibleBankerCards.append(bankerThird)
|
||||||
|
|||||||
@ -164,12 +164,8 @@ struct GameTableView: View {
|
|||||||
checkOnboardingHints()
|
checkOnboardingHints()
|
||||||
},
|
},
|
||||||
onStartPlaying: {
|
onStartPlaying: {
|
||||||
// Mark all hints as shown FIRST so they don't appear
|
|
||||||
state.onboarding.markHintShown("bettingZone")
|
|
||||||
state.onboarding.markHintShown("dealButton")
|
|
||||||
state.onboarding.markHintShown("firstResult")
|
|
||||||
state.onboarding.completeWelcome()
|
|
||||||
showWelcome = false
|
showWelcome = false
|
||||||
|
state.onboarding.completeWelcome()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,28 +206,14 @@ struct CardsDisplayArea: View {
|
|||||||
// MARK: - Private Views
|
// MARK: - Private Views
|
||||||
|
|
||||||
private func playerHandSection(width: CGFloat) -> some View {
|
private func playerHandSection(width: CGFloat) -> some View {
|
||||||
// Calculate value from face-up cards
|
VStack(spacing: Design.Spacing.small) {
|
||||||
let visibleValue: Int = {
|
|
||||||
guard !playerCards.isEmpty else { return 0 }
|
|
||||||
let faceUpCards = zip(playerCards, playerCardsFaceUp)
|
|
||||||
.filter { $1 }
|
|
||||||
.map { $0.0 }
|
|
||||||
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
|
||||||
}()
|
|
||||||
|
|
||||||
let allCardsFaceUp = !playerCards.isEmpty && playerCardsFaceUp.count == playerCards.count && playerCardsFaceUp.allSatisfy({ $0 })
|
|
||||||
let _ = CasinoDesign.debugLog("🎯 Player: cards=\(playerCards.count), faceUp=\(playerCardsFaceUp.count), allFaceUp=\(allCardsFaceUp), visibleValue=\(visibleValue)")
|
|
||||||
|
|
||||||
return VStack(spacing: Design.Spacing.small) {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
Text("PLAYER")
|
Text("PLAYER")
|
||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
// Always show value when there are cards - it updates as cards become visible
|
if !playerCards.isEmpty && playerCardsFaceUp.contains(true) {
|
||||||
if !playerCards.isEmpty {
|
HandValueBadge(value: playerValue, color: .blue)
|
||||||
HandValueBadge(value: visibleValue, color: .blue)
|
|
||||||
.animation(nil, value: visibleValue) // No animation when value changes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minHeight: labelRowMinHeight)
|
.frame(minHeight: labelRowMinHeight)
|
||||||
@ -248,28 +234,14 @@ struct CardsDisplayArea: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func bankerHandSection(width: CGFloat) -> some View {
|
private func bankerHandSection(width: CGFloat) -> some View {
|
||||||
// Calculate value from face-up cards
|
VStack(spacing: Design.Spacing.small) {
|
||||||
let visibleValue: Int = {
|
|
||||||
guard !bankerCards.isEmpty else { return 0 }
|
|
||||||
let faceUpCards = zip(bankerCards, bankerCardsFaceUp)
|
|
||||||
.filter { $1 }
|
|
||||||
.map { $0.0 }
|
|
||||||
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
|
||||||
}()
|
|
||||||
|
|
||||||
let allCardsFaceUp = !bankerCards.isEmpty && bankerCardsFaceUp.count == bankerCards.count && bankerCardsFaceUp.allSatisfy({ $0 })
|
|
||||||
let _ = CasinoDesign.debugLog("🎯 Banker: cards=\(bankerCards.count), faceUp=\(bankerCardsFaceUp.count), allFaceUp=\(allCardsFaceUp), visibleValue=\(visibleValue)")
|
|
||||||
|
|
||||||
return VStack(spacing: Design.Spacing.small) {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
Text("BANKER")
|
Text("BANKER")
|
||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
// Always show value when there are cards - it updates as cards become visible
|
if !bankerCards.isEmpty && bankerCardsFaceUp.contains(true) {
|
||||||
if !bankerCards.isEmpty {
|
HandValueBadge(value: bankerValue, color: .red)
|
||||||
HandValueBadge(value: visibleValue, color: .red)
|
|
||||||
.animation(nil, value: visibleValue) // No animation when value changes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minHeight: labelRowMinHeight)
|
.frame(minHeight: labelRowMinHeight)
|
||||||
|
|||||||
@ -24,52 +24,28 @@ struct BaccaratTests {
|
|||||||
// Test with a King (value 0) and an 8 (value 8)
|
// Test with a King (value 0) and an 8 (value 8)
|
||||||
hand.addCard(Card(suit: .spades, rank: .king))
|
hand.addCard(Card(suit: .spades, rank: .king))
|
||||||
hand.addCard(Card(suit: .hearts, rank: .eight))
|
hand.addCard(Card(suit: .hearts, rank: .eight))
|
||||||
#expect(hand.value == 8, "King (0) + Eight (8) should equal 8")
|
#expect(hand.value == 8)
|
||||||
|
|
||||||
// Test with two cards that sum to more than 10
|
// Test with two cards that sum to more than 10
|
||||||
hand.clear()
|
hand.clear()
|
||||||
hand.addCard(Card(suit: .spades, rank: .seven))
|
hand.addCard(Card(suit: .spades, rank: .seven))
|
||||||
hand.addCard(Card(suit: .hearts, rank: .five))
|
hand.addCard(Card(suit: .hearts, rank: .five))
|
||||||
#expect(hand.value == 2, "Seven (7) + Five (5) = 12, 12 % 10 should equal 2")
|
#expect(hand.value == 2) // 7 + 5 = 12, 12 % 10 = 2
|
||||||
|
|
||||||
// Test natural 9
|
// Test natural 9
|
||||||
hand.clear()
|
hand.clear()
|
||||||
hand.addCard(Card(suit: .clubs, rank: .four))
|
hand.addCard(Card(suit: .clubs, rank: .four))
|
||||||
hand.addCard(Card(suit: .diamonds, rank: .five))
|
hand.addCard(Card(suit: .diamonds, rank: .five))
|
||||||
#expect(hand.value == 9, "Four (4) + Five (5) should equal 9")
|
#expect(hand.value == 9)
|
||||||
#expect(hand.isNatural == true, "Hand with 2 cards and value 9 is a natural")
|
#expect(hand.isNatural == true)
|
||||||
|
|
||||||
// Test with three cards
|
// Test with three cards
|
||||||
hand.clear()
|
hand.clear()
|
||||||
hand.addCard(Card(suit: .spades, rank: .four))
|
hand.addCard(Card(suit: .spades, rank: .four))
|
||||||
hand.addCard(Card(suit: .hearts, rank: .three))
|
hand.addCard(Card(suit: .hearts, rank: .three))
|
||||||
hand.addCard(Card(suit: .clubs, rank: .two))
|
hand.addCard(Card(suit: .clubs, rank: .two))
|
||||||
#expect(hand.value == 9, "Four (4) + Three (3) + Two (2) should equal 9")
|
#expect(hand.value == 9) // 4 + 3 + 2 = 9
|
||||||
#expect(hand.isNatural == false, "Hand with 3 cards is not a natural")
|
#expect(hand.isNatural == false) // Not a natural (3 cards)
|
||||||
|
|
||||||
// Test face cards (all should be 0)
|
|
||||||
hand.clear()
|
|
||||||
hand.addCard(Card(suit: .spades, rank: .king))
|
|
||||||
hand.addCard(Card(suit: .hearts, rank: .queen))
|
|
||||||
#expect(hand.value == 0, "King (0) + Queen (0) should equal 0")
|
|
||||||
|
|
||||||
// Test 10 card (should be 0 in baccarat)
|
|
||||||
hand.clear()
|
|
||||||
hand.addCard(Card(suit: .diamonds, rank: .ten))
|
|
||||||
hand.addCard(Card(suit: .clubs, rank: .nine))
|
|
||||||
#expect(hand.value == 9, "Ten (0) + Nine (9) should equal 9")
|
|
||||||
|
|
||||||
// Test Ace (should be 1)
|
|
||||||
hand.clear()
|
|
||||||
hand.addCard(Card(suit: .hearts, rank: .ace))
|
|
||||||
hand.addCard(Card(suit: .spades, rank: .ace))
|
|
||||||
#expect(hand.value == 2, "Ace (1) + Ace (1) should equal 2")
|
|
||||||
|
|
||||||
// Test modulo 10 with higher values
|
|
||||||
hand.clear()
|
|
||||||
hand.addCard(Card(suit: .diamonds, rank: .nine))
|
|
||||||
hand.addCard(Card(suit: .clubs, rank: .nine))
|
|
||||||
#expect(hand.value == 8, "Nine (9) + Nine (9) = 18, 18 % 10 should equal 8")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func engineHandValues() async throws {
|
@Test func engineHandValues() async throws {
|
||||||
@ -89,12 +65,12 @@ struct BaccaratTests {
|
|||||||
#expect(engine.bankerHand.value <= 9)
|
#expect(engine.bankerHand.value <= 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func gameStateHandValues() async throws {
|
@Test func gameStateHandValues() async throws {
|
||||||
let state = GameState()
|
let state = GameState()
|
||||||
|
|
||||||
// Initially should be 0
|
// Initially should be 0
|
||||||
#expect(state.playerHandValue == 0, "Player hand value should be 0 when no cards dealt")
|
#expect(state.playerHandValue == 0)
|
||||||
#expect(state.bankerHandValue == 0, "Banker hand value should be 0 when no cards dealt")
|
#expect(state.bankerHandValue == 0)
|
||||||
|
|
||||||
// Place a bet
|
// Place a bet
|
||||||
state.placeBet(type: .player, amount: 100)
|
state.placeBet(type: .player, amount: 100)
|
||||||
@ -103,26 +79,14 @@ struct BaccaratTests {
|
|||||||
await state.deal()
|
await state.deal()
|
||||||
|
|
||||||
// After dealing, both hands should have values between 0-9
|
// After dealing, both hands should have values between 0-9
|
||||||
#expect(state.playerHandValue >= 0, "Player hand value should be >= 0")
|
#expect(state.playerHandValue >= 0)
|
||||||
#expect(state.playerHandValue <= 9, "Player hand value should be <= 9")
|
#expect(state.playerHandValue <= 9)
|
||||||
#expect(state.bankerHandValue >= 0, "Banker hand value should be >= 0")
|
#expect(state.bankerHandValue >= 0)
|
||||||
#expect(state.bankerHandValue <= 9, "Banker hand value should be <= 9")
|
#expect(state.bankerHandValue <= 9)
|
||||||
|
|
||||||
// Verify visible cards match engine cards
|
// Verify visible cards match engine cards
|
||||||
#expect(state.visiblePlayerCards.count >= 2, "Player should have at least 2 cards")
|
#expect(state.visiblePlayerCards.count >= 2)
|
||||||
#expect(state.visibleBankerCards.count >= 2, "Banker should have at least 2 cards")
|
#expect(state.visibleBankerCards.count >= 2)
|
||||||
|
|
||||||
// Verify cards are face up (needed for badges to show)
|
|
||||||
#expect(state.playerCardsFaceUp.count >= 2, "Player should have face-up state for cards")
|
|
||||||
#expect(state.bankerCardsFaceUp.count >= 2, "Banker should have face-up state for cards")
|
|
||||||
#expect(state.playerCardsFaceUp.contains(true), "At least one player card should be face up")
|
|
||||||
#expect(state.bankerCardsFaceUp.contains(true), "At least one banker card should be face up")
|
|
||||||
|
|
||||||
// Verify the visible hand values match what the engine calculates
|
|
||||||
let manualPlayerValue = state.visiblePlayerCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
|
||||||
let manualBankerValue = state.visibleBankerCards.reduce(0) { $0 + $1.baccaratValue } % 10
|
|
||||||
#expect(state.playerHandValue == manualPlayerValue, "Visible cards should match calculated value for player")
|
|
||||||
#expect(state.bankerHandValue == manualBankerValue, "Visible cards should match calculated value for banker")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,245 +8,28 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class BaccaratUITests: XCTestCase {
|
final class BaccaratUITests: XCTestCase {
|
||||||
|
|
||||||
var app: XCUIApplication!
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||||
|
|
||||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
|
|
||||||
app = XCUIApplication()
|
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||||
app.launchArguments = ["UI-TESTING"]
|
|
||||||
|
|
||||||
// In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
override func tearDownWithError() throws {
|
||||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
app = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testExample() throws {
|
func testExample() throws {
|
||||||
// UI tests must launch the application that they test.
|
// UI tests must launch the application that they test.
|
||||||
|
let app = XCUIApplication()
|
||||||
app.launch()
|
app.launch()
|
||||||
|
|
||||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UI Navigation Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testAppLaunches() throws {
|
|
||||||
app.launch()
|
|
||||||
XCTAssertTrue(app.exists, "App should launch successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testTopBarElements() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait for the app to load
|
|
||||||
let balanceText = app.staticTexts.matching(identifier: "balance").firstMatch
|
|
||||||
XCTAssertTrue(balanceText.waitForExistence(timeout: 2), "Balance should be visible")
|
|
||||||
|
|
||||||
// Check settings button exists
|
|
||||||
let settingsButton = app.buttons.matching(identifier: "settings").firstMatch
|
|
||||||
XCTAssertTrue(settingsButton.exists, "Settings button should exist")
|
|
||||||
|
|
||||||
// Check help button exists
|
|
||||||
let helpButton = app.buttons.matching(identifier: "help").firstMatch
|
|
||||||
XCTAssertTrue(helpButton.exists, "Help button should exist")
|
|
||||||
|
|
||||||
// Check stats button exists
|
|
||||||
let statsButton = app.buttons.matching(identifier: "stats").firstMatch
|
|
||||||
XCTAssertTrue(statsButton.exists, "Stats button should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testSettingsSheet() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Find and tap settings button
|
|
||||||
let settingsButton = app.buttons.matching(identifier: "settings").firstMatch
|
|
||||||
XCTAssertTrue(settingsButton.waitForExistence(timeout: 2), "Settings button should exist")
|
|
||||||
settingsButton.tap()
|
|
||||||
|
|
||||||
// Wait for settings sheet to appear
|
|
||||||
let settingsSheet = app.scrollViews.firstMatch
|
|
||||||
XCTAssertTrue(settingsSheet.waitForExistence(timeout: 2), "Settings sheet should appear")
|
|
||||||
|
|
||||||
// Check for some expected elements in settings
|
|
||||||
XCTAssertTrue(app.staticTexts["Table Limits"].waitForExistence(timeout: 1) ||
|
|
||||||
app.staticTexts["Game Settings"].waitForExistence(timeout: 1),
|
|
||||||
"Settings content should be visible")
|
|
||||||
|
|
||||||
// Close settings (tap outside or dismiss button)
|
|
||||||
if app.buttons["Done"].exists {
|
|
||||||
app.buttons["Done"].tap()
|
|
||||||
} else {
|
|
||||||
// Tap outside to dismiss
|
|
||||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testHelpSheet() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Find and tap help button
|
|
||||||
let helpButton = app.buttons.matching(identifier: "help").firstMatch
|
|
||||||
XCTAssertTrue(helpButton.waitForExistence(timeout: 2), "Help button should exist")
|
|
||||||
helpButton.tap()
|
|
||||||
|
|
||||||
// Wait for help sheet to appear
|
|
||||||
let helpSheet = app.scrollViews.firstMatch
|
|
||||||
XCTAssertTrue(helpSheet.waitForExistence(timeout: 2), "Help sheet should appear")
|
|
||||||
|
|
||||||
// Check for rules content
|
|
||||||
XCTAssertTrue(app.staticTexts["Rules"].exists ||
|
|
||||||
app.staticTexts["How to Play"].exists,
|
|
||||||
"Rules content should be visible")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Betting Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testChipSelector() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait for chip selector to appear
|
|
||||||
let chipSelector = app.otherElements["chip-selector"]
|
|
||||||
XCTAssertTrue(chipSelector.waitForExistence(timeout: 2) ||
|
|
||||||
app.buttons.matching(NSPredicate(format: "label CONTAINS '$'")).count > 0,
|
|
||||||
"Chip selector should be visible")
|
|
||||||
|
|
||||||
// Find a chip button (they should have $ in the label)
|
|
||||||
let chipButtons = app.buttons.matching(NSPredicate(format: "label CONTAINS '$'"))
|
|
||||||
XCTAssertGreaterThan(chipButtons.count, 0, "Should have chip buttons")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testBettingZones() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait for betting table
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Check for Player, Banker, and Tie betting zones
|
|
||||||
let playerZone = app.staticTexts["PLAYER"]
|
|
||||||
let bankerZone = app.staticTexts["BANKER"]
|
|
||||||
let tieZone = app.staticTexts["TIE"]
|
|
||||||
|
|
||||||
XCTAssertTrue(playerZone.exists, "Player betting zone should exist")
|
|
||||||
XCTAssertTrue(bankerZone.exists, "Banker betting zone should exist")
|
|
||||||
XCTAssertTrue(tieZone.exists, "Tie betting zone should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testPlaceBet() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait for betting area
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Find deal button (should be disabled initially)
|
|
||||||
let dealButton = app.buttons["Deal"]
|
|
||||||
XCTAssertTrue(dealButton.waitForExistence(timeout: 2), "Deal button should exist")
|
|
||||||
|
|
||||||
// Initially deal button should be disabled
|
|
||||||
XCTAssertFalse(dealButton.isEnabled, "Deal button should be disabled before bet")
|
|
||||||
|
|
||||||
// Tap on Player betting zone
|
|
||||||
let playerZone = app.staticTexts["PLAYER"]
|
|
||||||
if playerZone.exists {
|
|
||||||
// Tap multiple times to ensure we meet minimum bet
|
|
||||||
for _ in 1...5 {
|
|
||||||
playerZone.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deal button should now be enabled after placing sufficient bet
|
|
||||||
sleep(1)
|
|
||||||
// Note: Button might still be disabled if bet is below minimum
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testClearButton() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait and place a bet
|
|
||||||
sleep(1)
|
|
||||||
let playerZone = app.staticTexts["PLAYER"]
|
|
||||||
if playerZone.exists {
|
|
||||||
playerZone.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find clear button
|
|
||||||
let clearButton = app.buttons["Clear"]
|
|
||||||
if clearButton.exists {
|
|
||||||
clearButton.tap()
|
|
||||||
|
|
||||||
// After clearing, deal button should be disabled again
|
|
||||||
let dealButton = app.buttons["Deal"]
|
|
||||||
XCTAssertFalse(dealButton.isEnabled, "Deal button should be disabled after clear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Gameplay Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testDealCards() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Place a bet on Player by tapping multiple times
|
|
||||||
let playerZone = app.staticTexts["PLAYER"]
|
|
||||||
if playerZone.exists {
|
|
||||||
for _ in 1...5 {
|
|
||||||
playerZone.tap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and tap deal button
|
|
||||||
let dealButton = app.buttons["Deal"]
|
|
||||||
if dealButton.waitForExistence(timeout: 2) && dealButton.isEnabled {
|
|
||||||
dealButton.tap()
|
|
||||||
|
|
||||||
// Wait for cards to be dealt
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// After dealing, we should see hand value badges or a result
|
|
||||||
// Check for the PLAYER and BANKER labels which should still be visible
|
|
||||||
let playerLabel = app.staticTexts["PLAYER"]
|
|
||||||
let bankerLabel = app.staticTexts["BANKER"]
|
|
||||||
|
|
||||||
XCTAssertTrue(playerLabel.exists, "Player label should be visible after deal")
|
|
||||||
XCTAssertTrue(bankerLabel.exists, "Banker label should be visible after deal")
|
|
||||||
|
|
||||||
// Eventually a New Round button should appear
|
|
||||||
let newRoundButton = app.buttons["New Round"]
|
|
||||||
XCTAssertTrue(newRoundButton.waitForExistence(timeout: 5), "New Round button should appear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testRoadMapExists() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Road map might not be visible on small screens or in portrait mode
|
|
||||||
// This is an optional test that checks if enabled in settings
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Look for history text or road map elements
|
|
||||||
let historyText = app.staticTexts["HISTORY"]
|
|
||||||
// Road map is conditional, so this is not a required assertion
|
|
||||||
// Just checking it doesn't crash
|
|
||||||
_ = historyText.exists
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testLaunchPerformance() throws {
|
func testLaunchPerformance() throws {
|
||||||
|
|||||||
@ -1,629 +0,0 @@
|
|||||||
# Game Center Integration - Baccarat
|
|
||||||
|
|
||||||
This document outlines the Game Center achievements for Baccarat and the game-specific integration steps.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
✅ Complete `CasinoKit/GAME_CENTER_PLAN.md` implementation first (shared infrastructure)
|
|
||||||
|
|
||||||
## Achievement Design Philosophy
|
|
||||||
|
|
||||||
Focus on **gameplay milestones**, **pattern recognition**, and **understanding Baccarat strategy**, not pure luck or bankroll metrics.
|
|
||||||
|
|
||||||
Achievements celebrate:
|
|
||||||
- 🎲 Understanding Baccarat rules and betting
|
|
||||||
- 📊 Pattern recognition (road maps)
|
|
||||||
- 💎 High-stakes play (earned through gameplay)
|
|
||||||
- 🎯 Mastery of side bets
|
|
||||||
|
|
||||||
## Baccarat Achievements
|
|
||||||
|
|
||||||
### 🎓 Learning & First Steps Category
|
|
||||||
|
|
||||||
#### 1. **First Hand**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.first_hand`
|
|
||||||
- **Description:** Play your first hand of Baccarat
|
|
||||||
- **Progress:** Complete on first hand
|
|
||||||
- **Points:** 5
|
|
||||||
- **Icon:** `play.circle.fill`
|
|
||||||
- **Trigger:** After first `finishRound()` completes
|
|
||||||
|
|
||||||
#### 2. **Player Believer**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.player_wins_10`
|
|
||||||
- **Description:** Win 10 times betting on Player
|
|
||||||
- **Progress:** Incremental (0-10)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `person.fill`
|
|
||||||
- **Trigger:** When Player bet wins
|
|
||||||
|
|
||||||
#### 3. **Banker's Trust**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.banker_wins_25`
|
|
||||||
- **Description:** Win 25 times betting on Banker
|
|
||||||
- **Progress:** Incremental (0-25)
|
|
||||||
- **Points:** 15
|
|
||||||
- **Icon:** `building.columns.fill`
|
|
||||||
- **Trigger:** When Banker bet wins
|
|
||||||
|
|
||||||
#### 4. **Tie Hunter**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.tie_wins_5`
|
|
||||||
- **Description:** Win 5 times betting on Tie
|
|
||||||
- **Progress:** Incremental (0-5)
|
|
||||||
- **Points:** 15
|
|
||||||
- **Icon:** "equal.circle.fill"
|
|
||||||
- **Trigger:** When Tie bet wins
|
|
||||||
- **Note:** Higher points due to rarity (14.4% house edge)
|
|
||||||
|
|
||||||
### 🎲 Natural & Special Hands Category
|
|
||||||
|
|
||||||
#### 5. **Natural Talent**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.naturals_10`
|
|
||||||
- **Description:** Get 10 naturals (8 or 9 on initial two cards)
|
|
||||||
- **Progress:** Incremental (0-10)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `star.fill`
|
|
||||||
- **Trigger:** When either Player or Banker gets natural 8 or 9
|
|
||||||
|
|
||||||
#### 6. **Natural Legend**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.naturals_50`
|
|
||||||
- **Description:** Get 50 naturals
|
|
||||||
- **Progress:** Incremental (0-50)
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `star.circle.fill`
|
|
||||||
- **Trigger:** When either Player or Banker gets natural (extended)
|
|
||||||
|
|
||||||
#### 7. **Perfect Pair**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.pair_bets_10`
|
|
||||||
- **Description:** Win Player Pair or Banker Pair side bet 10 times
|
|
||||||
- **Progress:** Incremental (0-10)
|
|
||||||
- **Points:** 15
|
|
||||||
- **Icon:** `suit.heart.fill`
|
|
||||||
- **Trigger:** When either pair bet wins
|
|
||||||
|
|
||||||
#### 8. **Dragon Master**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.dragon_bonus_10`
|
|
||||||
- **Description:** Win Dragon Bonus side bet 10 times
|
|
||||||
- **Progress:** Incremental (0-10)
|
|
||||||
- **Points:** 15
|
|
||||||
- **Icon:** `flame.fill`
|
|
||||||
- **Trigger:** When Dragon Bonus bet wins
|
|
||||||
|
|
||||||
#### 9. **Dragon Legend**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.dragon_bonus_win_by_9`
|
|
||||||
- **Description:** Win Dragon Bonus with a 9-point margin (30:1 payout)
|
|
||||||
- **Progress:** Complete when achieved
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `flame.circle.fill`
|
|
||||||
- **Trigger:** When Dragon Bonus pays 30:1
|
|
||||||
- **Note:** Very rare achievement
|
|
||||||
|
|
||||||
### 💪 Persistence & Mastery Category
|
|
||||||
|
|
||||||
#### 10. **Beginner**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.rounds_50`
|
|
||||||
- **Description:** Play 50 total rounds
|
|
||||||
- **Progress:** Incremental (0-50)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `person.fill`
|
|
||||||
- **Trigger:** After each round completes
|
|
||||||
|
|
||||||
#### 11. **Enthusiast**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.rounds_250`
|
|
||||||
- **Description:** Play 250 total rounds
|
|
||||||
- **Progress:** Incremental (0-250)
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `person.2.fill`
|
|
||||||
- **Trigger:** After each round completes
|
|
||||||
|
|
||||||
#### 12. **Expert**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.rounds_500`
|
|
||||||
- **Description:** Play 500 total rounds
|
|
||||||
- **Progress:** Incremental (0-500)
|
|
||||||
- **Points:** 50
|
|
||||||
- **Icon:** `person.3.fill`
|
|
||||||
- **Trigger:** After each round completes
|
|
||||||
|
|
||||||
#### 13. **Comeback Kid**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.comeback`
|
|
||||||
- **Description:** Recover from under $100 to $10,000 in a single session
|
|
||||||
- **Progress:** Complete when achieved
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `arrow.up.right.circle.fill`
|
|
||||||
- **Trigger:** When balance reaches $10k after being below $100 in same session
|
|
||||||
- **Implementation Note:** Track session low point
|
|
||||||
|
|
||||||
### 📊 Pattern Recognition & Strategy Category
|
|
||||||
|
|
||||||
#### 14. **Streak Spotter**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.spot_streaks_5`
|
|
||||||
- **Description:** Correctly predict and bet on a streak 5 times (3+ same outcome in a row)
|
|
||||||
- **Progress:** Incremental (0-5)
|
|
||||||
- **Points:** 20
|
|
||||||
- **Icon:** `chart.line.uptrend.xyaxis`
|
|
||||||
- **Trigger:** When player bets same side 3+ times in a row and all win
|
|
||||||
- **Implementation Note:** Track consecutive wins on same bet type
|
|
||||||
|
|
||||||
#### 15. **Road Map Reader**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.view_road_map_25`
|
|
||||||
- **Description:** View the road map history 25 times
|
|
||||||
- **Progress:** Incremental (0-25)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `map.fill`
|
|
||||||
- **Trigger:** Track when road map view is expanded/scrolled
|
|
||||||
- **Implementation Note:** Encourages engagement with pattern tracking feature
|
|
||||||
|
|
||||||
### 💎 High Stakes Category
|
|
||||||
|
|
||||||
#### 16. **High Roller**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.bet_50000`
|
|
||||||
- **Description:** Place a single bet of $50,000 or more
|
|
||||||
- **Progress:** Complete when achieved
|
|
||||||
- **Points:** 20
|
|
||||||
- **Icon:** `dollarsign.circle.fill`
|
|
||||||
- **Trigger:** When any bet equals or exceeds $50,000
|
|
||||||
- **Implementation Note:** Must be earned through gameplay (requires VIP table limits)
|
|
||||||
|
|
||||||
#### 17. **Big Win**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.single_win_100000`
|
|
||||||
- **Description:** Win $100,000 or more in a single hand
|
|
||||||
- **Progress:** Complete when achieved
|
|
||||||
- **Points:** 30
|
|
||||||
- **Icon:** `banknote.fill`
|
|
||||||
- **Trigger:** When net profit from a single round ≥ $100,000
|
|
||||||
- **Implementation Note:** Possible with Dragon Bonus on large bet
|
|
||||||
|
|
||||||
### 🏆 Elite Category
|
|
||||||
|
|
||||||
#### 18. **Side Bet Specialist**
|
|
||||||
- **ID:** `com.yourdomain.baccarat.side_bets_total_25`
|
|
||||||
- **Description:** Win any side bet 25 times total
|
|
||||||
- **Progress:** Incremental (0-25)
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `plus.circle.fill`
|
|
||||||
- **Trigger:** Any time a side bet wins (Pairs or Dragon Bonus)
|
|
||||||
|
|
||||||
#### 19. **James Bond** (Hidden Achievement)
|
|
||||||
- **ID:** `com.yourdomain.baccarat.james_bond`
|
|
||||||
- **Description:** Play 100 consecutive hands betting only on Banker
|
|
||||||
- **Progress:** Incremental (0-100, resets if different bet placed)
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `tuxedo.fill` (or `theatermasks.fill`)
|
|
||||||
- **Trigger:** Track consecutive Banker-only bets
|
|
||||||
- **Implementation Note:** Resets to 0 if player bets on Player or Tie
|
|
||||||
- **Fun fact:** James Bond's favorite game and typical bet
|
|
||||||
|
|
||||||
#### 20. **Table Explorer** (Hidden Achievement)
|
|
||||||
- **ID:** `com.yourdomain.baccarat.all_table_limits`
|
|
||||||
- **Description:** Play at least 10 rounds at each table limit level
|
|
||||||
- **Progress:** Incremental (0-50, 10 per table × 5 tables)
|
|
||||||
- **Points:** 15
|
|
||||||
- **Icon:** `chart.bar.fill`
|
|
||||||
- **Trigger:** Track rounds played at each table limit (Casual, Low, Medium, High, VIP)
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### 1. Define Achievement Enum
|
|
||||||
|
|
||||||
**File:** `Baccarat/Baccarat/Models/BaccaratAchievement.swift` (new file)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import CasinoKit
|
|
||||||
import GameKit
|
|
||||||
|
|
||||||
enum BaccaratAchievement: String, AchievementDefinition {
|
|
||||||
case firstHand = "first_hand"
|
|
||||||
case playerWins10 = "player_wins_10"
|
|
||||||
case bankerWins25 = "banker_wins_25"
|
|
||||||
case tieWins5 = "tie_wins_5"
|
|
||||||
case naturals10 = "naturals_10"
|
|
||||||
case naturals50 = "naturals_50"
|
|
||||||
case pairBets10 = "pair_bets_10"
|
|
||||||
case dragonBonus10 = "dragon_bonus_10"
|
|
||||||
case dragonBonusWinBy9 = "dragon_bonus_win_by_9"
|
|
||||||
case rounds50 = "rounds_50"
|
|
||||||
case rounds250 = "rounds_250"
|
|
||||||
case rounds500 = "rounds_500"
|
|
||||||
case comeback = "comeback"
|
|
||||||
case spotStreaks5 = "spot_streaks_5"
|
|
||||||
case viewRoadMap25 = "view_road_map_25"
|
|
||||||
case bet50000 = "bet_50000"
|
|
||||||
case singleWin100000 = "single_win_100000"
|
|
||||||
case sideBetsTotal25 = "side_bets_total_25"
|
|
||||||
case jamesBond = "james_bond"
|
|
||||||
case allTableLimits = "all_table_limits"
|
|
||||||
|
|
||||||
var identifier: String {
|
|
||||||
"com.yourdomain.baccarat.\(rawValue)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .firstHand: return String(localized: "First Hand")
|
|
||||||
case .playerWins10: return String(localized: "Player Believer")
|
|
||||||
case .bankerWins25: return String(localized: "Banker's Trust")
|
|
||||||
case .tieWins5: return String(localized: "Tie Hunter")
|
|
||||||
case .naturals10: return String(localized: "Natural Talent")
|
|
||||||
case .naturals50: return String(localized: "Natural Legend")
|
|
||||||
case .pairBets10: return String(localized: "Perfect Pair")
|
|
||||||
case .dragonBonus10: return String(localized: "Dragon Master")
|
|
||||||
case .dragonBonusWinBy9: return String(localized: "Dragon Legend")
|
|
||||||
case .rounds50: return String(localized: "Beginner")
|
|
||||||
case .rounds250: return String(localized: "Enthusiast")
|
|
||||||
case .rounds500: return String(localized: "Expert")
|
|
||||||
case .comeback: return String(localized: "Comeback Kid")
|
|
||||||
case .spotStreaks5: return String(localized: "Streak Spotter")
|
|
||||||
case .viewRoadMap25: return String(localized: "Road Map Reader")
|
|
||||||
case .bet50000: return String(localized: "High Roller")
|
|
||||||
case .singleWin100000: return String(localized: "Big Win")
|
|
||||||
case .sideBetsTotal25: return String(localized: "Side Bet Specialist")
|
|
||||||
case .jamesBond: return String(localized: "James Bond")
|
|
||||||
case .allTableLimits: return String(localized: "Table Explorer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
// Full descriptions for each achievement
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxProgress: Int {
|
|
||||||
switch self {
|
|
||||||
case .firstHand, .comeback, .dragonBonusWinBy9, .bet50000, .singleWin100000: return 1
|
|
||||||
case .tieWins5, .spotStreaks5: return 5
|
|
||||||
case .playerWins10, .naturals10, .pairBets10, .dragonBonus10: return 10
|
|
||||||
case .bankerWins25, .sideBetsTotal25, .viewRoadMap25: return 25
|
|
||||||
case .naturals50, .rounds50, .allTableLimits: return 50
|
|
||||||
case .jamesBond: return 100
|
|
||||||
case .rounds250: return 250
|
|
||||||
case .rounds500: return 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isIncremental: Bool {
|
|
||||||
switch self {
|
|
||||||
case .firstHand, .comeback, .dragonBonusWinBy9, .bet50000, .singleWin100000:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconName: String {
|
|
||||||
switch self {
|
|
||||||
case .firstHand: return "play.circle.fill"
|
|
||||||
case .playerWins10: return "person.fill"
|
|
||||||
case .bankerWins25: return "building.columns.fill"
|
|
||||||
case .tieWins5: return "equal.circle.fill"
|
|
||||||
case .naturals10: return "star.fill"
|
|
||||||
case .naturals50: return "star.circle.fill"
|
|
||||||
case .pairBets10: return "suit.heart.fill"
|
|
||||||
case .dragonBonus10: return "flame.fill"
|
|
||||||
case .dragonBonusWinBy9: return "flame.circle.fill"
|
|
||||||
case .rounds50: return "person.fill"
|
|
||||||
case .rounds250: return "person.2.fill"
|
|
||||||
case .rounds500: return "person.3.fill"
|
|
||||||
case .comeback: return "arrow.up.right.circle.fill"
|
|
||||||
case .spotStreaks5: return "chart.line.uptrend.xyaxis"
|
|
||||||
case .viewRoadMap25: return "map.fill"
|
|
||||||
case .bet50000: return "dollarsign.circle.fill"
|
|
||||||
case .singleWin100000: return "banknote.fill"
|
|
||||||
case .sideBetsTotal25: return "plus.circle.fill"
|
|
||||||
case .jamesBond: return "theatermasks.fill"
|
|
||||||
case .allTableLimits: return "chart.bar.fill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Achievement Tracker to GameState
|
|
||||||
|
|
||||||
**File:** `Baccarat/Baccarat/Engine/GameState.swift`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
class GameState {
|
|
||||||
// ... existing properties ...
|
|
||||||
|
|
||||||
// NEW: Achievement tracking
|
|
||||||
private(set) var achievementTracker: AchievementTracker<BaccaratAchievement>
|
|
||||||
|
|
||||||
// Track session-specific metrics
|
|
||||||
private var sessionLowBalance: Int = 0
|
|
||||||
private var consecutiveBankerBets = 0
|
|
||||||
private var consecutiveSameBetType: BetType? = nil
|
|
||||||
private var consecutiveSameBetWins = 0
|
|
||||||
private var roundsPerTableLimit: [String: Int] = [:]
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// ... existing init ...
|
|
||||||
|
|
||||||
self.achievementTracker = AchievementTracker<BaccaratAchievement>()
|
|
||||||
self.sessionLowBalance = balance
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Achievement checking methods
|
|
||||||
func checkAchievements(after action: GameAction) {
|
|
||||||
// Called after each game action
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSessionTracking() {
|
|
||||||
if balance < sessionLowBalance {
|
|
||||||
sessionLowBalance = balance
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check comeback achievement
|
|
||||||
if sessionLowBalance < 100 && balance >= 10_000 {
|
|
||||||
achievementTracker.complete(.comeback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add Achievement Triggers
|
|
||||||
|
|
||||||
Add achievement checks throughout game logic:
|
|
||||||
|
|
||||||
**In `BaccaratEngine.swift` or `GameState.swift`:**
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// After round completes
|
|
||||||
func finishRound() {
|
|
||||||
// ... existing logic ...
|
|
||||||
|
|
||||||
// First hand achievement
|
|
||||||
if totalRoundsPlayed == 1 {
|
|
||||||
achievementTracker.complete(.firstHand)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persistence achievements
|
|
||||||
achievementTracker.increment(.rounds50)
|
|
||||||
achievementTracker.increment(.rounds250)
|
|
||||||
achievementTracker.increment(.rounds500)
|
|
||||||
|
|
||||||
// Track table limits
|
|
||||||
let limitKey = settings.tableLimit.displayName
|
|
||||||
roundsPerTableLimit[limitKey, default: 0] += 1
|
|
||||||
|
|
||||||
if roundsPerTableLimit.values.filter({ $0 >= 10 }).count == 5 {
|
|
||||||
achievementTracker.complete(.allTableLimits)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSessionTracking()
|
|
||||||
}
|
|
||||||
|
|
||||||
// When placing bets
|
|
||||||
func placeBet(type: BetType, amount: Int) {
|
|
||||||
// ... existing logic ...
|
|
||||||
|
|
||||||
// High roller achievement
|
|
||||||
if amount >= 50_000 {
|
|
||||||
achievementTracker.complete(.bet50000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// James Bond tracking (consecutive Banker bets)
|
|
||||||
if type == .banker {
|
|
||||||
consecutiveBankerBets += 1
|
|
||||||
if consecutiveBankerBets >= 100 {
|
|
||||||
achievementTracker.complete(.jamesBond)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
consecutiveBankerBets = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streak tracking
|
|
||||||
if type == consecutiveSameBetType {
|
|
||||||
// Continue tracking
|
|
||||||
} else {
|
|
||||||
consecutiveSameBetType = type
|
|
||||||
consecutiveSameBetWins = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After determining winner
|
|
||||||
func evaluateRound() {
|
|
||||||
// ... existing logic ...
|
|
||||||
|
|
||||||
// Natural achievements
|
|
||||||
if playerHand.isNatural || bankerHand.isNatural {
|
|
||||||
achievementTracker.increment(.naturals10)
|
|
||||||
achievementTracker.increment(.naturals50)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check main bet wins
|
|
||||||
if playerBetWon {
|
|
||||||
achievementTracker.increment(.playerWins10)
|
|
||||||
|
|
||||||
if consecutiveSameBetType == .player {
|
|
||||||
consecutiveSameBetWins += 1
|
|
||||||
if consecutiveSameBetWins >= 3 {
|
|
||||||
achievementTracker.increment(.spotStreaks5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if bankerBetWon {
|
|
||||||
achievementTracker.increment(.bankerWins25)
|
|
||||||
|
|
||||||
if consecutiveSameBetType == .banker {
|
|
||||||
consecutiveSameBetWins += 1
|
|
||||||
if consecutiveSameBetWins >= 3 {
|
|
||||||
achievementTracker.increment(.spotStreaks5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tieBetWon {
|
|
||||||
achievementTracker.increment(.tieWins5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Side bet achievements
|
|
||||||
if playerPairWon || bankerPairWon {
|
|
||||||
achievementTracker.increment(.pairBets10)
|
|
||||||
achievementTracker.increment(.sideBetsTotal25)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dragonBonusWon {
|
|
||||||
achievementTracker.increment(.dragonBonus10)
|
|
||||||
achievementTracker.increment(.sideBetsTotal25)
|
|
||||||
|
|
||||||
// Check for 9-point margin win
|
|
||||||
if dragonBonusMargin == 9 {
|
|
||||||
achievementTracker.complete(.dragonBonusWinBy9)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Big win achievement
|
|
||||||
let netProfit = calculateNetProfit()
|
|
||||||
if netProfit >= 100_000 {
|
|
||||||
achievementTracker.complete(.singleWin100000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When road map is viewed
|
|
||||||
func trackRoadMapView() {
|
|
||||||
achievementTracker.increment(.viewRoadMap25)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Add Road Map View Tracking
|
|
||||||
|
|
||||||
**File:** `Baccarat/Baccarat/Views/Table/RoadMapView.swift` (or wherever road map is displayed)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
.onAppear {
|
|
||||||
state.trackRoadMapView()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Add Game Center to Settings
|
|
||||||
|
|
||||||
**File:** `Baccarat/Baccarat/Views/Sheets/SettingsView.swift`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
|
||||||
// ... existing code ...
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
SheetContainerView(title: String(localized: "Settings")) {
|
|
||||||
// ... existing sections ...
|
|
||||||
|
|
||||||
// NEW: Game Center section
|
|
||||||
GameCenterSettingsSection()
|
|
||||||
|
|
||||||
} onDone: {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Optional: Add Game Center Access Point
|
|
||||||
|
|
||||||
**File:** `Baccarat/Baccarat/Views/Game/GameTableView.swift`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
TopBarView(
|
|
||||||
balance: state.balance,
|
|
||||||
// ... existing parameters ...
|
|
||||||
leadingButtons: [
|
|
||||||
TopBarButton(
|
|
||||||
icon: "gamecontroller.fill",
|
|
||||||
accessibilityLabel: "Game Center"
|
|
||||||
) {
|
|
||||||
GameCenterManager.shared.showGameCenterDashboard()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Initialize Game Center on App Launch
|
|
||||||
|
|
||||||
**File:** `Baccarat/Baccarat/BaccaratApp.swift`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct BaccaratApp: App {
|
|
||||||
init() {
|
|
||||||
// Authenticate with Game Center silently
|
|
||||||
Task {
|
|
||||||
await GameCenterManager.shared.authenticate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... rest of app ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Add Localized Strings
|
|
||||||
|
|
||||||
**File:** `Baccarat/Baccarat/Resources/Localizable.xcstrings`
|
|
||||||
|
|
||||||
Add all achievement titles, descriptions, and Game Center UI strings.
|
|
||||||
|
|
||||||
## App Store Connect Configuration
|
|
||||||
|
|
||||||
### For Each Achievement:
|
|
||||||
|
|
||||||
1. Log into App Store Connect
|
|
||||||
2. Select Baccarat app
|
|
||||||
3. Go to Game Center → Achievements
|
|
||||||
4. Click "+" to add achievement
|
|
||||||
5. Configure:
|
|
||||||
- **Reference Name:** (internal only)
|
|
||||||
- **Achievement ID:** Must match enum identifier
|
|
||||||
- **Point Value:** As specified above
|
|
||||||
- **Hidden:** Set to "Yes" for James Bond and Table Explorer
|
|
||||||
- **Achievable More Than Once:** No for all
|
|
||||||
- **Localization:** Add EN, ES-MX, FR-CA
|
|
||||||
- **Images:** Upload 512x512 and 1024x1024 versions
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] All achievements can be triggered in game
|
|
||||||
- [ ] Incremental achievements show progress
|
|
||||||
- [ ] Hidden achievements don't appear until unlocked
|
|
||||||
- [ ] James Bond achievement resets on non-Banker bet
|
|
||||||
- [ ] Streak tracking works correctly (3+ consecutive wins)
|
|
||||||
- [ ] Road map view tracking increments
|
|
||||||
- [ ] Table limit tracking works across all 5 limits
|
|
||||||
- [ ] Big win achievement triggers at $100k profit
|
|
||||||
- [ ] Dragon Bonus 9-point margin detected correctly
|
|
||||||
- [ ] Comeback achievement tracks session low properly
|
|
||||||
|
|
||||||
## Edge Cases to Handle
|
|
||||||
|
|
||||||
1. **Player resets game mid-session** - Session achievements (comeback) should reset
|
|
||||||
2. **Multiple simultaneous bets** - Track all winning bet types for achievements
|
|
||||||
3. **Tie pushes main bet** - Don't count as "win" for streak tracking
|
|
||||||
4. **Switching table limits** - Properly track rounds per limit
|
|
||||||
5. **Offline play** - Queue achievements, submit when online
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- **Achievement notifications** - Use CasinoKit's AchievementToast
|
|
||||||
- **Progress UI** - Show achievement progress in statistics view
|
|
||||||
- **Pattern hints** - Suggest when player is on a streak (Road Map Reader tie-in)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- ✅ 20 total achievements
|
|
||||||
- ✅ Focus on gameplay understanding, pattern recognition, and milestones
|
|
||||||
- ✅ Two hidden achievements (James Bond, Table Explorer)
|
|
||||||
- ✅ Incremental progress for most achievements
|
|
||||||
- ✅ Unique Baccarat-specific achievements (road maps, Dragon Bonus, streaks)
|
|
||||||
- ✅ Integration with existing GameState and road map system
|
|
||||||
- ✅ No code duplication (uses CasinoKit infrastructure)
|
|
||||||
|
|
||||||
**Estimated Integration Time:** 2-3 hours after CasinoKit infrastructure is complete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*See `CasinoKit/GAME_CENTER_PLAN.md` for shared infrastructure implementation details.*
|
|
||||||
|
|
||||||
@ -74,12 +74,6 @@ final class GameState {
|
|||||||
/// Index of the hand currently being played.
|
/// Index of the hand currently being played.
|
||||||
private(set) var activeHandIndex: Int = 0
|
private(set) var activeHandIndex: Int = 0
|
||||||
|
|
||||||
/// Number of cards that have completed their deal animation for each player hand.
|
|
||||||
private(set) var playerHandsVisibleCardCount: [Int] = []
|
|
||||||
|
|
||||||
/// Number of cards that have completed their deal animation for dealer hand.
|
|
||||||
private(set) var dealerVisibleCardCount: Int = 0
|
|
||||||
|
|
||||||
/// Whether an action is currently being processed (prevents double-tap issues).
|
/// Whether an action is currently being processed (prevents double-tap issues).
|
||||||
private(set) var isProcessingAction: Bool = false
|
private(set) var isProcessingAction: Bool = false
|
||||||
|
|
||||||
@ -493,10 +487,6 @@ final class GameState {
|
|||||||
perfectPairsResult = nil
|
perfectPairsResult = nil
|
||||||
twentyOnePlusThreeResult = nil
|
twentyOnePlusThreeResult = nil
|
||||||
|
|
||||||
// Reset visible card counts for animations
|
|
||||||
playerHandsVisibleCardCount = [0]
|
|
||||||
dealerVisibleCardCount = 0
|
|
||||||
|
|
||||||
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
|
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
|
||||||
|
|
||||||
// European no-hole-card: deal 3 cards (player, dealer, player)
|
// European no-hole-card: deal 3 cards (player, dealer, player)
|
||||||
@ -514,13 +504,6 @@ final class GameState {
|
|||||||
if delay > 0 {
|
if delay > 0 {
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark card as visible after animation delay
|
|
||||||
if i % 2 == 0 {
|
|
||||||
playerHandsVisibleCardCount[0] += 1
|
|
||||||
} else {
|
|
||||||
dealerVisibleCardCount += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -639,15 +622,6 @@ final class GameState {
|
|||||||
playerHands[activeHandIndex].cards.append(card)
|
playerHands[activeHandIndex].cards.append(card)
|
||||||
sound.play(.cardDeal)
|
sound.play(.cardDeal)
|
||||||
|
|
||||||
// Wait for animation if enabled
|
|
||||||
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
|
|
||||||
if delay > 0 {
|
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark card as visible after animation
|
|
||||||
playerHandsVisibleCardCount[activeHandIndex] += 1
|
|
||||||
|
|
||||||
// Check for bust or 21
|
// Check for bust or 21
|
||||||
if playerHands[activeHandIndex].isBusted {
|
if playerHands[activeHandIndex].isBusted {
|
||||||
playerHands[activeHandIndex].result = .bust
|
playerHands[activeHandIndex].result = .bust
|
||||||
@ -695,15 +669,6 @@ final class GameState {
|
|||||||
if let card = engine.dealCard() {
|
if let card = engine.dealCard() {
|
||||||
playerHands[activeHandIndex].cards.append(card)
|
playerHands[activeHandIndex].cards.append(card)
|
||||||
sound.play(.cardDeal)
|
sound.play(.cardDeal)
|
||||||
|
|
||||||
// Wait for animation if enabled
|
|
||||||
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
|
|
||||||
if delay > 0 {
|
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark card as visible after animation
|
|
||||||
playerHandsVisibleCardCount[activeHandIndex] += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if playerHands[activeHandIndex].isBusted {
|
if playerHands[activeHandIndex].isBusted {
|
||||||
@ -742,22 +707,14 @@ final class GameState {
|
|||||||
sound.play(.chipPlace)
|
sound.play(.chipPlace)
|
||||||
|
|
||||||
// Deal one card to each hand
|
// Deal one card to each hand
|
||||||
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
|
|
||||||
|
|
||||||
if let card1 = engine.dealCard() {
|
if let card1 = engine.dealCard() {
|
||||||
hand1.cards.append(card1)
|
hand1.cards.append(card1)
|
||||||
sound.play(.cardDeal)
|
sound.play(.cardDeal)
|
||||||
if delay > 0 {
|
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let card2 = engine.dealCard() {
|
if let card2 = engine.dealCard() {
|
||||||
hand2.cards.append(card2)
|
hand2.cards.append(card2)
|
||||||
sound.play(.cardDeal)
|
sound.play(.cardDeal)
|
||||||
if delay > 0 {
|
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace original with split hands
|
// Replace original with split hands
|
||||||
@ -765,11 +722,6 @@ final class GameState {
|
|||||||
playerHands.insert(hand1, at: activeHandIndex)
|
playerHands.insert(hand1, at: activeHandIndex)
|
||||||
playerHands.insert(hand2, at: activeHandIndex + 1)
|
playerHands.insert(hand2, at: activeHandIndex + 1)
|
||||||
|
|
||||||
// Update visible card counts - each split hand starts with 2 visible cards
|
|
||||||
playerHandsVisibleCardCount.remove(at: activeHandIndex)
|
|
||||||
playerHandsVisibleCardCount.insert(2, at: activeHandIndex)
|
|
||||||
playerHandsVisibleCardCount.insert(2, at: activeHandIndex + 1)
|
|
||||||
|
|
||||||
// If split aces, typically only one card each and stand
|
// If split aces, typically only one card each and stand
|
||||||
if originalHand.cards[0].rank == .ace && !settings.resplitAces {
|
if originalHand.cards[0].rank == .ace && !settings.resplitAces {
|
||||||
playerHands[activeHandIndex].isStanding = true
|
playerHands[activeHandIndex].isStanding = true
|
||||||
@ -838,9 +790,6 @@ final class GameState {
|
|||||||
if delay > 0 {
|
if delay > 0 {
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark card as visible after animation
|
|
||||||
dealerVisibleCardCount += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for dealer blackjack in European mode
|
// Check for dealer blackjack in European mode
|
||||||
@ -860,17 +809,12 @@ final class GameState {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// American style: reveal hole card (card is already in hand, just mark as visible)
|
// American style: reveal hole card
|
||||||
sound.play(.cardFlip)
|
sound.play(.cardFlip)
|
||||||
|
|
||||||
if delay > 0 {
|
if delay > 0 {
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark hole card as visible (if not already)
|
|
||||||
if dealerVisibleCardCount < dealerHand.cards.count {
|
|
||||||
dealerVisibleCardCount = dealerHand.cards.count
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dealer draws
|
// Dealer draws
|
||||||
@ -882,9 +826,6 @@ final class GameState {
|
|||||||
if delay > 0 {
|
if delay > 0 {
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark card as visible after animation
|
|
||||||
dealerVisibleCardCount += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1116,10 +1057,6 @@ final class GameState {
|
|||||||
dealerHand = BlackjackHand()
|
dealerHand = BlackjackHand()
|
||||||
activeHandIndex = 0
|
activeHandIndex = 0
|
||||||
|
|
||||||
// Reset visible card counts
|
|
||||||
playerHandsVisibleCardCount = []
|
|
||||||
dealerVisibleCardCount = 0
|
|
||||||
|
|
||||||
// Reset bets
|
// Reset bets
|
||||||
currentBet = 0
|
currentBet = 0
|
||||||
insuranceBet = 0
|
insuranceBet = 0
|
||||||
|
|||||||
@ -112,12 +112,8 @@ struct GameTableView: View {
|
|||||||
checkOnboardingHints()
|
checkOnboardingHints()
|
||||||
},
|
},
|
||||||
onStartPlaying: {
|
onStartPlaying: {
|
||||||
// Mark all hints as shown FIRST so they don't appear
|
|
||||||
state.onboarding.markHintShown("bettingZone")
|
|
||||||
state.onboarding.markHintShown("dealButton")
|
|
||||||
state.onboarding.markHintShown("playerActions")
|
|
||||||
state.onboarding.completeWelcome()
|
|
||||||
showWelcome = false
|
showWelcome = false
|
||||||
|
state.onboarding.completeWelcome()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,8 +94,7 @@ struct BlackjackTableView: View {
|
|||||||
showAnimations: state.settings.showAnimations,
|
showAnimations: state.settings.showAnimations,
|
||||||
dealingSpeed: state.settings.dealingSpeed,
|
dealingSpeed: state.settings.dealingSpeed,
|
||||||
cardWidth: cardWidth,
|
cardWidth: cardWidth,
|
||||||
cardSpacing: cardSpacing,
|
cardSpacing: cardSpacing
|
||||||
visibleCardCount: state.dealerVisibleCardCount
|
|
||||||
)
|
)
|
||||||
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
|
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
|
||||||
|
|
||||||
@ -128,7 +127,6 @@ struct BlackjackTableView: View {
|
|||||||
dealingSpeed: state.settings.dealingSpeed,
|
dealingSpeed: state.settings.dealingSpeed,
|
||||||
cardWidth: cardWidth,
|
cardWidth: cardWidth,
|
||||||
cardSpacing: cardSpacing,
|
cardSpacing: cardSpacing,
|
||||||
visibleCardCounts: state.playerHandsVisibleCardCount,
|
|
||||||
currentHint: state.currentHint,
|
currentHint: state.currentHint,
|
||||||
showHintToast: state.showHintToast
|
showHintToast: state.showHintToast
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,9 +17,6 @@ struct DealerHandView: View {
|
|||||||
let cardWidth: CGFloat
|
let cardWidth: CGFloat
|
||||||
let cardSpacing: CGFloat
|
let cardSpacing: CGFloat
|
||||||
|
|
||||||
/// Number of cards that have completed their animation
|
|
||||||
let visibleCardCount: Int
|
|
||||||
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
||||||
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
|
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
|
||||||
|
|
||||||
@ -37,28 +34,21 @@ struct DealerHandView: View {
|
|||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
// Calculate value from visible cards only
|
// Badge animates in when cards are dealt
|
||||||
if !hand.cards.isEmpty && visibleCardCount > 0 {
|
if !hand.cards.isEmpty {
|
||||||
if showHoleCard && visibleCardCount >= hand.cards.count {
|
if showHoleCard {
|
||||||
// All cards visible - calculate total hand value from visible cards
|
// All cards visible - show total hand value
|
||||||
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
|
ValueBadge(value: hand.value, color: Color.Hand.dealer)
|
||||||
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
|
.transition(.scale.combined(with: .opacity))
|
||||||
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
|
} else {
|
||||||
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
|
// Hole card hidden - show only the first (face-up) card's value
|
||||||
|
|
||||||
ValueBadge(value: displayValue, color: Color.Hand.dealer)
|
|
||||||
.animation(nil, value: displayValue) // No animation when value changes
|
|
||||||
} else if visibleCardCount >= 1 {
|
|
||||||
// Hole card hidden or not all cards visible - show only the first (face-up) card's value
|
|
||||||
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
|
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
|
||||||
.animation(nil, value: hand.cards[0].blackjackValue) // No animation when value changes
|
.transition(.scale.combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minHeight: badgeHeight) // Reserve consistent height
|
.frame(minHeight: badgeHeight) // Reserve consistent height
|
||||||
// Remove animations on badge appearance/value changes
|
.animation(.spring(duration: Design.Animation.springDuration), value: hand.cards.isEmpty)
|
||||||
.animation(nil, value: visibleCardCount)
|
|
||||||
.animation(nil, value: showHoleCard)
|
|
||||||
// Cards with result badge overlay (overlay prevents height change)
|
// Cards with result badge overlay (overlay prevents height change)
|
||||||
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
||||||
if hand.cards.isEmpty {
|
if hand.cards.isEmpty {
|
||||||
@ -162,8 +152,7 @@ struct DealerHandView: View {
|
|||||||
showAnimations: true,
|
showAnimations: true,
|
||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20
|
||||||
visibleCardCount: 0
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -181,8 +170,7 @@ struct DealerHandView: View {
|
|||||||
showAnimations: true,
|
showAnimations: true,
|
||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20
|
||||||
visibleCardCount: 2
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -200,8 +188,7 @@ struct DealerHandView: View {
|
|||||||
showAnimations: true,
|
showAnimations: true,
|
||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20
|
||||||
visibleCardCount: 2
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,9 +21,6 @@ struct PlayerHandsView: View {
|
|||||||
let cardWidth: CGFloat
|
let cardWidth: CGFloat
|
||||||
let cardSpacing: CGFloat
|
let cardSpacing: CGFloat
|
||||||
|
|
||||||
/// Number of visible cards for each hand (completed animations)
|
|
||||||
let visibleCardCounts: [Int]
|
|
||||||
|
|
||||||
/// Current hint to display (shown on active hand only).
|
/// Current hint to display (shown on active hand only).
|
||||||
let currentHint: String?
|
let currentHint: String?
|
||||||
|
|
||||||
@ -44,7 +41,6 @@ struct PlayerHandsView: View {
|
|||||||
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
|
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
|
||||||
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
|
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
|
||||||
let isActiveHand = index == activeHandIndex && isPlayerTurn
|
let isActiveHand = index == activeHandIndex && isPlayerTurn
|
||||||
let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0
|
|
||||||
PlayerHandView(
|
PlayerHandView(
|
||||||
hand: hand,
|
hand: hand,
|
||||||
isActive: isActiveHand,
|
isActive: isActiveHand,
|
||||||
@ -55,7 +51,6 @@ struct PlayerHandsView: View {
|
|||||||
handNumber: hands.count > 1 ? index + 1 : nil,
|
handNumber: hands.count > 1 ? index + 1 : nil,
|
||||||
cardWidth: cardWidth,
|
cardWidth: cardWidth,
|
||||||
cardSpacing: cardSpacing,
|
cardSpacing: cardSpacing,
|
||||||
visibleCardCount: visibleCount,
|
|
||||||
// Only show hint on the active hand
|
// Only show hint on the active hand
|
||||||
currentHint: isActiveHand ? currentHint : nil,
|
currentHint: isActiveHand ? currentHint : nil,
|
||||||
showHintToast: isActiveHand && showHintToast
|
showHintToast: isActiveHand && showHintToast
|
||||||
@ -110,9 +105,6 @@ struct PlayerHandView: View {
|
|||||||
let cardWidth: CGFloat
|
let cardWidth: CGFloat
|
||||||
let cardSpacing: CGFloat
|
let cardSpacing: CGFloat
|
||||||
|
|
||||||
/// Number of cards that have completed their animation
|
|
||||||
let visibleCardCount: Int
|
|
||||||
|
|
||||||
/// Current hint to display on this hand.
|
/// Current hint to display on this hand.
|
||||||
let currentHint: String?
|
let currentHint: String?
|
||||||
|
|
||||||
@ -214,35 +206,10 @@ struct PlayerHandView: View {
|
|||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate value from visible (animation-completed) cards
|
|
||||||
// Always show the value - it updates as cards become visible
|
|
||||||
if !hand.cards.isEmpty {
|
if !hand.cards.isEmpty {
|
||||||
// Use only the cards that have completed their animation
|
Text(hand.valueDisplay)
|
||||||
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
|
|
||||||
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
|
|
||||||
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
|
|
||||||
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
|
|
||||||
|
|
||||||
// Determine color based on visible cards
|
|
||||||
let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21
|
|
||||||
let isVisibleBusted = visibleValue > 21
|
|
||||||
let isVisible21 = displayValue == 21 && !isVisibleBlackjack
|
|
||||||
|
|
||||||
let displayColor: Color = {
|
|
||||||
if isVisibleBlackjack { return .yellow }
|
|
||||||
if isVisibleBusted { return .red }
|
|
||||||
if isVisible21 { return .green }
|
|
||||||
return .white
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Show value like hand.valueDisplay does
|
|
||||||
let valueText = visibleHasSoftAce ? "\(visibleValue)/\(displayValue)" : "\(displayValue)"
|
|
||||||
|
|
||||||
Text(valueText)
|
|
||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(displayColor)
|
.foregroundStyle(valueColor)
|
||||||
.animation(nil, value: valueText) // No animation when text changes
|
|
||||||
.animation(nil, value: displayColor) // No animation when color changes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if hand.isDoubledDown {
|
if hand.isDoubledDown {
|
||||||
@ -301,7 +268,6 @@ struct PlayerHandView: View {
|
|||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20,
|
||||||
visibleCardCounts: [0],
|
|
||||||
currentHint: nil,
|
currentHint: nil,
|
||||||
showHintToast: false
|
showHintToast: false
|
||||||
)
|
)
|
||||||
@ -323,7 +289,6 @@ struct PlayerHandView: View {
|
|||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20,
|
||||||
visibleCardCounts: [2],
|
|
||||||
currentHint: "Hit",
|
currentHint: "Hit",
|
||||||
showHintToast: true
|
showHintToast: true
|
||||||
)
|
)
|
||||||
@ -359,7 +324,6 @@ struct PlayerHandView: View {
|
|||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20,
|
||||||
visibleCardCounts: [2, 2, 2, 2],
|
|
||||||
currentHint: "Stand",
|
currentHint: "Stand",
|
||||||
showHintToast: true
|
showHintToast: true
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,281 +7,11 @@
|
|||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Blackjack
|
@testable import Blackjack
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
struct BlackjackTests {
|
struct BlackjackTests {
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func example() async throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hand Value Tests
|
|
||||||
|
|
||||||
@Test @MainActor func handValueCalculation() async throws {
|
|
||||||
var hand = BlackjackHand()
|
|
||||||
|
|
||||||
// Test empty hand
|
|
||||||
#expect(hand.value == 0, "Empty hand should have value 0")
|
|
||||||
|
|
||||||
// Test simple values
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .five),
|
|
||||||
Card(suit: .hearts, rank: .seven)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 12, "5 + 7 should equal 12")
|
|
||||||
#expect(!hand.isSoft, "Hand without ace should not be soft")
|
|
||||||
|
|
||||||
// Test face cards
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .clubs, rank: .king),
|
|
||||||
Card(suit: .diamonds, rank: .queen)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 20, "King (10) + Queen (10) should equal 20")
|
|
||||||
|
|
||||||
// Test blackjack
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .ace),
|
|
||||||
Card(suit: .hearts, rank: .king)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 21, "Ace + King should equal 21")
|
|
||||||
#expect(hand.isBlackjack, "Ace + King should be blackjack")
|
|
||||||
#expect(hand.isSoft, "Blackjack hand should be soft")
|
|
||||||
|
|
||||||
// Test soft hand (ace as 11)
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .diamonds, rank: .ace),
|
|
||||||
Card(suit: .clubs, rank: .six)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 17, "Ace + 6 should equal soft 17")
|
|
||||||
#expect(hand.isSoft, "Ace + 6 should be soft")
|
|
||||||
|
|
||||||
// Test hard hand (ace as 1)
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .ace),
|
|
||||||
Card(suit: .hearts, rank: .five),
|
|
||||||
Card(suit: .clubs, rank: .nine)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 15, "Ace + 5 + 9 = 15 (ace counts as 1)")
|
|
||||||
#expect(!hand.isSoft, "Hand over 21 if ace=11 should be hard")
|
|
||||||
|
|
||||||
// Test bust
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .diamonds, rank: .king),
|
|
||||||
Card(suit: .clubs, rank: .queen),
|
|
||||||
Card(suit: .hearts, rank: .five)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 25, "10 + 10 + 5 = 25")
|
|
||||||
#expect(hand.isBusted, "Hand over 21 should be busted")
|
|
||||||
|
|
||||||
// Test multiple aces
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .ace),
|
|
||||||
Card(suit: .hearts, rank: .ace)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 12, "Ace + Ace = 12 (one as 11, one as 1)")
|
|
||||||
#expect(hand.isSoft, "Two aces should be soft")
|
|
||||||
|
|
||||||
// Test three aces
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .ace),
|
|
||||||
Card(suit: .hearts, rank: .ace),
|
|
||||||
Card(suit: .clubs, rank: .ace)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 13, "Three aces = 13 (one as 11, two as 1)")
|
|
||||||
#expect(hand.isSoft, "Three aces should be soft")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor func handBlackjackDetection() async throws {
|
|
||||||
// Natural blackjack
|
|
||||||
var hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .ace),
|
|
||||||
Card(suit: .hearts, rank: .ten)
|
|
||||||
])
|
|
||||||
#expect(hand.isBlackjack, "Ace + 10 should be blackjack")
|
|
||||||
|
|
||||||
// 21 with three cards is not blackjack
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .seven),
|
|
||||||
Card(suit: .hearts, rank: .seven),
|
|
||||||
Card(suit: .clubs, rank: .seven)
|
|
||||||
])
|
|
||||||
#expect(hand.value == 21, "Should have value 21")
|
|
||||||
#expect(!hand.isBlackjack, "21 with 3 cards is not blackjack")
|
|
||||||
|
|
||||||
// Split hand 21 is not blackjack
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .ace),
|
|
||||||
Card(suit: .hearts, rank: .king)
|
|
||||||
], bet: 100)
|
|
||||||
hand.isSplit = true
|
|
||||||
#expect(hand.value == 21, "Should have value 21")
|
|
||||||
#expect(!hand.isBlackjack, "21 after split is not blackjack")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor func handSplitDetection() async throws {
|
|
||||||
// Splittable pair
|
|
||||||
var hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .eight),
|
|
||||||
Card(suit: .hearts, rank: .eight)
|
|
||||||
])
|
|
||||||
#expect(hand.canSplit, "Pair of 8s should be splittable")
|
|
||||||
|
|
||||||
// Face cards of same rank can split
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .king),
|
|
||||||
Card(suit: .hearts, rank: .king)
|
|
||||||
])
|
|
||||||
#expect(hand.canSplit, "Pair of Kings should be splittable")
|
|
||||||
|
|
||||||
// Face cards of different ranks cannot split
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .king),
|
|
||||||
Card(suit: .hearts, rank: .queen)
|
|
||||||
])
|
|
||||||
#expect(!hand.canSplit, "King + Queen should not be splittable (different ranks)")
|
|
||||||
|
|
||||||
// Not splittable - different ranks
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .nine),
|
|
||||||
Card(suit: .hearts, rank: .ten)
|
|
||||||
])
|
|
||||||
#expect(!hand.canSplit, "9 + 10 should not be splittable")
|
|
||||||
|
|
||||||
// Not splittable - more than 2 cards
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .five),
|
|
||||||
Card(suit: .hearts, rank: .five),
|
|
||||||
Card(suit: .clubs, rank: .ace)
|
|
||||||
])
|
|
||||||
#expect(!hand.canSplit, "Hand with 3 cards should not be splittable")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor func handDoubleDownDetection() async throws {
|
|
||||||
// Can double down with 2 cards
|
|
||||||
var hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .five),
|
|
||||||
Card(suit: .hearts, rank: .six)
|
|
||||||
])
|
|
||||||
#expect(hand.canDoubleDown, "Should be able to double down with 2 cards")
|
|
||||||
|
|
||||||
// Cannot double down after already doubled
|
|
||||||
hand.isDoubledDown = true
|
|
||||||
#expect(!hand.canDoubleDown, "Should not be able to double down twice")
|
|
||||||
|
|
||||||
// Cannot double down with 3+ cards
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .five),
|
|
||||||
Card(suit: .hearts, rank: .six),
|
|
||||||
Card(suit: .clubs, rank: .two)
|
|
||||||
])
|
|
||||||
#expect(!hand.canDoubleDown, "Should not be able to double down with 3 cards")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor func handHitDetection() async throws {
|
|
||||||
// Can hit with normal hand
|
|
||||||
var hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .five),
|
|
||||||
Card(suit: .hearts, rank: .six)
|
|
||||||
])
|
|
||||||
#expect(hand.canHit, "Should be able to hit with 11")
|
|
||||||
|
|
||||||
// Cannot hit after standing
|
|
||||||
hand.isStanding = true
|
|
||||||
#expect(!hand.canHit, "Should not be able to hit after standing")
|
|
||||||
|
|
||||||
// Cannot hit when busted
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .king),
|
|
||||||
Card(suit: .hearts, rank: .queen),
|
|
||||||
Card(suit: .clubs, rank: .five)
|
|
||||||
])
|
|
||||||
#expect(!hand.canHit, "Should not be able to hit when busted")
|
|
||||||
|
|
||||||
// Cannot hit with blackjack
|
|
||||||
hand = BlackjackHand(cards: [
|
|
||||||
Card(suit: .spades, rank: .ace),
|
|
||||||
Card(suit: .hearts, rank: .king)
|
|
||||||
])
|
|
||||||
#expect(!hand.canHit, "Should not be able to hit with blackjack")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Card Value Tests
|
|
||||||
|
|
||||||
@Test func cardHiLoValues() async throws {
|
|
||||||
// Low cards (2-6) = +1
|
|
||||||
#expect(Card(suit: .spades, rank: .two).hiLoValue == 1)
|
|
||||||
#expect(Card(suit: .hearts, rank: .three).hiLoValue == 1)
|
|
||||||
#expect(Card(suit: .clubs, rank: .four).hiLoValue == 1)
|
|
||||||
#expect(Card(suit: .diamonds, rank: .five).hiLoValue == 1)
|
|
||||||
#expect(Card(suit: .spades, rank: .six).hiLoValue == 1)
|
|
||||||
|
|
||||||
// Neutral cards (7-9) = 0
|
|
||||||
#expect(Card(suit: .hearts, rank: .seven).hiLoValue == 0)
|
|
||||||
#expect(Card(suit: .clubs, rank: .eight).hiLoValue == 0)
|
|
||||||
#expect(Card(suit: .diamonds, rank: .nine).hiLoValue == 0)
|
|
||||||
|
|
||||||
// High cards (10, J, Q, K, A) = -1
|
|
||||||
#expect(Card(suit: .spades, rank: .ten).hiLoValue == -1)
|
|
||||||
#expect(Card(suit: .hearts, rank: .jack).hiLoValue == -1)
|
|
||||||
#expect(Card(suit: .clubs, rank: .queen).hiLoValue == -1)
|
|
||||||
#expect(Card(suit: .diamonds, rank: .king).hiLoValue == -1)
|
|
||||||
#expect(Card(suit: .spades, rank: .ace).hiLoValue == -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Game State Tests
|
|
||||||
|
|
||||||
@Test @MainActor func gameStateInitialization() async throws {
|
|
||||||
let state = GameState(settings: GameSettings())
|
|
||||||
|
|
||||||
// Initial state
|
|
||||||
#expect(state.balance == 10000, "Should start with $10,000")
|
|
||||||
#expect(state.currentBet == 0, "Should start with no bet")
|
|
||||||
#expect(state.playerHands.isEmpty, "Should start with no hands")
|
|
||||||
#expect(state.dealerHand.cards.isEmpty, "Should start with empty dealer hand")
|
|
||||||
|
|
||||||
// Check phase enum
|
|
||||||
switch state.currentPhase {
|
|
||||||
case .betting:
|
|
||||||
break // Expected
|
|
||||||
default:
|
|
||||||
Issue.record("Should start in betting phase")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor func gameStateBetting() async throws {
|
|
||||||
let state = GameState(settings: GameSettings())
|
|
||||||
|
|
||||||
// Place a bet
|
|
||||||
state.placeBet(amount: 100)
|
|
||||||
#expect(state.currentBet == 100, "Bet should be placed")
|
|
||||||
#expect(state.balance == 9900, "Balance should decrease")
|
|
||||||
|
|
||||||
// Add to bet
|
|
||||||
state.placeBet(amount: 50)
|
|
||||||
#expect(state.currentBet == 150, "Bet should increase")
|
|
||||||
#expect(state.balance == 9850, "Balance should decrease more")
|
|
||||||
|
|
||||||
// Clear bet
|
|
||||||
state.clearBet()
|
|
||||||
#expect(state.currentBet == 0, "Bet should be cleared")
|
|
||||||
#expect(state.balance == 10000, "Balance should be restored")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor func gameStateDealing() async throws {
|
|
||||||
let state = GameState(settings: GameSettings())
|
|
||||||
|
|
||||||
// Place bet and deal
|
|
||||||
state.placeBet(amount: 100)
|
|
||||||
await state.deal()
|
|
||||||
|
|
||||||
// Verify hands were dealt
|
|
||||||
#expect(state.playerHands.count == 1, "Should have 1 player hand")
|
|
||||||
#expect(state.playerHands[0].cards.count == 2, "Player should have 2 cards")
|
|
||||||
#expect(state.dealerHand.cards.count == 2, "Dealer should have 2 cards")
|
|
||||||
|
|
||||||
// Verify values are valid
|
|
||||||
#expect(state.playerHands[0].value >= 2, "Player hand should have valid value")
|
|
||||||
#expect(state.playerHands[0].value <= 21, "Player hand should not exceed 21 initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,205 +8,28 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class BlackjackUITests: XCTestCase {
|
final class BlackjackUITests: XCTestCase {
|
||||||
|
|
||||||
var app: XCUIApplication!
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||||
|
|
||||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
|
|
||||||
app = XCUIApplication()
|
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||||
app.launchArguments = ["UI-TESTING"]
|
|
||||||
|
|
||||||
// In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
override func tearDownWithError() throws {
|
||||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
app = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testExample() throws {
|
func testExample() throws {
|
||||||
// UI tests must launch the application that they test.
|
// UI tests must launch the application that they test.
|
||||||
|
let app = XCUIApplication()
|
||||||
app.launch()
|
app.launch()
|
||||||
|
|
||||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UI Navigation Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testAppLaunches() throws {
|
|
||||||
app.launch()
|
|
||||||
XCTAssertTrue(app.exists, "App should launch successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testTopBarElements() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait for the app to load
|
|
||||||
let balanceText = app.staticTexts.matching(identifier: "balance").firstMatch
|
|
||||||
XCTAssertTrue(balanceText.waitForExistence(timeout: 2), "Balance should be visible")
|
|
||||||
|
|
||||||
// Check settings button exists
|
|
||||||
let settingsButton = app.buttons.matching(identifier: "settings").firstMatch
|
|
||||||
XCTAssertTrue(settingsButton.exists, "Settings button should exist")
|
|
||||||
|
|
||||||
// Check help button exists
|
|
||||||
let helpButton = app.buttons.matching(identifier: "help").firstMatch
|
|
||||||
XCTAssertTrue(helpButton.exists, "Help button should exist")
|
|
||||||
|
|
||||||
// Check stats button exists
|
|
||||||
let statsButton = app.buttons.matching(identifier: "stats").firstMatch
|
|
||||||
XCTAssertTrue(statsButton.exists, "Stats button should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testSettingsSheet() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Find and tap settings button
|
|
||||||
let settingsButton = app.buttons.matching(identifier: "settings").firstMatch
|
|
||||||
XCTAssertTrue(settingsButton.waitForExistence(timeout: 2), "Settings button should exist")
|
|
||||||
settingsButton.tap()
|
|
||||||
|
|
||||||
// Wait for settings sheet to appear
|
|
||||||
let settingsSheet = app.scrollViews.firstMatch
|
|
||||||
XCTAssertTrue(settingsSheet.waitForExistence(timeout: 2), "Settings sheet should appear")
|
|
||||||
|
|
||||||
// Check for some expected elements in settings
|
|
||||||
XCTAssertTrue(app.staticTexts["Table Limits"].waitForExistence(timeout: 1) ||
|
|
||||||
app.staticTexts["Game Settings"].waitForExistence(timeout: 1),
|
|
||||||
"Settings content should be visible")
|
|
||||||
|
|
||||||
// Close settings (tap outside or dismiss button)
|
|
||||||
if app.buttons["Done"].exists {
|
|
||||||
app.buttons["Done"].tap()
|
|
||||||
} else {
|
|
||||||
// Tap outside to dismiss
|
|
||||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testHelpSheet() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Find and tap help button
|
|
||||||
let helpButton = app.buttons.matching(identifier: "help").firstMatch
|
|
||||||
XCTAssertTrue(helpButton.waitForExistence(timeout: 2), "Help button should exist")
|
|
||||||
helpButton.tap()
|
|
||||||
|
|
||||||
// Wait for help sheet to appear
|
|
||||||
let helpSheet = app.scrollViews.firstMatch
|
|
||||||
XCTAssertTrue(helpSheet.waitForExistence(timeout: 2), "Help sheet should appear")
|
|
||||||
|
|
||||||
// Check for rules content
|
|
||||||
XCTAssertTrue(app.staticTexts["Rules"].exists ||
|
|
||||||
app.staticTexts["How to Play"].exists,
|
|
||||||
"Rules content should be visible")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Betting Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testChipSelector() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait for chip selector to appear
|
|
||||||
let chipSelector = app.otherElements["chip-selector"]
|
|
||||||
XCTAssertTrue(chipSelector.waitForExistence(timeout: 2) ||
|
|
||||||
app.buttons.matching(NSPredicate(format: "label CONTAINS '$'")).count > 0,
|
|
||||||
"Chip selector should be visible")
|
|
||||||
|
|
||||||
// Find a chip button (they should have $ in the label)
|
|
||||||
let chipButtons = app.buttons.matching(NSPredicate(format: "label CONTAINS '$'"))
|
|
||||||
XCTAssertGreaterThan(chipButtons.count, 0, "Should have chip buttons")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testPlaceBet() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait for betting area
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Find deal button (should be disabled initially)
|
|
||||||
let dealButton = app.buttons["Deal"]
|
|
||||||
XCTAssertTrue(dealButton.waitForExistence(timeout: 2), "Deal button should exist")
|
|
||||||
|
|
||||||
// Initially deal button should be disabled
|
|
||||||
XCTAssertFalse(dealButton.isEnabled, "Deal button should be disabled before bet")
|
|
||||||
|
|
||||||
// Tap on betting zone
|
|
||||||
let bettingZone = app.otherElements["betting-zone"]
|
|
||||||
if bettingZone.exists {
|
|
||||||
bettingZone.tap()
|
|
||||||
|
|
||||||
// Deal button should now be enabled (or still disabled if bet is below minimum)
|
|
||||||
// We can't reliably test this without knowing the bet amount vs minimum
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testClearButton() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Wait and place a bet by tapping betting zone
|
|
||||||
sleep(1)
|
|
||||||
let bettingZone = app.otherElements["betting-zone"]
|
|
||||||
if bettingZone.exists {
|
|
||||||
bettingZone.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find clear button
|
|
||||||
let clearButton = app.buttons["Clear"]
|
|
||||||
if clearButton.exists {
|
|
||||||
clearButton.tap()
|
|
||||||
|
|
||||||
// After clearing, deal button should be disabled again
|
|
||||||
let dealButton = app.buttons["Deal"]
|
|
||||||
XCTAssertFalse(dealButton.isEnabled, "Deal button should be disabled after clear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Gameplay Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testDealCards() throws {
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Place a bet by tapping betting zone multiple times to ensure we meet minimum
|
|
||||||
let bettingZone = app.otherElements["betting-zone"]
|
|
||||||
if bettingZone.exists {
|
|
||||||
for _ in 1...5 {
|
|
||||||
bettingZone.tap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and tap deal button
|
|
||||||
let dealButton = app.buttons["Deal"]
|
|
||||||
if dealButton.waitForExistence(timeout: 2) && dealButton.isEnabled {
|
|
||||||
dealButton.tap()
|
|
||||||
|
|
||||||
// Wait for cards to be dealt
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// After dealing, we should see hit/stand buttons or a result
|
|
||||||
let hitButton = app.buttons["Hit"]
|
|
||||||
let standButton = app.buttons["Stand"]
|
|
||||||
let newRoundButton = app.buttons["New Round"]
|
|
||||||
|
|
||||||
XCTAssertTrue(hitButton.exists || standButton.exists || newRoundButton.exists,
|
|
||||||
"Should see action buttons after dealing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testLaunchPerformance() throws {
|
func testLaunchPerformance() throws {
|
||||||
|
|||||||
@ -1,554 +0,0 @@
|
|||||||
# Game Center Integration - Blackjack
|
|
||||||
|
|
||||||
This document outlines the Game Center achievements for Blackjack and the game-specific integration steps.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
✅ Complete `CasinoKit/GAME_CENTER_PLAN.md` implementation first (shared infrastructure)
|
|
||||||
|
|
||||||
## Achievement Design Philosophy
|
|
||||||
|
|
||||||
Focus on **learning milestones** and **skill demonstration**, not bankroll/luck-based metrics.
|
|
||||||
|
|
||||||
Achievements celebrate:
|
|
||||||
- 📚 Learning basic strategy
|
|
||||||
- 🎓 Card counting practice
|
|
||||||
- 🎯 Gameplay milestones
|
|
||||||
- 💪 Persistence and mastery
|
|
||||||
|
|
||||||
## Blackjack Achievements
|
|
||||||
|
|
||||||
### 🎓 Learning & Strategy Category
|
|
||||||
|
|
||||||
#### 1. **First Hand**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.first_hand`
|
|
||||||
- **Description:** Play your first hand of Blackjack
|
|
||||||
- **Progress:** Complete on first hand
|
|
||||||
- **Points:** 5
|
|
||||||
- **Icon:** `play.circle.fill`
|
|
||||||
- **Trigger:** After first `finishRound()` completes
|
|
||||||
|
|
||||||
#### 2. **Strategy Student**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.strategy_student`
|
|
||||||
- **Description:** Play 50 hands following perfect basic strategy
|
|
||||||
- **Progress:** Incremental (0-50)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `book.fill`
|
|
||||||
- **Trigger:** Each hand where player follows all hint recommendations
|
|
||||||
- **Implementation Note:** Track in `GameState` when hint matches action taken
|
|
||||||
|
|
||||||
#### 3. **Strategy Master**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.strategy_master`
|
|
||||||
- **Description:** Play 100 hands following perfect basic strategy
|
|
||||||
- **Progress:** Incremental (0-100)
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `graduationcap.fill`
|
|
||||||
- **Trigger:** Same as Strategy Student, extended goal
|
|
||||||
|
|
||||||
#### 4. **Card Counter**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.card_counter`
|
|
||||||
- **Description:** Play 25 hands using the Hi-Lo counting system
|
|
||||||
- **Progress:** Incremental (0-25)
|
|
||||||
- **Points:** 15
|
|
||||||
- **Icon:** `brain.head.profile`
|
|
||||||
- **Trigger:** When `settings.showCount` is enabled and hand completes
|
|
||||||
- **Implementation Note:** Must have counting enabled in settings
|
|
||||||
|
|
||||||
#### 5. **True Count Master**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.true_count_master`
|
|
||||||
- **Description:** Make 10 correct betting decisions based on true count
|
|
||||||
- **Progress:** Incremental (0-10)
|
|
||||||
- **Points:** 20
|
|
||||||
- **Icon:** `chart.line.uptrend.xyaxis`
|
|
||||||
- **Trigger:** When player increases bet with positive true count (+2 or higher)
|
|
||||||
- **Implementation Note:** Track bet increases that align with count recommendations
|
|
||||||
|
|
||||||
### 🎯 Gameplay Milestones Category
|
|
||||||
|
|
||||||
#### 6. **Natural Talent**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.natural_10`
|
|
||||||
- **Description:** Get 10 Blackjacks (natural 21)
|
|
||||||
- **Progress:** Incremental (0-10)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `star.fill`
|
|
||||||
- **Trigger:** When player hand is natural blackjack
|
|
||||||
|
|
||||||
#### 7. **Natural Legend**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.natural_50`
|
|
||||||
- **Description:** Get 50 Blackjacks
|
|
||||||
- **Progress:** Incremental (0-50)
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `star.circle.fill`
|
|
||||||
- **Trigger:** When player hand is natural blackjack (extended)
|
|
||||||
|
|
||||||
#### 8. **Split Decision**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.split_wins_10`
|
|
||||||
- **Description:** Win with split hands 10 times
|
|
||||||
- **Progress:** Incremental (0-10)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `rectangle.split.2x1.fill`
|
|
||||||
- **Trigger:** When a split hand wins against dealer
|
|
||||||
|
|
||||||
#### 9. **Double Down Dynamo**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.double_wins_15`
|
|
||||||
- **Description:** Win 15 hands after doubling down
|
|
||||||
- **Progress:** Incremental (0-15)
|
|
||||||
- **Points:** 15
|
|
||||||
- **Icon:** `arrow.down.circle.fill`
|
|
||||||
- **Trigger:** When player wins a hand after doubling down
|
|
||||||
|
|
||||||
#### 10. **Insurance Agent**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.insurance_wins_5`
|
|
||||||
- **Description:** Win insurance bet 5 times
|
|
||||||
- **Progress:** Incremental (0-5)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `shield.checkered`
|
|
||||||
- **Trigger:** When dealer has blackjack and player took insurance
|
|
||||||
|
|
||||||
### 💪 Persistence & Mastery Category
|
|
||||||
|
|
||||||
#### 11. **Rookie**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.hands_100`
|
|
||||||
- **Description:** Play 100 total hands
|
|
||||||
- **Progress:** Incremental (0-100)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `person.fill`
|
|
||||||
- **Trigger:** After each hand completes
|
|
||||||
|
|
||||||
#### 12. **Regular**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.hands_500`
|
|
||||||
- **Description:** Play 500 total hands
|
|
||||||
- **Progress:** Incremental (0-500)
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `person.2.fill`
|
|
||||||
- **Trigger:** After each hand completes
|
|
||||||
|
|
||||||
#### 13. **Veteran**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.hands_1000`
|
|
||||||
- **Description:** Play 1,000 total hands
|
|
||||||
- **Progress:** Incremental (0-1000)
|
|
||||||
- **Points:** 50
|
|
||||||
- **Icon:** `person.3.fill`
|
|
||||||
- **Trigger:** After each hand completes
|
|
||||||
|
|
||||||
#### 14. **Comeback Kid**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.comeback`
|
|
||||||
- **Description:** Recover from under $100 to $10,000 in a single session
|
|
||||||
- **Progress:** Complete when achieved
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `arrow.up.right.circle.fill`
|
|
||||||
- **Trigger:** When balance reaches $10k after being below $100 in same session
|
|
||||||
- **Implementation Note:** Track session low point
|
|
||||||
|
|
||||||
#### 15. **Perfect Pair Player**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.perfect_pairs_5`
|
|
||||||
- **Description:** Win Perfect Pairs side bet 5 times
|
|
||||||
- **Progress:** Incremental (0-5)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `suit.heart.fill`
|
|
||||||
- **Trigger:** When Perfect Pairs side bet wins
|
|
||||||
|
|
||||||
#### 16. **21+3 Champion**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.twentyone_plus_three_5`
|
|
||||||
- **Description:** Win 21+3 side bet 5 times
|
|
||||||
- **Progress:** Incremental (0-5)
|
|
||||||
- **Points:** 10
|
|
||||||
- **Icon:** `suit.diamond.fill`
|
|
||||||
- **Trigger:** When 21+3 side bet wins
|
|
||||||
|
|
||||||
### 🏆 Elite Category
|
|
||||||
|
|
||||||
#### 17. **Side Bet Specialist**
|
|
||||||
- **ID:** `com.yourdomain.blackjack.side_bet_wins_25`
|
|
||||||
- **Description:** Win any side bet 25 times total
|
|
||||||
- **Progress:** Incremental (0-25)
|
|
||||||
- **Points:** 25
|
|
||||||
- **Icon:** `plus.circle.fill`
|
|
||||||
- **Trigger:** Any time a side bet wins (Perfect Pairs or 21+3)
|
|
||||||
|
|
||||||
#### 18. **Rule Breaker** (Hidden Achievement)
|
|
||||||
- **ID:** `com.yourdomain.blackjack.all_rule_variations`
|
|
||||||
- **Description:** Play at least 10 hands with each rule variation
|
|
||||||
- **Progress:** Incremental (0-40, 10 per variation × 4 variations)
|
|
||||||
- **Points:** 15
|
|
||||||
- **Icon:** `gearshape.2.fill`
|
|
||||||
- **Trigger:** Track which rule sets have been played
|
|
||||||
- **Implementation Note:** Vegas Strip, Atlantic City, European, Custom (10 hands each)
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### 1. Define Achievement Enum
|
|
||||||
|
|
||||||
**File:** `Blackjack/Blackjack/Models/BlackjackAchievement.swift` (new file)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import CasinoKit
|
|
||||||
import GameKit
|
|
||||||
|
|
||||||
enum BlackjackAchievement: String, AchievementDefinition {
|
|
||||||
case firstHand = "first_hand"
|
|
||||||
case strategyStudent = "strategy_student"
|
|
||||||
case strategyMaster = "strategy_master"
|
|
||||||
case cardCounter = "card_counter"
|
|
||||||
case trueCountMaster = "true_count_master"
|
|
||||||
case natural10 = "natural_10"
|
|
||||||
case natural50 = "natural_50"
|
|
||||||
case splitWins10 = "split_wins_10"
|
|
||||||
case doubleWins15 = "double_wins_15"
|
|
||||||
case insuranceWins5 = "insurance_wins_5"
|
|
||||||
case hands100 = "hands_100"
|
|
||||||
case hands500 = "hands_500"
|
|
||||||
case hands1000 = "hands_1000"
|
|
||||||
case comeback = "comeback"
|
|
||||||
case perfectPairs5 = "perfect_pairs_5"
|
|
||||||
case twentyOnePlusThree5 = "twentyone_plus_three_5"
|
|
||||||
case sideBetWins25 = "side_bet_wins_25"
|
|
||||||
case allRuleVariations = "all_rule_variations"
|
|
||||||
|
|
||||||
var identifier: String {
|
|
||||||
"com.yourdomain.blackjack.\(rawValue)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .firstHand: return String(localized: "First Hand")
|
|
||||||
case .strategyStudent: return String(localized: "Strategy Student")
|
|
||||||
case .strategyMaster: return String(localized: "Strategy Master")
|
|
||||||
case .cardCounter: return String(localized: "Card Counter")
|
|
||||||
case .trueCountMaster: return String(localized: "True Count Master")
|
|
||||||
case .natural10: return String(localized: "Natural Talent")
|
|
||||||
case .natural50: return String(localized: "Natural Legend")
|
|
||||||
case .splitWins10: return String(localized: "Split Decision")
|
|
||||||
case .doubleWins15: return String(localized: "Double Down Dynamo")
|
|
||||||
case .insuranceWins5: return String(localized: "Insurance Agent")
|
|
||||||
case .hands100: return String(localized: "Rookie")
|
|
||||||
case .hands500: return String(localized: "Regular")
|
|
||||||
case .hands1000: return String(localized: "Veteran")
|
|
||||||
case .comeback: return String(localized: "Comeback Kid")
|
|
||||||
case .perfectPairs5: return String(localized: "Perfect Pair Player")
|
|
||||||
case .twentyOnePlusThree5: return String(localized: "21+3 Champion")
|
|
||||||
case .sideBetWins25: return String(localized: "Side Bet Specialist")
|
|
||||||
case .allRuleVariations: return String(localized: "Rule Breaker")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
// Full descriptions for each achievement
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxProgress: Int {
|
|
||||||
switch self {
|
|
||||||
case .firstHand, .comeback: return 1
|
|
||||||
case .insuranceWins5, .perfectPairs5, .twentyOnePlusThree5: return 5
|
|
||||||
case .splitWins10, .natural10: return 10
|
|
||||||
case .doubleWins15: return 15
|
|
||||||
case .cardCounter, .sideBetWins25, .strategyMaster: return 25
|
|
||||||
case .strategyStudent, .natural50: return 50
|
|
||||||
case .hands100: return 100
|
|
||||||
case .hands500: return 500
|
|
||||||
case .hands1000: return 1000
|
|
||||||
case .trueCountMaster: return 10
|
|
||||||
case .allRuleVariations: return 40
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isIncremental: Bool {
|
|
||||||
self != .firstHand && self != .comeback
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconName: String {
|
|
||||||
switch self {
|
|
||||||
case .firstHand: return "play.circle.fill"
|
|
||||||
case .strategyStudent: return "book.fill"
|
|
||||||
case .strategyMaster: return "graduationcap.fill"
|
|
||||||
case .cardCounter: return "brain.head.profile"
|
|
||||||
case .trueCountMaster: return "chart.line.uptrend.xyaxis"
|
|
||||||
case .natural10: return "star.fill"
|
|
||||||
case .natural50: return "star.circle.fill"
|
|
||||||
case .splitWins10: return "rectangle.split.2x1.fill"
|
|
||||||
case .doubleWins15: return "arrow.down.circle.fill"
|
|
||||||
case .insuranceWins5: return "shield.checkered"
|
|
||||||
case .hands100: return "person.fill"
|
|
||||||
case .hands500: return "person.2.fill"
|
|
||||||
case .hands1000: return "person.3.fill"
|
|
||||||
case .comeback: return "arrow.up.right.circle.fill"
|
|
||||||
case .perfectPairs5: return "suit.heart.fill"
|
|
||||||
case .twentyOnePlusThree5: return "suit.diamond.fill"
|
|
||||||
case .sideBetWins25: return "plus.circle.fill"
|
|
||||||
case .allRuleVariations: return "gearshape.2.fill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Achievement Tracker to GameState
|
|
||||||
|
|
||||||
**File:** `Blackjack/Blackjack/Engine/GameState.swift`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
class GameState {
|
|
||||||
// ... existing properties ...
|
|
||||||
|
|
||||||
// NEW: Achievement tracking
|
|
||||||
private(set) var achievementTracker: AchievementTracker<BlackjackAchievement>
|
|
||||||
|
|
||||||
// Track session-specific metrics for achievements
|
|
||||||
private var sessionLowBalance: Int = 0
|
|
||||||
private var handsPlayedThisSession = 0
|
|
||||||
private var lastActionWasStrategyOptimal = false
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// ... existing init ...
|
|
||||||
|
|
||||||
self.achievementTracker = AchievementTracker<BlackjackAchievement>()
|
|
||||||
self.sessionLowBalance = balance
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Achievement checking methods
|
|
||||||
func checkAchievements(after action: GameAction) {
|
|
||||||
// Called after each game action
|
|
||||||
// Check relevant achievements based on action type
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSessionTracking() {
|
|
||||||
if balance < sessionLowBalance {
|
|
||||||
sessionLowBalance = balance
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check comeback achievement
|
|
||||||
if sessionLowBalance < 100 && balance >= 10_000 {
|
|
||||||
achievementTracker.complete(.comeback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add Achievement Triggers
|
|
||||||
|
|
||||||
Add achievement checks throughout game logic:
|
|
||||||
|
|
||||||
**In `BlackjackEngine.swift` or `GameState.swift`:**
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// After dealing initial cards
|
|
||||||
if playerHand.isBlackjack {
|
|
||||||
achievementTracker.increment(.natural10)
|
|
||||||
achievementTracker.increment(.natural50)
|
|
||||||
}
|
|
||||||
|
|
||||||
// After completing a hand
|
|
||||||
func finishRound() {
|
|
||||||
// ... existing logic ...
|
|
||||||
|
|
||||||
// First hand achievement
|
|
||||||
if totalHandsPlayed == 1 {
|
|
||||||
achievementTracker.complete(.firstHand)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persistence achievements
|
|
||||||
achievementTracker.increment(.hands100)
|
|
||||||
achievementTracker.increment(.hands500)
|
|
||||||
achievementTracker.increment(.hands1000)
|
|
||||||
|
|
||||||
// Check if strategy was followed
|
|
||||||
if lastActionWasStrategyOptimal {
|
|
||||||
achievementTracker.increment(.strategyStudent)
|
|
||||||
achievementTracker.increment(.strategyMaster)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card counting achievements
|
|
||||||
if settings.showCount {
|
|
||||||
achievementTracker.increment(.cardCounter)
|
|
||||||
|
|
||||||
// Check if bet was adjusted based on count
|
|
||||||
if betWasIncreasedWithPositiveCount {
|
|
||||||
achievementTracker.increment(.trueCountMaster)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSessionTracking()
|
|
||||||
}
|
|
||||||
|
|
||||||
// After winning with split
|
|
||||||
func checkSplitWin() {
|
|
||||||
if handWasSplit && playerWon {
|
|
||||||
achievementTracker.increment(.splitWins10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After winning with double down
|
|
||||||
func checkDoubleWin() {
|
|
||||||
if playerDoubledDown && playerWon {
|
|
||||||
achievementTracker.increment(.doubleWins15)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After insurance win
|
|
||||||
func checkInsuranceWin() {
|
|
||||||
if insuranceBetWon {
|
|
||||||
achievementTracker.increment(.insuranceWins5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After side bet wins
|
|
||||||
func checkSideBetWins() {
|
|
||||||
if perfectPairsBetWon {
|
|
||||||
achievementTracker.increment(.perfectPairs5)
|
|
||||||
achievementTracker.increment(.sideBetWins25)
|
|
||||||
}
|
|
||||||
|
|
||||||
if twentyOnePlusThreeBetWon {
|
|
||||||
achievementTracker.increment(.twentyOnePlusThree5)
|
|
||||||
achievementTracker.increment(.sideBetWins25)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Add Game Center to Settings
|
|
||||||
|
|
||||||
**File:** `Blackjack/Blackjack/Views/Sheets/SettingsView.swift`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
|
||||||
// ... existing code ...
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
SheetContainerView(title: String(localized: "Settings")) {
|
|
||||||
// ... existing sections ...
|
|
||||||
|
|
||||||
// NEW: Game Center section
|
|
||||||
GameCenterSettingsSection()
|
|
||||||
|
|
||||||
} onDone: {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Optional: Add Game Center Access Point
|
|
||||||
|
|
||||||
**File:** `Blackjack/Blackjack/Views/Game/GameTableView.swift`
|
|
||||||
|
|
||||||
Add to top bar or as floating overlay:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
TopBarView(
|
|
||||||
balance: state.balance,
|
|
||||||
// ... existing parameters ...
|
|
||||||
leadingButtons: [
|
|
||||||
TopBarButton(
|
|
||||||
icon: "gamecontroller.fill",
|
|
||||||
accessibilityLabel: "Game Center"
|
|
||||||
) {
|
|
||||||
GameCenterManager.shared.showGameCenterDashboard()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Initialize Game Center on App Launch
|
|
||||||
|
|
||||||
**File:** `Blackjack/Blackjack/BlackjackApp.swift`
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct BlackjackApp: App {
|
|
||||||
init() {
|
|
||||||
// Authenticate with Game Center silently
|
|
||||||
Task {
|
|
||||||
await GameCenterManager.shared.authenticate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... rest of app ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Add Localized Strings
|
|
||||||
|
|
||||||
**File:** `Blackjack/Blackjack/Resources/Localizable.xcstrings`
|
|
||||||
|
|
||||||
Add all achievement titles, descriptions, and Game Center UI strings.
|
|
||||||
|
|
||||||
## App Store Connect Configuration
|
|
||||||
|
|
||||||
### For Each Achievement:
|
|
||||||
|
|
||||||
1. Log into App Store Connect
|
|
||||||
2. Select Blackjack app
|
|
||||||
3. Go to Game Center → Achievements
|
|
||||||
4. Click "+" to add achievement
|
|
||||||
5. Configure:
|
|
||||||
- **Reference Name:** (internal only, e.g., "Strategy Student")
|
|
||||||
- **Achievement ID:** Must match enum identifier (e.g., `com.yourdomain.blackjack.strategy_student`)
|
|
||||||
- **Point Value:** As specified above
|
|
||||||
- **Hidden:** Set to "Yes" for hidden achievements (Rule Breaker)
|
|
||||||
- **Achievable More Than Once:** No for all
|
|
||||||
- **Pre-iOS 14 Gamers:** Leave unchecked
|
|
||||||
- **Localization:** Add EN, ES-MX, FR-CA with titles/descriptions
|
|
||||||
- **Images:** Upload 512x512 and 1024x1024 versions
|
|
||||||
|
|
||||||
### Achievement Icon Assets
|
|
||||||
|
|
||||||
Create icons matching the SF Symbols specified in the achievement enum. Use your app's color scheme (golds, blues, casino theme).
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] All achievements can be triggered in game
|
|
||||||
- [ ] Incremental achievements show progress correctly
|
|
||||||
- [ ] Achievements appear in Game Center dashboard
|
|
||||||
- [ ] Achievements work offline (queue and submit later)
|
|
||||||
- [ ] Achievement tracking persists across app launches
|
|
||||||
- [ ] Localization works for all three languages
|
|
||||||
- [ ] VoiceOver announces achievement unlocks
|
|
||||||
- [ ] Settings section shows correct Game Center status
|
|
||||||
- [ ] Strategy-based achievements verify correct play
|
|
||||||
- [ ] Card counting achievements only trigger when enabled
|
|
||||||
|
|
||||||
## Edge Cases to Handle
|
|
||||||
|
|
||||||
1. **Player resets game mid-session** - Session achievements (comeback) should reset
|
|
||||||
2. **Player changes rule variations** - Track separately for "Rule Breaker"
|
|
||||||
3. **Player disables hints** - Strategy achievements still trackable via engine logic
|
|
||||||
4. **Offline play** - Queue achievements, submit when online
|
|
||||||
5. **Multiple devices** - Game Center syncs achievement progress automatically
|
|
||||||
|
|
||||||
## Analytics to Track (Optional)
|
|
||||||
|
|
||||||
Consider adding local analytics to understand achievement engagement:
|
|
||||||
- Which achievements are earned most often?
|
|
||||||
- Average time to complete each achievement
|
|
||||||
- Percentage of players who enable card counting (for targeted education)
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- **Achievement notifications** - Show toast when unlocked (use CasinoKit's AchievementToast)
|
|
||||||
- **Progress tracking UI** - Show achievement progress in stats view
|
|
||||||
- **Suggested next achievement** - Guide players toward specific goals
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- ✅ 18 total achievements
|
|
||||||
- ✅ Focus on learning and skill, not luck/bankroll
|
|
||||||
- ✅ Incremental progress for most achievements
|
|
||||||
- ✅ Hidden achievement for exploring all rule variations
|
|
||||||
- ✅ Integration with existing GameState and statistics
|
|
||||||
- ✅ No code duplication (uses CasinoKit infrastructure)
|
|
||||||
|
|
||||||
**Estimated Integration Time:** 2-3 hours after CasinoKit infrastructure is complete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*See `CasinoKit/GAME_CENTER_PLAN.md` for shared infrastructure implementation details.*
|
|
||||||
|
|
||||||
@ -1,364 +0,0 @@
|
|||||||
# Game Center Integration - CasinoKit (Shared Infrastructure)
|
|
||||||
|
|
||||||
This document outlines the shared Game Center infrastructure that will be used by all casino games in the workspace.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Game Center will be integrated for **achievements only** (no leaderboards) to avoid cheating concerns with client-side casino games. All shared Game Center logic will live in CasinoKit to avoid code duplication.
|
|
||||||
|
|
||||||
## Shared Components to Add
|
|
||||||
|
|
||||||
### 1. GameCenterManager (Core Service)
|
|
||||||
|
|
||||||
**Location:** `CasinoKit/Sources/CasinoKit/GameCenter/GameCenterManager.swift`
|
|
||||||
|
|
||||||
**Purpose:** Centralized manager for all Game Center operations.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Authentication with Game Center
|
|
||||||
- Achievement submission
|
|
||||||
- Achievement progress tracking
|
|
||||||
- Error handling and retry logic
|
|
||||||
- Availability checking
|
|
||||||
- Privacy-friendly (doesn't force sign-in)
|
|
||||||
|
|
||||||
**Key Methods:**
|
|
||||||
```swift
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
class GameCenterManager {
|
|
||||||
static let shared = GameCenterManager()
|
|
||||||
|
|
||||||
var isAuthenticated: Bool = false
|
|
||||||
var isAvailable: Bool = true
|
|
||||||
var localPlayer: GKLocalPlayer?
|
|
||||||
|
|
||||||
// Authentication
|
|
||||||
func authenticate()
|
|
||||||
|
|
||||||
// Achievement management
|
|
||||||
func submitAchievement(_ identifier: String, percentComplete: Double)
|
|
||||||
func incrementAchievement(_ identifier: String, by value: Double)
|
|
||||||
func resetAchievements() // For testing only
|
|
||||||
func loadAchievements() -> [GKAchievement]
|
|
||||||
|
|
||||||
// UI presentation
|
|
||||||
func showGameCenterDashboard()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation Notes:**
|
|
||||||
- Use async/await APIs (iOS 14+)
|
|
||||||
- Silent authentication (no blocking UI)
|
|
||||||
- Gracefully handle Game Center being disabled
|
|
||||||
- Cache authentication state
|
|
||||||
- Support offline mode (queue submissions for later)
|
|
||||||
|
|
||||||
### 2. Achievement Configuration Protocol
|
|
||||||
|
|
||||||
**Location:** `CasinoKit/Sources/CasinoKit/GameCenter/AchievementDefinition.swift`
|
|
||||||
|
|
||||||
**Purpose:** Type-safe achievement definitions per game.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
protocol AchievementDefinition {
|
|
||||||
var identifier: String { get }
|
|
||||||
var title: String { get }
|
|
||||||
var description: String { get }
|
|
||||||
var maxProgress: Int { get }
|
|
||||||
var iconName: String { get }
|
|
||||||
var isIncremental: Bool { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example usage in game:
|
|
||||||
enum BlackjackAchievement: String, AchievementDefinition {
|
|
||||||
case strategyStudent = "blackjack_strategy_50"
|
|
||||||
case cardCounter = "blackjack_card_counting_25"
|
|
||||||
// ...
|
|
||||||
|
|
||||||
var identifier: String {
|
|
||||||
"com.yourdomain.blackjack.\(rawValue)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .strategyStudent: return String(localized: "Strategy Student")
|
|
||||||
case .cardCounter: return String(localized: "Card Counter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... other properties
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Game Center Access Point View
|
|
||||||
|
|
||||||
**Location:** `CasinoKit/Sources/CasinoKit/Views/GameCenter/GameCenterAccessPoint.swift`
|
|
||||||
|
|
||||||
**Purpose:** Standard UI component for showing Game Center status.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Shows authentication state
|
|
||||||
- Displays achievement progress
|
|
||||||
- Links to Game Center dashboard
|
|
||||||
- Optional placement (top bar or settings)
|
|
||||||
- Respects user privacy preferences
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct GameCenterAccessPoint: View {
|
|
||||||
@State private var manager = GameCenterManager.shared
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// Small floating button or status indicator
|
|
||||||
// Shows GC icon when authenticated
|
|
||||||
// Tapping opens achievement list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Achievement Tracking Helper
|
|
||||||
|
|
||||||
**Location:** `CasinoKit/Sources/CasinoKit/GameCenter/AchievementTracker.swift`
|
|
||||||
|
|
||||||
**Purpose:** Helper for games to track achievement progress locally before submitting.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
class AchievementTracker<Achievement: AchievementDefinition> {
|
|
||||||
private var progress: [String: Int] = [:]
|
|
||||||
|
|
||||||
func increment(_ achievement: Achievement, by value: Int = 1)
|
|
||||||
func getProgress(_ achievement: Achievement) -> Int
|
|
||||||
func checkAndSubmit(_ achievement: Achievement)
|
|
||||||
func reset() // For new game sessions
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why This Helps:**
|
|
||||||
- Games can track progress locally (fast)
|
|
||||||
- Batch submissions to Game Center (efficient)
|
|
||||||
- Handles the "report once at 100%" logic for non-incremental achievements
|
|
||||||
|
|
||||||
### 5. Settings Integration
|
|
||||||
|
|
||||||
**Location:** `CasinoKit/Sources/CasinoKit/Views/Settings/GameCenterSettingsSection.swift`
|
|
||||||
|
|
||||||
**Purpose:** Standard settings UI for Game Center.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct GameCenterSettingsSection: View {
|
|
||||||
@State private var manager = GameCenterManager.shared
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
SheetSection(title: "GAME CENTER", icon: "gamecontroller.fill") {
|
|
||||||
if manager.isAvailable {
|
|
||||||
HStack {
|
|
||||||
Text("Status")
|
|
||||||
Spacer()
|
|
||||||
Text(manager.isAuthenticated ? "Connected" : "Not Connected")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if manager.isAuthenticated {
|
|
||||||
Button("View Achievements") {
|
|
||||||
manager.showGameCenterDashboard()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Sign Out") {
|
|
||||||
// Note: Can't actually sign out from app,
|
|
||||||
// just stop authenticating
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button("Connect to Game Center") {
|
|
||||||
Task { await manager.authenticate() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("Game Center is not available")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Achievement Toast Notification
|
|
||||||
|
|
||||||
**Location:** `CasinoKit/Sources/CasinoKit/Views/GameCenter/AchievementToast.swift`
|
|
||||||
|
|
||||||
**Purpose:** Show celebratory notification when achievement is earned (optional enhancement).
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Brief animation when achievement unlocks
|
|
||||||
- Shows achievement icon and title
|
|
||||||
- Auto-dismisses after 3 seconds
|
|
||||||
- Doesn't block gameplay
|
|
||||||
- Similar to iOS system notifications
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct AchievementToast: View {
|
|
||||||
let achievement: String
|
|
||||||
let title: String
|
|
||||||
@State private var isShowing = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// Slide-in notification from top
|
|
||||||
// Shows achievement icon + title
|
|
||||||
// Fades out after delay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
CasinoKit/Sources/CasinoKit/
|
|
||||||
├── GameCenter/
|
|
||||||
│ ├── GameCenterManager.swift # Core manager (authentication, submission)
|
|
||||||
│ ├── AchievementDefinition.swift # Protocol for type-safe achievements
|
|
||||||
│ ├── AchievementTracker.swift # Local progress tracking helper
|
|
||||||
│ └── GameCenterError.swift # Custom error types
|
|
||||||
├── Views/
|
|
||||||
│ └── GameCenter/
|
|
||||||
│ ├── GameCenterAccessPoint.swift # Status indicator/button
|
|
||||||
│ ├── GameCenterSettingsSection.swift # Settings UI component
|
|
||||||
│ └── AchievementToast.swift # Achievement unlock notification
|
|
||||||
└── Resources/
|
|
||||||
└── Localizable.xcstrings # Add GC-related strings
|
|
||||||
```
|
|
||||||
|
|
||||||
## Localization Strings to Add
|
|
||||||
|
|
||||||
Add to `CasinoKit/Resources/Localizable.xcstrings`:
|
|
||||||
|
|
||||||
```
|
|
||||||
Game Center
|
|
||||||
Connected
|
|
||||||
Not Connected
|
|
||||||
Sign Out
|
|
||||||
View Achievements
|
|
||||||
Game Center is not available
|
|
||||||
Achievement Unlocked!
|
|
||||||
Connect to Game Center
|
|
||||||
```
|
|
||||||
|
|
||||||
## App Store Connect Configuration
|
|
||||||
|
|
||||||
**Note:** Each game will need its own achievements configured in App Store Connect.
|
|
||||||
|
|
||||||
### Achievement Naming Convention
|
|
||||||
|
|
||||||
Use consistent identifier format:
|
|
||||||
```
|
|
||||||
com.yourdomain.{game}.{achievement_key}
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
com.yourdomain.blackjack.strategy_student
|
|
||||||
com.yourdomain.blackjack.card_counter
|
|
||||||
com.yourdomain.baccarat.dragon_master
|
|
||||||
com.yourdomain.baccarat.natural_high
|
|
||||||
```
|
|
||||||
|
|
||||||
### Achievement Assets
|
|
||||||
|
|
||||||
Each achievement needs:
|
|
||||||
- 512x512px icon (1x)
|
|
||||||
- 1024x1024px icon (2x)
|
|
||||||
|
|
||||||
**Design Guidelines:**
|
|
||||||
- Use SF Symbols where appropriate (consistent with app design)
|
|
||||||
- Match app color scheme
|
|
||||||
- Clear, recognizable icons
|
|
||||||
- Consider accessibility (high contrast)
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. **GameCenterManager** - Core authentication and submission logic
|
|
||||||
2. **AchievementDefinition protocol** - Type system
|
|
||||||
3. **AchievementTracker** - Helper for games to use
|
|
||||||
4. **GameCenterSettingsSection** - UI integration
|
|
||||||
5. **GameCenterAccessPoint** - Optional status indicator
|
|
||||||
6. **AchievementToast** - Optional enhancement
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Local Testing
|
|
||||||
- Test with Game Center sandbox account
|
|
||||||
- Verify authentication flow
|
|
||||||
- Test achievement submission
|
|
||||||
- Test offline behavior
|
|
||||||
- Test achievement progress tracking
|
|
||||||
|
|
||||||
### TestFlight Testing
|
|
||||||
- Required for full Game Center integration testing
|
|
||||||
- Verify achievements appear correctly
|
|
||||||
- Test achievement notifications
|
|
||||||
- Verify localization
|
|
||||||
|
|
||||||
### Debug Features to Add
|
|
||||||
```swift
|
|
||||||
#if DEBUG
|
|
||||||
extension GameCenterManager {
|
|
||||||
func resetAllAchievements() {
|
|
||||||
// Only available in debug builds
|
|
||||||
}
|
|
||||||
|
|
||||||
func logAchievementStatus() {
|
|
||||||
// Print all achievement progress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
## Privacy Considerations
|
|
||||||
|
|
||||||
- **No forced sign-in** - Game Center is entirely optional
|
|
||||||
- **Graceful degradation** - App works fully without Game Center
|
|
||||||
- **No data collection** - Only submit achievement progress, nothing else
|
|
||||||
- **User control** - Easy to see status and disconnect
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- **Authenticate once** - On app launch, silent background auth
|
|
||||||
- **Batch submissions** - Don't submit every increment immediately
|
|
||||||
- **Cache state** - Remember authentication status
|
|
||||||
- **Async operations** - Never block UI on Game Center calls
|
|
||||||
- **Offline queue** - Store failed submissions, retry later
|
|
||||||
|
|
||||||
## Future Enhancements (Optional)
|
|
||||||
|
|
||||||
- **Challenge Mode** - Separate game mode with fixed rules and leaderboards
|
|
||||||
- **Friend Comparison** - Show achievement progress vs friends
|
|
||||||
- **Weekly Challenges** - Time-limited achievement variants
|
|
||||||
- **Leaderboards** - Only if Challenge Mode is added with anti-cheat
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **GameKit framework** - Apple's Game Center SDK
|
|
||||||
- **No third-party dependencies** - Pure Apple APIs
|
|
||||||
|
|
||||||
## Estimated Effort
|
|
||||||
|
|
||||||
- **CasinoKit infrastructure**: 4-6 hours
|
|
||||||
- **Per-game integration**: 2-3 hours each
|
|
||||||
- **App Store Connect setup**: 1-2 hours
|
|
||||||
- **Testing & polish**: 2-3 hours
|
|
||||||
|
|
||||||
**Total: ~12-16 hours** for complete implementation across both games.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Review this plan
|
|
||||||
2. See game-specific plans in `Blackjack/GAME_CENTER_PLAN.md` and `Baccarat/GAME_CENTER_PLAN.md`
|
|
||||||
3. Configure achievements in App Store Connect
|
|
||||||
4. Implement CasinoKit infrastructure first
|
|
||||||
5. Integrate into games second
|
|
||||||
6. Test with TestFlight
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This shared infrastructure approach ensures consistency across all casino games while avoiding code duplication.*
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user